wallpaper-engine-dsh 1.0.4 → 1.0.6

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/README.md CHANGED
@@ -10,7 +10,7 @@ A DSH bundle that turns your local **Wallpaper Engine** library into the live ba
10
10
 
11
11
  **Legibility on any wallpaper.** Text stays readable by construction, not by luck. The veil always opposes the text colour — black in the dark theme, white in the light theme — so neither a pitch-black nor a pure-white wallpaper can swallow the UI. On top of that, a Smart veil samples the wallpaper's brightness every few seconds and automatically strengthens the veil when a scene drifts too bright (or too dark) for the current theme; it also strengthens the border contrast and the glass frosting on the same evidence. Dropdowns, menus, and dialogs sit on DSH's opaque surfaces and are never affected.
12
12
 
13
- **Manual framing.** Pick a base layout — Cover, Contain, Stretch, or Original — then frame the shot yourself. **Adjust** opens the live wallpaper fullscreen: scroll to zoom in (up to 4×), drag to pan within the zoomed framing — the crop is clamped so the image always fills the screen with no exposed edges. Esc or Done finishes, the framing persists, and **Reset crop** restores the default in one click. Web wallpapers take the zoom/pan framing too; the base-layout select applies to video and stills and hides itself for web.
13
+ **Manual framing.** Pick a base layout — Cover, Contain, Stretch, Original, or Free — then frame the shot yourself. **Adjust** opens the live wallpaper fullscreen: scroll to zoom, drag to pan. In the first four layouts the crop is clamped so the image always fills the screen with no exposed edges; **Free** places the wallpaper at natural size on a black canvas where it can be dragged anywhere, zoomed from 0.1× to 4×, and rotated to any angle (±90° buttons or Alt+drag), with just enough kept on screen that it can never be dragged out of sight. **Mirror** flips the wallpaper horizontally and works in every layout — a flip never exposes an edge. Esc or Done finishes, the framing persists, and **Reset crop** restores the default in one click. Web wallpapers take the same select and the same zoom/pan/rotate framing — the transform applies to the iframe directly.
14
14
 
15
15
  **Four live sliders.** Wallpaper blur, Scrim, Borders, and Glass — every change lands instantly and persists. Blur renders in place: composition, layout, and crop stay identical at every blur level, and within one blur radius of the screen edge the picture fades softly into the veil beneath the UI chrome. A whisper of dither noise keeps smooth gradients band-free; at zero blur the media bypasses the filter pipeline entirely for bit-exact pixels.
16
16
 
package/README.zh.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  **任何壁纸上文字都可读。** 遮罩颜色始终与文字颜色相反:深色主题用黑色遮罩,浅色主题用白色遮罩,因此过亮或过暗的壁纸都不会盖住文字。在此基础上,「智能可视」每隔几秒采样一次壁纸亮度,亮度过高或过低时自动加强遮罩。下拉菜单、弹窗等浮层显示在 DSH 原生的不透明表面上,不受影响。
12
12
 
13
- **手动取景。** 先选基础排布(填充、适应、拉伸、原始),再手动调整画面:点「调整画面」将壁纸全屏展开,滚轮放大(最高 4×),拖动在放大后的画面内平移。裁剪范围经过限制,画面始终铺满屏幕,不露出边缘。按 Esc 或点「完成」退出,取景设置会保存,「重置裁剪」可一键恢复默认。网页壁纸同样支持缩放与平移;排布下拉只对视频与静态图生效,网页壁纸时自动隐藏。
13
+ **手动取景。** 先选基础排布(填充、适应、拉伸、原始、自由),再手动调整画面:点「调整画面」将壁纸全屏展开,滚轮缩放,拖动平移。前四种排布下裁剪范围经过限制,画面始终铺满屏幕,不露出边缘;「自由」把壁纸按原始大小放在黑色画布上,可拖动到任意位置,缩放范围 0.1–4×,并可旋转任意角度(±90° 按钮或 Alt+拖动),同时始终保留一小块在屏幕内,不会拖丢。「镜像」水平翻转壁纸,所有排布下都可用——翻转不会破坏铺满。按 Esc 或点「完成」退出,取景设置会保存,「重置裁剪」可一键恢复默认。网页壁纸使用相同的排布下拉与缩放/平移/旋转取景(变换直接作用于 iframe)。
14
14
 
15
15
  **四个实时滑杆。** 壁纸模糊、暗化、边框、玻璃,每一步调节立即生效并保存。模糊在原位渲染:任意模糊度下,画面的比例、排布与取景完全一致;屏幕边缘一个模糊半径内的画面向遮罩过渡,被界面边饰覆盖,通常不可见。模糊时叠加轻微噪声,消除渐变色带;模糊为零时媒体完全绕过滤镜管线,像素保持不变。
16
16
 
package/lib/client.js CHANGED
@@ -91,9 +91,16 @@ window.__ModuleLoader__.load({
91
91
  fitContain: "适应",
92
92
  fitFill: "拉伸",
93
93
  fitNone: "原始",
94
+ fitFree: "自由",
94
95
  adjust: "调整画面",
95
96
  adjustDone: "完成",
96
97
  adjustHint: "拖动平移 · 滚轮缩放 · Esc 或点「完成」退出",
98
+ adjustHintFree: "拖动平移 · 滚轮缩放 · Alt+拖动旋转 · Esc 或点「完成」退出",
99
+ mirror: "镜像",
100
+ rotateCcw: "⟲",
101
+ rotateCw: "⟳",
102
+ rotateCcwTitle: "逆时针旋转 90°",
103
+ rotateCwTitle: "顺时针旋转 90°",
97
104
  resetCrop: "重置裁剪",
98
105
  statusGroup: (name, total, usable, interval, order) =>
99
106
  "列表「" + name + "」:" + total + " 项 · " + usable + " 可用 · 每 " + interval + " 分钟 · " + order,
@@ -168,9 +175,16 @@ window.__ModuleLoader__.load({
168
175
  fitContain: "Contain",
169
176
  fitFill: "Stretch",
170
177
  fitNone: "Original",
178
+ fitFree: "Free",
171
179
  adjust: "Adjust",
172
180
  adjustDone: "Done",
173
181
  adjustHint: "Drag to pan · wheel to zoom · Esc or Done to exit",
182
+ adjustHintFree: "Drag to pan · wheel to zoom · Alt+drag to rotate · Esc or Done to exit",
183
+ mirror: "Mirror",
184
+ rotateCcw: "⟲",
185
+ rotateCw: "⟳",
186
+ rotateCcwTitle: "Rotate 90° counterclockwise",
187
+ rotateCwTitle: "Rotate 90° clockwise",
174
188
  resetCrop: "Reset crop",
175
189
  statusGroup: (name, total, usable, interval, order) =>
176
190
  "List \"" + name + "\": " + total + " items · " + usable + " usable · every " + interval + " min · " + order,
@@ -224,9 +238,11 @@ window.__ModuleLoader__.load({
224
238
  zoom: 1,
225
239
  offsetX: 0,
226
240
  offsetY: 0,
241
+ rotation: 0, // free-fit only; degrees, positive = clockwise
242
+ mirrored: false, // horizontal flip, available in every fit mode
227
243
  };
228
244
 
229
- const FIT_MODES = ["cover", "contain", "fill", "none"];
245
+ const FIT_MODES = ["cover", "contain", "fill", "none", "free"];
230
246
 
231
247
  // ── Persisted selection ─────────────────────────────────────────────────────
232
248
  function clampNum(v, lo, hi, fallback) {
@@ -273,11 +289,14 @@ window.__ModuleLoader__.load({
273
289
  autoScrim: o.autoScrim !== false,
274
290
  lang: o.lang === "zh" || o.lang === "en" ? o.lang : "auto",
275
291
  fit: FIT_MODES.indexOf(o.fit) >= 0 ? o.fit : DEFAULTS.fit,
276
- // Older builds could persist zoom < 1 / unclamped pans — clampCrop()
277
- // at store creation re-seats both into the gap-free envelope.
278
- zoom: clampNum(o.zoom, 0.25, 4, DEFAULTS.zoom),
279
- offsetX: clampNum(o.offsetX, -200, 200, DEFAULTS.offsetX),
280
- offsetY: clampNum(o.offsetY, -200, 200, DEFAULTS.offsetY),
292
+ // Older builds could persist unclamped crops — clampCrop() at store
293
+ // creation re-seats both into the envelope the CURRENT fit mode
294
+ // allows (free: zoom ≥ 0.1 with keep-visible pans; others: gap-free).
295
+ zoom: clampNum(o.zoom, 0.1, 4, DEFAULTS.zoom),
296
+ offsetX: clampNum(o.offsetX, -300, 300, DEFAULTS.offsetX),
297
+ offsetY: clampNum(o.offsetY, -300, 300, DEFAULTS.offsetY),
298
+ rotation: clampNum(o.rotation, -1080, 1080, DEFAULTS.rotation),
299
+ mirrored: o.mirrored === true,
281
300
  };
282
301
  } catch {
283
302
  return { id: "", ...DEFAULTS };
@@ -302,8 +321,8 @@ window.__ModuleLoader__.load({
302
321
  inventory: { installDir: null, wallpapers: [], total: 0, portableCount: 0, playlists: [], error: null },
303
322
  loaded: false,
304
323
  };
305
- // Older builds allowed zoom < 1 and unclamped pans — re-seat any persisted
306
- // crop into the gap-free envelope before the first render.
324
+ // Re-seat any persisted crop into the envelope the persisted fit mode
325
+ // allows before the first render.
307
326
  clampCrop();
308
327
 
309
328
  const listeners = new Set();
@@ -336,6 +355,8 @@ window.__ModuleLoader__.load({
336
355
  zoom: selection.zoom,
337
356
  offsetX: selection.offsetX,
338
357
  offsetY: selection.offsetY,
358
+ rotation: selection.rotation,
359
+ mirrored: selection.mirrored,
339
360
  }));
340
361
  } catch { /* storage full / blocked → session-only state, not fatal */ }
341
362
  }
@@ -729,17 +750,47 @@ window.__ModuleLoader__.load({
729
750
  // never receive pointer events. Adjust mode moves the LIVE layer node into a
730
751
  // topmost transparent overlay — the user edits the real thing, WYSIWYG.
731
752
  //
732
- // Crop geometry invariants: the media box covers the viewport exactly at
733
- // zoom 1; zooming creates headroom, panning spends it. Zoom floors at 1
734
- // and pan is clamped to ±(zoom-1)/2·100 %, so a gap-free fullscreen image
735
- // is guaranteed at every setting.
753
+ // Crop geometry is chosen by the fit mode. The four object-fit modes keep a
754
+ // GAP-FREE invariant: the media box covers the viewport exactly at zoom 1,
755
+ // zoom floors at 1, pan is clamped to ±(zoom−1)/2·100 %, and rotation is
756
+ // forced to 0 — a rotated rectangle cannot cover the screen, so turning
757
+ // belongs to free mode only. Mirroring is allowed everywhere: a flip is
758
+ // symmetric and never breaks coverage. The "free" mode drops the gap-free
759
+ // invariant on purpose: the media sits at natural size on a black canvas,
760
+ // zoom runs 0.1–4×, rotation is arbitrary, and pan is only clamped to keep
761
+ // a sliver (≥10 % of the box) on screen so the wallpaper can never be
762
+ // dragged entirely out of sight.
736
763
  function clampCrop() {
737
- selection.zoom = Math.round(Math.min(4, Math.max(1, selection.zoom)) * 100) / 100;
764
+ if (!isFreeFit()) selection.rotation = 0;
765
+ else {
766
+ // Keep the stored angle canonical (−180, 180]: Alt+drag can accumulate
767
+ // several full turns, and persisting raw degrees would come back from
768
+ // storage truncated at the ±1080 read clamp — a DIFFERENT visual angle.
769
+ // Modulo keeps every equivalent representation identical on disk.
770
+ selection.rotation = ((selection.rotation + 180) % 360 + 360) % 360 - 180;
771
+ }
772
+ selection.zoom = Math.round(Math.min(4, Math.max(isFreeFit() ? 0.1 : 1, selection.zoom)) * 100) / 100;
773
+ if (isFreeFit()) {
774
+ // Content spans ±zoom·50 % of the box around its centre; the pan offset
775
+ // shifts it by its own % of the box. Rotation expands the projected
776
+ // extent by k = |cos θ| + |sin θ| (up to √2 at 45°). |offset| ≤
777
+ // (zoom·k+1)·50 − 10 keeps ≥10 % of the media visible in both axes.
778
+ const rad = selection.rotation * Math.PI / 180;
779
+ const k = Math.abs(Math.cos(rad)) + Math.abs(Math.sin(rad));
780
+ const reach = (selection.zoom * k + 1) * 50 - 10;
781
+ selection.offsetX = Math.min(reach, Math.max(-reach, selection.offsetX));
782
+ selection.offsetY = Math.min(reach, Math.max(-reach, selection.offsetY));
783
+ return;
784
+ }
738
785
  const pan = (selection.zoom - 1) * 50;
739
786
  selection.offsetX = Math.min(pan, Math.max(-pan, selection.offsetX));
740
787
  selection.offsetY = Math.min(pan, Math.max(-pan, selection.offsetY));
741
788
  }
742
789
 
790
+ function isFreeFit() {
791
+ return selection.fit === "free";
792
+ }
793
+
743
794
  let adjustOverlay = null;
744
795
  let adjustCleanup = null;
745
796
 
@@ -767,12 +818,31 @@ window.__ModuleLoader__.load({
767
818
  bar.className = "webg-adjust-bar";
768
819
  const hint = document.createElement("span");
769
820
  hint.className = "webg-adjust-hint";
770
- hint.textContent = S().adjustHint;
821
+ hint.textContent = isFreeFit() ? S().adjustHintFree : S().adjustHint;
771
822
  const done = document.createElement("button");
772
823
  done.type = "button";
773
824
  done.className = "webg-btn webg-btn--primary";
774
825
  done.textContent = S().adjustDone;
775
826
  bar.appendChild(hint);
827
+ // Free fit only: a rotated rectangle cannot cover the screen, so turning
828
+ // exists solely where exposed edges are allowed. ±90° buttons cover the
829
+ // common cases; arbitrary angles come from Alt+drag below.
830
+ if (isFreeFit()) {
831
+ for (const dir of [-90, 90]) {
832
+ const rot = document.createElement("button");
833
+ rot.type = "button";
834
+ rot.className = "webg-btn";
835
+ rot.textContent = dir < 0 ? S().rotateCcw : S().rotateCw;
836
+ rot.title = dir < 0 ? S().rotateCcwTitle : S().rotateCwTitle;
837
+ rot.addEventListener("click", () => {
838
+ selection.rotation += dir;
839
+ clampCrop();
840
+ applyEffects();
841
+ persistSelection();
842
+ });
843
+ bar.appendChild(rot);
844
+ }
845
+ }
776
846
  bar.appendChild(done);
777
847
  overlay.appendChild(bar);
778
848
  document.body.appendChild(overlay);
@@ -782,14 +852,34 @@ window.__ModuleLoader__.load({
782
852
  // translate() in the transform list is applied AFTER scale() (right-to-
783
853
  // left), so pan deltas are independent of zoom: dx px = dx/width %.
784
854
  let drag = null;
855
+ // Alt+drag (free fit): swing the media around the overlay's centre —
856
+ // the angle delta between pointer positions becomes the rotation delta.
857
+ let rot = null; // { a0: starting pointer angle, base: starting rotation }
858
+ const centerAngle = (e) => {
859
+ const cx = (overlay.clientWidth || 1) / 2;
860
+ const cy = (overlay.clientHeight || 1) / 2;
861
+ return Math.atan2(e.clientY - cy, e.clientX - cx);
862
+ };
785
863
  const onPointerDown = (e) => {
786
864
  if (e.target === done || bar.contains(e.target)) return;
787
- drag = { x: e.clientX, y: e.clientY };
865
+ if (e.altKey && isFreeFit()) {
866
+ rot = { a0: centerAngle(e), base: selection.rotation };
867
+ } else {
868
+ drag = { x: e.clientX, y: e.clientY };
869
+ }
870
+ // Capture for EITHER gesture: pointer moves must keep arriving even
871
+ // when the cursor leaves the window mid-swing.
788
872
  if (overlay.setPointerCapture && e.pointerId !== undefined) {
789
873
  try { overlay.setPointerCapture(e.pointerId); } catch {}
790
874
  }
791
875
  };
792
876
  const onPointerMove = (e) => {
877
+ if (rot) {
878
+ selection.rotation = rot.base + (centerAngle(e) - rot.a0) * 180 / Math.PI;
879
+ clampCrop();
880
+ applyEffects();
881
+ return;
882
+ }
793
883
  if (!drag) return;
794
884
  // translate % refers to the media box, which is the viewport exactly —
795
885
  // divide the pixel delta by it so the content tracks the cursor 1:1.
@@ -797,28 +887,26 @@ window.__ModuleLoader__.load({
797
887
  const h = overlay.clientHeight || 1;
798
888
  selection.offsetX += ((e.clientX - drag.x) / w) * 100;
799
889
  selection.offsetY += ((e.clientY - drag.y) / h) * 100;
800
- clampCrop(); // gap-free guarantee: pan never outruns the zoom headroom
890
+ clampCrop(); // mode envelope: gap-free pan headroom, or free keep-visible
801
891
  drag = { x: e.clientX, y: e.clientY };
802
892
  applyEffects();
803
893
  };
804
- const onPointerUp = () => {
894
+ const endPointer = () => {
895
+ if (rot) { rot = null; persistSelection(); return; }
805
896
  if (!drag) return;
806
897
  drag = null;
807
898
  persistSelection();
808
899
  };
900
+ const onPointerUp = endPointer;
809
901
  // pointercancel (touch gesture takeover): end the drag without losing the
810
902
  // current offsets — otherwise drag stays "stuck" until the next pointerup.
811
- const onPointerCancel = () => {
812
- if (!drag) return;
813
- drag = null;
814
- persistSelection();
815
- };
903
+ const onPointerCancel = endPointer;
816
904
  const onWheel = (e) => {
817
905
  if (typeof e.preventDefault === "function") e.preventDefault();
818
- // Crop semantics: zoom IN only (1–4×). Zooming below cover would float a
819
- // shrunken box in empty space — that is not a crop. Tightening zoom also
820
- // re-clamps the pan into the new headroom, so zooming back to 1 pulls
821
- // the framing home instead of leaving edge gaps.
906
+ // clampCrop enforces the mode's zoom floor: the gap-free modes never
907
+ // zoom below 1 (a shrunken box floating in empty space is not a crop)
908
+ // and tightening zoom re-clamps the pan so zooming back to 1 pulls the
909
+ // framing home; free mode shrinks down to 0.1× on its black canvas.
822
910
  selection.zoom *= Math.exp(-(e.deltaY || 0) * 0.0012);
823
911
  clampCrop();
824
912
  applyEffects();
@@ -851,6 +939,7 @@ window.__ModuleLoader__.load({
851
939
  selection.zoom = 1;
852
940
  selection.offsetX = 0;
853
941
  selection.offsetY = 0;
942
+ selection.rotation = 0; // framing geometry home; mirroring keeps its own toggle
854
943
  persistSelection();
855
944
  applyEffects();
856
945
  emit();
@@ -1009,7 +1098,7 @@ window.__ModuleLoader__.load({
1009
1098
  const key = [
1010
1099
  dark, selection.scrim, selection.border, selection.blur, selection.wallpaperBlur,
1011
1100
  selection.autoScrim, sampledLuminance, selection.fit, selection.zoom,
1012
- selection.offsetX, selection.offsetY,
1101
+ selection.offsetX, selection.offsetY, selection.rotation, selection.mirrored,
1013
1102
  ].join("\u0000");
1014
1103
  if (key === lastEffectKey) return; // nothing it reads changed → no writes, no reflow
1015
1104
  lastEffectKey = key;
@@ -1044,11 +1133,16 @@ window.__ModuleLoader__.load({
1044
1133
  selection.wallpaperBlur > 0 ? (0.02 + selection.wallpaperBlur * 0.0004).toFixed(4) : "0");
1045
1134
  // Canvas fit: object-fit base mode plus the user's manual crop — pan
1046
1135
  // offsets (percent of the media box) and zoom, applied as a transform so
1047
- // no remount is ever needed while dragging.
1048
- s.setProperty("--webg-fit", selection.fit);
1136
+ // no remount is ever needed while dragging. "free" draws at natural size
1137
+ // (object-fit: none) and paints the layer black, so exposed edges read as
1138
+ // a canvas instead of whatever sits behind the app frame.
1139
+ s.setProperty("--webg-fit", selection.fit === "free" ? "none" : selection.fit);
1140
+ s.setProperty("--webg-layer-bg", selection.fit === "free" ? "#000" : "transparent");
1049
1141
  s.setProperty("--webg-zoom", String(selection.zoom));
1050
1142
  s.setProperty("--webg-offset-x", selection.offsetX + "%");
1051
1143
  s.setProperty("--webg-offset-y", selection.offsetY + "%");
1144
+ s.setProperty("--webg-rotate", selection.rotation + "deg");
1145
+ s.setProperty("--webg-mirror", selection.mirrored ? "-1" : "1");
1052
1146
 
1053
1147
  // Write the scrim colour directly too, then force a synchronous layout, so
1054
1148
  // slider feedback lands on this frame even on stalled compositors.
@@ -1071,9 +1165,12 @@ window.__ModuleLoader__.load({
1071
1165
  s.removeProperty("--webg-noise-display");
1072
1166
  s.removeProperty("--webg-noise-opacity");
1073
1167
  s.removeProperty("--webg-fit");
1168
+ s.removeProperty("--webg-layer-bg");
1074
1169
  s.removeProperty("--webg-zoom");
1075
1170
  s.removeProperty("--webg-offset-x");
1076
1171
  s.removeProperty("--webg-offset-y");
1172
+ s.removeProperty("--webg-rotate");
1173
+ s.removeProperty("--webg-mirror");
1077
1174
  const scrim = document.getElementById(SCRIM_ID);
1078
1175
  if (scrim) scrim.style.background = "";
1079
1176
  }
@@ -1268,6 +1365,19 @@ window.__ModuleLoader__.load({
1268
1365
 
1269
1366
  function onFit(e) {
1270
1367
  selection.fit = FIT_MODES.indexOf(e.target.value) >= 0 ? e.target.value : "cover";
1368
+ // Re-seat the crop into the new mode's envelope: leaving free for a
1369
+ // gap-free mode pulls zoom back up to 1, reins the pan in and zeroes the
1370
+ // rotation; the other direction only ever relaxes bounds, so the framing
1371
+ // carries over. Mirroring survives every switch — a flip never breaks
1372
+ // coverage.
1373
+ clampCrop();
1374
+ persistSelection();
1375
+ applyEffects();
1376
+ emit();
1377
+ }
1378
+
1379
+ function onToggleMirror() {
1380
+ selection.mirrored = !selection.mirrored;
1271
1381
  persistSelection();
1272
1382
  applyEffects();
1273
1383
  emit();
@@ -1496,21 +1606,29 @@ window.__ModuleLoader__.load({
1496
1606
 
1497
1607
  function EffectsPanel(t, sel) {
1498
1608
  return React.createElement(React.Fragment, null,
1499
- // object-fit only means something for video/still wallpapers — an
1500
- // iframe ignores it and always fills its layer — so the base-mode
1501
- // select is hidden for web. The manual crop (drag / wheel in the
1502
- // adjust overlay) is a plain CSS transform and applies to EVERY kind,
1503
- // so 调整画面 / 重置裁剪 stay available even for iframes.
1609
+ // The fit select covers all wallpaper types: for video/stills it picks
1610
+ // the object-fit base mode, and "free" additionally unlocks shrink +
1611
+ // off-edge dragging for every kind — an iframe ignores object-fit, but
1612
+ // the zoom/pan transform applies to it the same way. The manual crop
1613
+ // (drag / wheel in the adjust overlay) is a plain CSS transform and
1614
+ // applies to EVERY kind, so 调整画面 / 重置裁剪 are always available.
1504
1615
  React.createElement("div", { className: "webg-row webg-fit-row" },
1505
- sel.type !== "web" && React.createElement("span", { className: "webg-hint webg-label" }, t.fit),
1506
- sel.type !== "web" && React.createElement("select", {
1616
+ React.createElement("span", { className: "webg-hint webg-label" }, t.fit),
1617
+ React.createElement("select", {
1507
1618
  className: "webg-select webg-fit-select",
1508
1619
  value: sel.fit,
1509
1620
  onChange: onFit,
1510
1621
  },
1511
- ...[t.fitCover, t.fitContain, t.fitFill, t.fitNone].map((label, i) =>
1622
+ ...[t.fitCover, t.fitContain, t.fitFill, t.fitNone, t.fitFree].map((label, i) =>
1512
1623
  React.createElement("option", { key: FIT_MODES[i], value: FIT_MODES[i] }, label),
1513
1624
  )),
1625
+ // Mirror is a coverage-preserving flip: valid in EVERY fit mode and
1626
+ // for every wallpaper kind (the transform applies to iframes too).
1627
+ React.createElement("button", {
1628
+ className: "webg-btn" + (sel.mirrored ? " webg-btn--active" : ""),
1629
+ type: "button",
1630
+ onClick: onToggleMirror,
1631
+ }, t.mirror),
1514
1632
  React.createElement("button", {
1515
1633
  className: "webg-btn", type: "button", onClick: enterAdjust,
1516
1634
  disabled: sel.adjusting,
@@ -1623,10 +1741,13 @@ window.__ModuleLoader__.load({
1623
1741
  // Everything reads DSH design tokens (--dsw-*) so the UI blends into the
1624
1742
  // shell and follows light/dark theme switches automatically.
1625
1743
  const CSS = `
1626
- /* Wallpaper layers: fixed, sunk below the app frame, crossfading. */
1744
+ /* Wallpaper layers: fixed, sunk below the app frame, crossfading. The
1745
+ background paints black only in the "free" fit mode, where the media
1746
+ can be dragged off the edges and the exposed area reads as a canvas. */
1627
1747
  .webg-layer {
1628
1748
  position: fixed; inset: 0; z-index: -2; overflow: hidden;
1629
1749
  pointer-events: none; opacity: 1;
1750
+ background: var(--webg-layer-bg, transparent);
1630
1751
  transition: opacity ${CROSSFADE_MS}ms ease;
1631
1752
  }
1632
1753
  .webg-layer--enter { opacity: 0; }
@@ -1649,9 +1770,13 @@ window.__ModuleLoader__.load({
1649
1770
  display: block;
1650
1771
  background: transparent; border: 0;
1651
1772
  filter: var(--webg-media-filter, none);
1652
- /* Manual crop: pan offsets are % of the media box (independent of zoom
1653
- because translate is listed before scale), zoom is centered. */
1654
- transform: translate(var(--webg-offset-x, 0%), var(--webg-offset-y, 0%)) scale(var(--webg-zoom, 1));
1773
+ /* Manual crop: the transform list is ordered pan → rotate → zoom →
1774
+ mirror. translate comes first so pan offsets stay screen-aligned no
1775
+ matter the rotation; mirror is innermost so the flip happens in the
1776
+ media's own axes. */
1777
+ transform: translate(var(--webg-offset-x, 0%), var(--webg-offset-y, 0%))
1778
+ rotate(var(--webg-rotate, 0deg)) scale(var(--webg-zoom, 1))
1779
+ scaleX(var(--webg-mirror, 1));
1655
1780
  transform-origin: center center;
1656
1781
  }
1657
1782
 
@@ -1772,17 +1897,32 @@ window.__ModuleLoader__.load({
1772
1897
  border-color: var(--dsw-alias-border-l1, rgba(128, 128, 128, 0.5));
1773
1898
  }
1774
1899
  .webg-btn:disabled { opacity: 0.45; cursor: not-allowed; }
1775
- /* brand-primary is monochrome (near-black light / near-white dark), so
1776
- the label must use its inverse token — never a hardcoded white. */
1900
+ /* brand-primary is monochrome (near-black light / near-white dark). The
1901
+ shell's brand-primary-INVERT token aliases the same shade as brand-
1902
+ primary itself in both themes (no contrast), so anything sitting ON a
1903
+ brand-primary fill takes its contrast colour from the inverted label
1904
+ ramp instead — near-white on the dark light-theme fill, dark bluish-800
1905
+ on the near-white dark-theme fill. */
1906
+ body {
1907
+ --webg-on-brand: var(--dsw-alias-label-primary-inverted,
1908
+ var(--dsw-alias-label-primary-foreground, rgb(249, 250, 251)));
1909
+ }
1777
1910
  .webg-btn--primary {
1778
1911
  background: var(--dsw-alias-brand-primary, rgb(15, 17, 21));
1779
1912
  border-color: transparent;
1780
- color: var(--dsw-alias-brand-primary-invert, rgb(249, 250, 251));
1913
+ color: var(--webg-on-brand);
1781
1914
  }
1782
1915
  .webg-btn--primary:hover:not(:disabled) {
1783
1916
  background: var(--dsw-alias-brand-primary, rgb(15, 17, 21));
1784
1917
  opacity: 0.85;
1785
1918
  }
1919
+ /* Toggle-style button showing an engaged state (e.g. 镜像): brand fill
1920
+ with the contrasting on-brand label. */
1921
+ .webg-btn--active {
1922
+ background: var(--dsw-alias-brand-primary, rgb(15, 17, 21));
1923
+ border-color: transparent;
1924
+ color: var(--webg-on-brand);
1925
+ }
1786
1926
 
1787
1927
  .webg-select, .webg-text {
1788
1928
  padding: 5px 10px;
@@ -1842,7 +1982,7 @@ window.__ModuleLoader__.load({
1842
1982
  -webkit-appearance: none;
1843
1983
  width: 14px; height: 14px; border-radius: 50%;
1844
1984
  margin-top: -5px; /* centers the 14px thumb on the 4px track */
1845
- background: var(--dsw-alias-brand-primary-invert, rgb(249, 250, 251));
1985
+ background: var(--webg-on-brand);
1846
1986
  border: 2.5px solid var(--dsw-alias-brand-primary, rgb(15, 17, 21));
1847
1987
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
1848
1988
  transition: transform 120ms ease;
@@ -1940,7 +2080,7 @@ window.__ModuleLoader__.load({
1940
2080
  position: absolute; top: 4px; left: 4px; width: 18px; height: 18px;
1941
2081
  border-radius: 4px;
1942
2082
  background: var(--dsw-alias-brand-primary, rgb(15, 17, 21));
1943
- color: var(--dsw-alias-brand-primary-invert, rgb(249, 250, 251));
2083
+ color: var(--webg-on-brand);
1944
2084
  font-size: 12px; line-height: 18px; text-align: center;
1945
2085
  }
1946
2086
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wallpaper-engine-dsh",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Wallpaper Engine library as the DSH web GUI background — video/web/still wallpapers, rotation lists, search, resource monitor, bilingual UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",