dsh-any-background 0.2.9 → 0.2.10

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
@@ -12,9 +12,15 @@ window.__ModuleLoader__.load({
12
12
  try {
13
13
  return require("@deepseek-ai/dsh-client-store");
14
14
  } catch {}
15
- return require("@deepseek-ai/dsh-client-runtime/client");
15
+ try {
16
+ return require("@deepseek-ai/dsh-client-runtime/client");
17
+ } catch {
18
+ console.warn("dsh-any-background: no client store module resolved on this host; the settings panel will be disabled");
19
+ return null;
20
+ }
16
21
  }
17
- const defineStore = resolveStore().defineStore;
22
+ /** null when neither store package resolves on this host (see resolveStore). */
23
+ const defineStore = resolveStore()?.defineStore ?? null;
18
24
  //#endregion
19
25
  //#region src/client/i18n.ts
20
26
  const NS = "settings.anyBg";
@@ -57,6 +63,9 @@ window.__ModuleLoader__.load({
57
63
  fontEnabled: "启用字体",
58
64
  fontNone: "未设置,使用宿主默认字体",
59
65
  fontFail: "字体上传失败,请确认是有效的 ttf/otf/woff/woff2 文件",
66
+ uploadTooLarge: "文件过大,上限 {limit}",
67
+ bgUploadFail: "壁纸上传失败",
68
+ videoUploadFail: "视频上传失败,本次播放不会被保存",
60
69
  strokeTitle: "文字描边",
61
70
  strokeHint: "按区域为文字添加描边:粗细为 0 表示关闭,「自动」会生成与文字颜色相反的颜色,「主题色」跟随当前主题色。代码块与图标已自动豁免,多色语法不会糊成一团",
62
71
  strokeWidth: "描边粗细",
@@ -243,6 +252,9 @@ window.__ModuleLoader__.load({
243
252
  fontEnabled: "Font enabled",
244
253
  fontNone: "None — host default",
245
254
  fontFail: "Upload failed — make sure it is a valid ttf/otf/woff/woff2 file",
255
+ uploadTooLarge: "File is too large (limit {limit})",
256
+ bgUploadFail: "Wallpaper upload failed",
257
+ videoUploadFail: "Video upload failed — this playback will not be saved",
246
258
  strokeTitle: "Text outline",
247
259
  strokeHint: "Outline text per region: width 0 turns it off, \"auto\" picks the inverse of the text color, and \"theme\" follows the current accent. Code blocks and icons are exempted automatically, so multi-color syntax never smears",
248
260
  strokeWidth: "Outline width",
@@ -1117,36 +1129,73 @@ window.__ModuleLoader__.load({
1117
1129
  error: v?.error ?? "failed"
1118
1130
  };
1119
1131
  }
1132
+ /** Map a refused upload response onto an outcome. The body has to be read: the
1133
+ * node half names the reason there, and an oversized transfer answers
1134
+ * `413 { ok:false, error:'too large', limit:'100 MB' }`. Checking `res.ok`
1135
+ * alone threw that away, so an oversized upload surfaced as a bare failure —
1136
+ * or, on the wallpaper path, as nothing at all. */
1137
+ async function refusedUpload(res) {
1138
+ const body = await res.json().catch(() => null);
1139
+ if (body?.error === "too large") return {
1140
+ ok: false,
1141
+ refusal: {
1142
+ kind: "too-large",
1143
+ status: res.status,
1144
+ limit: typeof body.limit === "string" ? body.limit : void 0
1145
+ }
1146
+ };
1147
+ return {
1148
+ ok: false,
1149
+ refusal: {
1150
+ kind: "http",
1151
+ status: res.status
1152
+ }
1153
+ };
1154
+ }
1155
+ /** Localized description of a refusal for the panel's toast. `fallbackKey` is
1156
+ * the caller's own "…upload failed" string, used for anything that is not a
1157
+ * size refusal. */
1158
+ function uploadRefusalText(o, t, fallbackKey) {
1159
+ if (o.refusal?.kind === "too-large") {
1160
+ const limit = o.refusal.limit ?? "";
1161
+ return t("uploadTooLarge").split("{limit}").join(limit);
1162
+ }
1163
+ if (o.refusal !== void 0) return `${t(fallbackKey)} (http ${o.refusal.status})`;
1164
+ return t(fallbackKey);
1165
+ }
1120
1166
  /** Upload a video's raw bytes over HTTP (MIME in Content-Type, body untouched
1121
1167
  * — no base64 inflation that would blow the RPC body limit on large clips). */
1122
1168
  async function uploadVideo(blob, mime) {
1123
1169
  try {
1124
- return (await fetch(VIDEO_UPLOAD_URL, {
1170
+ const res = await fetch(VIDEO_UPLOAD_URL, {
1125
1171
  method: "POST",
1126
1172
  headers: { "Content-Type": mime || "application/octet-stream" },
1127
1173
  body: blob
1128
- })).ok;
1174
+ });
1175
+ return res.ok ? { ok: true } : await refusedUpload(res);
1129
1176
  } catch (e) {
1130
1177
  console.warn("dsh-any-background: video upload failed", e);
1131
- return false;
1178
+ return { ok: false };
1132
1179
  }
1133
1180
  }
1134
1181
  /** Upload a wallpaper's raw bytes over HTTP — same streaming model as videos,
1135
1182
  * original pixels preserved, zero base64 round-trips. */
1136
1183
  async function uploadWallpaper(blob) {
1137
1184
  try {
1138
- return (await fetch(WALLPAPER_UPLOAD_URL, {
1185
+ const res = await fetch(WALLPAPER_UPLOAD_URL, {
1139
1186
  method: "POST",
1140
1187
  headers: { "Content-Type": blob.type || "image/jpeg" },
1141
1188
  body: blob
1142
- })).ok;
1189
+ });
1190
+ return res.ok ? { ok: true } : await refusedUpload(res);
1143
1191
  } catch (e) {
1144
1192
  console.warn("dsh-any-background: wallpaper upload failed", e);
1145
- return false;
1193
+ return { ok: false };
1146
1194
  }
1147
1195
  }
1148
1196
  /** Upload a custom font's raw bytes over HTTP; resolves the sniffed MIME on
1149
- * success (the server validates the container from magic bytes) or null. */
1197
+ * success, or a refusal the caller can report (an oversized font used to be
1198
+ * indistinguishable from a corrupt one). */
1150
1199
  async function uploadFont(blob) {
1151
1200
  try {
1152
1201
  const res = await fetch(FONT_UPLOAD_URL, {
@@ -1154,12 +1203,15 @@ window.__ModuleLoader__.load({
1154
1203
  headers: { "Content-Type": blob.type || "application/octet-stream" },
1155
1204
  body: blob
1156
1205
  });
1157
- if (!res.ok) return null;
1206
+ if (!res.ok) return await refusedUpload(res);
1158
1207
  const data = await res.json().catch(() => null);
1159
- return typeof data?.mime === "string" ? data.mime : "font/ttf";
1208
+ return {
1209
+ ok: true,
1210
+ mime: typeof data?.mime === "string" ? data.mime : "font/ttf"
1211
+ };
1160
1212
  } catch (e) {
1161
1213
  console.warn("dsh-any-background: font upload failed", e);
1162
- return null;
1214
+ return { ok: false };
1163
1215
  }
1164
1216
  }
1165
1217
  /** Remove the stored custom font (server deletes every variant + clears the
@@ -1340,6 +1392,12 @@ window.__ModuleLoader__.load({
1340
1392
  v
1341
1393
  ];
1342
1394
  }
1395
+ /** Format an RGB triple as a lowercase hex string. Channels are rounded: some
1396
+ * callers feed float channels and a fractional byte would produce invalid
1397
+ * hex. */
1398
+ function rgbToHex(rgb) {
1399
+ return "#" + rgb.map((v) => Math.round(v).toString(16).padStart(2, "0")).join("");
1400
+ }
1343
1401
  let tokensCacheKey = "";
1344
1402
  let tokensCache = null;
1345
1403
  /**
@@ -2483,9 +2541,15 @@ window.__ModuleLoader__.load({
2483
2541
  wpController = null;
2484
2542
  }
2485
2543
  /** Register a callback fired once a generated snapshot is ready (so the caller
2486
- * can re-sync the settings preview / store). */
2544
+ * can re-sync the settings preview / store). Returns an unsubscribe: HMR
2545
+ * re-runs apply and would otherwise leave the previous apply's closure as the
2546
+ * live listener (a stale this-session callback invoked by a lingering
2547
+ * controller). */
2487
2548
  function onGeneratedSnapshot(cb) {
2488
2549
  snapshotListener = cb;
2550
+ return () => {
2551
+ if (snapshotListener === cb) snapshotListener = null;
2552
+ };
2489
2553
  }
2490
2554
  function ensureTokenStyle() {
2491
2555
  if (tokenStyleEl?.isConnected) return tokenStyleEl;
@@ -2724,6 +2788,18 @@ window.__ModuleLoader__.load({
2724
2788
  }
2725
2789
  /** Write every group's stroke width/color variables + the two column strokes.
2726
2790
  * Called from applyWp so palette/verdict changes re-derive 'auto'/'theme'. */
2791
+ /** Write (or clear) one column's inline text stroke. The stroke rides inline
2792
+ * styles and is fully removed at width 0 so nothing lingers after the slider
2793
+ * resets. */
2794
+ function applyInlineStroke(el, s) {
2795
+ if (s.width > 0) {
2796
+ el.style.setProperty("-webkit-text-stroke", `${s.width}px ${strokeColor(s)}`);
2797
+ el.style.setProperty("paint-order", "stroke fill");
2798
+ } else {
2799
+ el.style.removeProperty("-webkit-text-stroke");
2800
+ el.style.removeProperty("paint-order");
2801
+ }
2802
+ }
2727
2803
  function applyStrokes() {
2728
2804
  const strokes = rStrokes();
2729
2805
  const root = document.documentElement;
@@ -2735,15 +2811,7 @@ window.__ModuleLoader__.load({
2735
2811
  discoverParts();
2736
2812
  for (const g of STROKE_INLINE_GROUPS) {
2737
2813
  const el = g === "bg" ? centerEl : sidebarEl;
2738
- if (el === null) continue;
2739
- const s = strokes[g];
2740
- if (s.width > 0) {
2741
- el.style.setProperty("-webkit-text-stroke", `${s.width}px ${strokeColor(s)}`);
2742
- el.style.setProperty("paint-order", "stroke fill");
2743
- } else {
2744
- el.style.removeProperty("-webkit-text-stroke");
2745
- el.style.removeProperty("paint-order");
2746
- }
2814
+ if (el !== null) applyInlineStroke(el, strokes[g]);
2747
2815
  }
2748
2816
  }
2749
2817
  /** Live per-group stroke update during slider drag (no full re-apply). */
@@ -2756,13 +2824,7 @@ window.__ModuleLoader__.load({
2756
2824
  discoverParts();
2757
2825
  const el = part === "bg" ? centerEl : sidebarEl;
2758
2826
  if (el === null) return;
2759
- if (s.width > 0) {
2760
- el.style.setProperty("-webkit-text-stroke", `${s.width}px ${strokeColor(s)}`);
2761
- el.style.setProperty("paint-order", "stroke fill");
2762
- } else {
2763
- el.style.removeProperty("-webkit-text-stroke");
2764
- el.style.removeProperty("paint-order");
2765
- }
2827
+ applyInlineStroke(el, s);
2766
2828
  }
2767
2829
  /** Teardown only: drop every stroke variable and column inline stroke. */
2768
2830
  function removeStrokes() {
@@ -2867,27 +2929,35 @@ window.__ModuleLoader__.load({
2867
2929
  if (px > 0) document.documentElement.style.setProperty("--dsh-any-blur-panel", `blur(${px}px)`);
2868
2930
  else document.documentElement.style.removeProperty("--dsh-any-blur-panel");
2869
2931
  }
2870
- function applySettingsOverrides(op) {
2932
+ /** Re-scope one surface group's layer tokens onto plugin-owned variables so a
2933
+ * dedicated slider owns its alpha (the host has no per-surface opacity). The
2934
+ * settings group additionally retints its dialog surface variable. Always
2935
+ * written explicitly — including at 100% — so removing a slider's effect means
2936
+ * writing 1, not deleting the variable (the style rules below have no fallback
2937
+ * and would otherwise resolve the body tokens that applyCustomTokens rewrote
2938
+ * with the homepage card alpha). */
2939
+ function applyLayerOverrides(prefix, op, surface) {
2871
2940
  const [h, s, l] = rColor();
2872
2941
  const tokens = genTokens(h, s, l, rColorScheme()).tokens;
2873
- const layer1 = tokens["--dsw-alias-bg-layer-1"];
2874
- const layer2 = tokens["--dsw-alias-bg-layer-2"];
2875
- const layer3 = tokens["--dsw-alias-bg-layer-3"];
2876
- if (layer2 !== void 0) document.documentElement.style.setProperty("--dsh-any-bg-settings-surface", toRgba(layer2, op));
2877
- if (layer1 !== void 0) document.documentElement.style.setProperty("--dsh-any-bg-settings-layer-1", toRgba(layer1, op));
2878
- if (layer2 !== void 0) document.documentElement.style.setProperty("--dsh-any-bg-settings-layer-2", toRgba(layer2, op));
2879
- if (layer3 !== void 0) document.documentElement.style.setProperty("--dsh-any-bg-settings-layer-3", toRgba(layer3, op));
2942
+ if (surface) {
2943
+ const surfaceColor = tokens["--dsw-alias-bg-layer-2"];
2944
+ if (surfaceColor !== void 0) document.documentElement.style.setProperty(`${prefix}-surface`, toRgba(surfaceColor, op));
2945
+ }
2946
+ for (const layer of [
2947
+ 1,
2948
+ 2,
2949
+ 3
2950
+ ]) {
2951
+ const c = tokens[`--dsw-alias-bg-layer-${layer}`];
2952
+ if (c !== void 0) document.documentElement.style.setProperty(`${prefix}-layer-${layer}`, toRgba(c, op));
2953
+ }
2954
+ }
2955
+ function applySettingsOverrides(op) {
2956
+ applyLayerOverrides("--dsh-any-bg-settings", op, true);
2880
2957
  }
2881
2958
  const TRAJECTORY_STYLE_RULE = "[data-conversation-composer-overlay]{--dsw-alias-bg-layer-1:var(--dsh-any-traj-layer-1);--dsw-alias-bg-layer-2:var(--dsh-any-traj-layer-2);--dsw-alias-bg-layer-3:var(--dsh-any-traj-layer-3)}";
2882
2959
  function applyTrajectoryOverrides(op) {
2883
- const [h, s, l] = rColor();
2884
- const tokens = genTokens(h, s, l, rColorScheme()).tokens;
2885
- const layer1 = tokens["--dsw-alias-bg-layer-1"];
2886
- const layer2 = tokens["--dsw-alias-bg-layer-2"];
2887
- const layer3 = tokens["--dsw-alias-bg-layer-3"];
2888
- if (layer1 !== void 0) document.documentElement.style.setProperty("--dsh-any-traj-layer-1", toRgba(layer1, op));
2889
- if (layer2 !== void 0) document.documentElement.style.setProperty("--dsh-any-traj-layer-2", toRgba(layer2, op));
2890
- if (layer3 !== void 0) document.documentElement.style.setProperty("--dsh-any-traj-layer-3", toRgba(layer3, op));
2960
+ applyLayerOverrides("--dsh-any-traj", op, false);
2891
2961
  }
2892
2962
  /** Register a callback fired when a wallpaper-extracted theme color is
2893
2963
  * adopted by the auto-adaptation path, so the section can re-register the
@@ -2896,6 +2966,9 @@ window.__ModuleLoader__.load({
2896
2966
  let colorAdoptedListener = null;
2897
2967
  function onColorAdopted(cb) {
2898
2968
  colorAdoptedListener = cb;
2969
+ return () => {
2970
+ if (colorAdoptedListener === cb) colorAdoptedListener = null;
2971
+ };
2899
2972
  }
2900
2973
  /** Apply the theme color: use the saved pick directly, or fall back to
2901
2974
  * extracting a dominant color from the current wallpaper. */
@@ -2911,8 +2984,8 @@ window.__ModuleLoader__.load({
2911
2984
  if (hsl && rWp() === url) {
2912
2985
  cfg.color = hsl;
2913
2986
  colorAdoptedListener?.(hsl);
2987
+ applyWp();
2914
2988
  }
2915
- applyWp();
2916
2989
  });
2917
2990
  } else applyWp();
2918
2991
  }
@@ -2944,7 +3017,7 @@ window.__ModuleLoader__.load({
2944
3017
  * preserving the user's scale/intensity/speed/density/preset choices. */
2945
3018
  function regenerateGeneratedBg() {
2946
3019
  const params = cfg.generatedBg;
2947
- if (!params || cfg.backgroundType === "image") return;
3020
+ if (!params || cfg.backgroundType === "image" || cfg.backgroundType === "video") return;
2948
3021
  cfg.generatedBg = {
2949
3022
  ...params,
2950
3023
  seed: randomSeed()
@@ -2967,9 +3040,10 @@ window.__ModuleLoader__.load({
2967
3040
  wpEl.appendChild(wpController.canvas);
2968
3041
  }
2969
3042
  requestAnimationFrame(() => {
2970
- if (!wpController) return;
2971
3043
  const controller = wpController;
3044
+ if (controller === null) return;
2972
3045
  const frame = controller.snapshot();
3046
+ if (wpController !== controller) return;
2973
3047
  setWpUrl(frame);
2974
3048
  applyWp();
2975
3049
  snapshotListener?.();
@@ -2996,35 +3070,36 @@ window.__ModuleLoader__.load({
2996
3070
  partBlurStyleEl.textContent = PART_BLUR_RULE;
2997
3071
  document.head.appendChild(partBlurStyleEl);
2998
3072
  }
2999
- /** Find one direct child of `frame` whose `[data-rightbar-col]` marker matches.
3000
- * The rightbar is its own grid track, NOT a "details" twin of the center
3001
- * column, so it must never receive the main-bg tint or blur underlay. */
3002
- function findRightbarInFrame(frame) {
3003
- for (let i = 0; i < frame.children.length; i++) {
3004
- const child = frame.children[i];
3005
- if (child instanceof HTMLElement && child.dataset.rightbarCol !== void 0) return child;
3006
- }
3007
- return null;
3008
- }
3009
3073
  function discoverParts() {
3010
3074
  const overlay = document.querySelector("[data-shell-overlay]");
3011
3075
  if (overlay === null) return;
3012
3076
  const frame = overlay.parentElement;
3013
3077
  if (frame === null) return;
3014
3078
  frameEl = frame;
3015
- rightEl = findRightbarInFrame(frame);
3016
- const idx = Array.from(frame.children).indexOf(overlay);
3017
- if (rightEl !== null) {
3018
- const rightIdx = Array.from(frame.children).indexOf(rightEl);
3019
- sidebarEl = frame.children[rightIdx - 2] ?? null;
3020
- centerEl = frame.children[rightIdx - 1] ?? null;
3079
+ let overlayIdx = -1;
3080
+ let rightIdx = -1;
3081
+ let right = null;
3082
+ const children = frame.children;
3083
+ for (let i = 0; i < children.length; i++) {
3084
+ const child = children[i];
3085
+ if (child === overlay) overlayIdx = i;
3086
+ else if (child instanceof HTMLElement && child.dataset.rightbarCol !== void 0) {
3087
+ right = child;
3088
+ rightIdx = i;
3089
+ }
3090
+ }
3091
+ rightEl = right;
3092
+ if (right !== null) {
3093
+ sidebarEl = children[rightIdx - 2] ?? null;
3094
+ centerEl = children[rightIdx - 1] ?? null;
3021
3095
  } else {
3022
- sidebarEl = frame.children[idx - 3] ?? null;
3023
- centerEl = frame.children[idx - 2] ?? null;
3096
+ sidebarEl = children[overlayIdx - 3] ?? null;
3097
+ centerEl = children[overlayIdx - 2] ?? null;
3024
3098
  }
3025
3099
  }
3026
3100
  function setBlur(el, px) {
3027
3101
  if (el === null) return;
3102
+ if (px === 0 && !el.classList.contains(PART_BLUR_CLASS) && el.getAttribute("data-dab-pos-patched") !== "1") return;
3028
3103
  const underlay = el.querySelector(`:scope > .${PART_UNDERLAY_CLASS}`);
3029
3104
  if (px > 0) {
3030
3105
  ensurePartBlurStyle();
@@ -3158,6 +3233,34 @@ window.__ModuleLoader__.load({
3158
3233
  plain: true
3159
3234
  }];
3160
3235
  const viewTargets = VIEW_CARDS.map(() => null);
3236
+ /** View specs whose real host marker has been seen at least once. Until a
3237
+ * marker is seen, its absence is ambiguous — "that view is not mounted" or
3238
+ * "this build has no such marker, keep running the generic fallback" — and the
3239
+ * pass has to keep probing. Once seen, absence can only mean the former, so the
3240
+ * pass may be skipped.
3241
+ *
3242
+ * This matters because the two specs are two different MAIN VIEWS and only one
3243
+ * of them is mounted at a time (the host renders the main slot for the active
3244
+ * panel id, keyed by `entryKey` — see AppFrame's MainPanel). So "every surface
3245
+ * present" is never true during a normal session, and an all-or-nothing gate on
3246
+ * it silently never engages: that is exactly how the previous version of this
3247
+ * observer ended up running the full pass on every animation frame while
3248
+ * tokens streamed. */
3249
+ const markerSeen = /* @__PURE__ */ new Set();
3250
+ /** Resolve just the stable host markers for the view specs — one querySelector
3251
+ * each, no fallback heuristics. Cheap enough to run on every coalesced burst,
3252
+ * and enough to notice a view mounting/unmounting. */
3253
+ function probeViewMarkers() {
3254
+ const center = centerEl;
3255
+ if (center === null || !document.body.contains(center)) return VIEW_CARDS.map(() => null);
3256
+ return VIEW_CARDS.map((spec) => center.querySelector(spec.sel));
3257
+ }
3258
+ /** Whether the pass must run regardless of the value/identity signature. */
3259
+ function needsPartsPass(markers) {
3260
+ if (!columnsPresent()) return true;
3261
+ if (markers.some((el, i) => el !== null && el !== viewTargets[i])) return true;
3262
+ return VIEW_CARDS.some((spec, i) => spec.fallback === true && markers[i] === null && !markerSeen.has(i));
3263
+ }
3161
3264
  function isScrollableY(el) {
3162
3265
  const oy = getComputedStyle(el).overflowY;
3163
3266
  return oy === "auto" || oy === "scroll" || oy === "overlay";
@@ -3202,6 +3305,7 @@ window.__ModuleLoader__.load({
3202
3305
  const marked = centerEl.querySelector(spec.sel);
3203
3306
  const cached = viewTargets[idx];
3204
3307
  if (marked !== null) {
3308
+ markerSeen.add(idx);
3205
3309
  if (cached !== null && cached !== marked) {
3206
3310
  setBlur(cached, 0);
3207
3311
  restoreCardHost(cached, spec.mark, spec.prev, spec.plain === true);
@@ -3337,15 +3441,48 @@ window.__ModuleLoader__.load({
3337
3441
  syncTableFix();
3338
3442
  }
3339
3443
  let partsObserver = null;
3444
+ let partsApplyRaf = 0;
3445
+ /** Signature of the last blur+opacity pass we actually wrote to the DOM. */
3446
+ let appliedPartsKey = "";
3447
+ /** The surfaces the last pass styled, compared BY IDENTITY: the host can swap
3448
+ * a view element for a fresh one while the slider values stay identical, and
3449
+ * the value signature alone would then skip the pass and leave the new view
3450
+ * unstyled until a slider moves. */
3451
+ let appliedTargets = [];
3452
+ /** The four frame columns are mounted and still attached. These are the frame's
3453
+ * own children (see the host's AppFrame: sidebar / center / rightbar carrying
3454
+ * `data-rightbar-col` / the overlay), so they exist as soon as the shell does —
3455
+ * unlike the per-view cards below, which come and go with the active view. */
3456
+ function columnsPresent() {
3457
+ return frameEl !== null && sidebarEl !== null && centerEl !== null && rightEl !== null && document.body.contains(frameEl) && document.body.contains(centerEl);
3458
+ }
3459
+ /** Snapshot of every surface the pass would style, for the identity check. */
3460
+ function targetsNow() {
3461
+ return [
3462
+ frameEl,
3463
+ sidebarEl,
3464
+ centerEl,
3465
+ rightEl,
3466
+ ...viewTargets
3467
+ ];
3468
+ }
3340
3469
  /** Watch for the AppFrame mounting so persisted blurs land even when the shell
3341
- * renders after this plugin's apply. Cheap: once all parts are found, the
3342
- * callback returns. */
3470
+ * renders after this plugin's apply. */
3343
3471
  function watchParts() {
3344
3472
  if (partsObserver !== null || typeof MutationObserver === "undefined") return;
3345
3473
  partsObserver = new MutationObserver(() => {
3346
- if (frameEl !== null && sidebarEl !== null && centerEl !== null && rightEl !== null && document.body.contains(frameEl) && viewTargets.every((el) => el !== null && document.body.contains(el))) return;
3347
- applyPartBlurs(rBlurs());
3348
- applyPartOpacities(rOps());
3474
+ if (partsApplyRaf !== 0) return;
3475
+ partsApplyRaf = requestAnimationFrame(() => {
3476
+ partsApplyRaf = 0;
3477
+ const blurs = rBlurs();
3478
+ const ops = rOps();
3479
+ const key = JSON.stringify([blurs, ops]);
3480
+ if (!needsPartsPass(probeViewMarkers()) && key === appliedPartsKey && !targetsNow().some((el, i) => el !== appliedTargets[i])) return;
3481
+ applyPartBlurs(blurs);
3482
+ applyPartOpacities(ops);
3483
+ appliedPartsKey = key;
3484
+ appliedTargets = targetsNow();
3485
+ });
3349
3486
  });
3350
3487
  partsObserver.observe(document.body, {
3351
3488
  childList: true,
@@ -3355,6 +3492,13 @@ window.__ModuleLoader__.load({
3355
3492
  function stopWatchingParts() {
3356
3493
  partsObserver?.disconnect();
3357
3494
  partsObserver = null;
3495
+ if (partsApplyRaf !== 0) {
3496
+ cancelAnimationFrame(partsApplyRaf);
3497
+ partsApplyRaf = 0;
3498
+ }
3499
+ appliedPartsKey = "";
3500
+ appliedTargets = [];
3501
+ markerSeen.clear();
3358
3502
  }
3359
3503
  let themeObserver = null;
3360
3504
  let themeRaf = 0;
@@ -3507,6 +3651,9 @@ window.__ModuleLoader__.load({
3507
3651
  * be re-registered through the host theme service. */
3508
3652
  function onVerdictApplied(cb) {
3509
3653
  verdictListener = cb;
3654
+ return () => {
3655
+ if (verdictListener === cb) verdictListener = null;
3656
+ };
3510
3657
  }
3511
3658
  function applyVerdict(dark) {
3512
3659
  if (rBgDark() === dark) return;
@@ -3634,6 +3781,8 @@ window.__ModuleLoader__.load({
3634
3781
  clearVideoEl();
3635
3782
  wpEl?.remove();
3636
3783
  wpEl = null;
3784
+ wpVerdict = null;
3785
+ setBgDark(null);
3637
3786
  }
3638
3787
  } else if (cfg.backgroundType !== "image" && cfg.generatedBg) {
3639
3788
  clearVideoEl();
@@ -3705,8 +3854,14 @@ window.__ModuleLoader__.load({
3705
3854
  pendingOps = null;
3706
3855
  tableFixStyleEl?.remove();
3707
3856
  tableFixStyleEl = null;
3857
+ partBlurStyleEl?.remove();
3858
+ partBlurStyleEl = null;
3708
3859
  removeStrokes();
3709
3860
  removeFontFace();
3861
+ imgNat = null;
3862
+ lowResUrl = null;
3863
+ lowResFor = "";
3864
+ dragLow = false;
3710
3865
  setBlur(frameEl, 0);
3711
3866
  setBlur(sidebarEl, 0);
3712
3867
  setBlur(centerEl, 0);
@@ -3842,7 +3997,10 @@ window.__ModuleLoader__.load({
3842
3997
  /* ── wheel card ──────────────────────────────────────────────────────────── */
3843
3998
  .dab-wheel-card{position:relative;display:flex;align-items:center;justify-content:center;gap:28px;flex-wrap:wrap;padding:24px 18px}
3844
3999
  .dab-wheel-glow{position:absolute;width:230px;height:230px;border-radius:50%;filter:blur(48px);opacity:.2;background:var(--c,#888);pointer-events:none;transition:background .4s ease}
3845
- .dab-wheel{position:relative;cursor:crosshair;border-radius:50%;box-shadow:0 12px 32px -14px rgba(0,0,0,.4)}
4000
+ /* flex:none is load bearing, not cosmetic: the pointer maths reads client
4001
+ * coordinates straight off this element's box, so any shrink below the 220px
4002
+ * drawing surface would desynchronise the ring from the cursor. */
4003
+ .dab-wheel{position:relative;flex:none;cursor:crosshair;border-radius:50%;box-shadow:0 12px 32px -14px rgba(0,0,0,.4)}
3846
4004
  .dab-hint{font-size:11.5px;line-height:1.55;color:var(--dsw-alias-label-tertiary);padding:0 4px}
3847
4005
 
3848
4006
  /* ── precise color inputs ────────────────────────────────────────────────── */
@@ -3896,7 +4054,7 @@ window.__ModuleLoader__.load({
3896
4054
  .dab-slider::-moz-range-thumb{width:13px;height:13px;border-radius:50%;background:var(--dsw-alias-button-elevated-fill,#fff);border:2px solid var(--dsw-alias-brand-primary)}
3897
4055
 
3898
4056
  /* ── interface part cards ────────────────────────────────────────────────── */
3899
- .dab-grid-parts{display:grid;grid-template-columns:repeat(auto-fill,minmax(256px,1fr));gap:13px}
4057
+ .dab-grid-parts{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(240px,100%),1fr));gap:13px}
3900
4058
  .dab-part-head{display:flex;align-items:center;gap:11px;margin-bottom:14px}
3901
4059
  .dab-part-ico{width:32px;height:32px;flex:none;border-radius:10px;display:grid;place-items:center;color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-2);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent)}
3902
4060
  .dab-part-name{font-size:13.5px;font-weight:600}
@@ -3973,7 +4131,7 @@ window.__ModuleLoader__.load({
3973
4131
  .dab-spin{animation:dab-rotate .7s cubic-bezier(.3,.7,.3,1) 1}
3974
4132
 
3975
4133
  /* ── profile page ────────────────────────────────────────────────────────── */
3976
- .dab-profile-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(238px,1fr));gap:13px}
4134
+ .dab-profile-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(220px,100%),1fr));gap:13px}
3977
4135
  .dab-profile-ico{width:38px;height:38px;border-radius:11px;display:grid;place-items:center;margin-bottom:13px;color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-2);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent)}
3978
4136
  .dab-profile-title{font-size:14px;font-weight:650}
3979
4137
  .dab-profile-desc{font-size:12px;color:var(--dsw-alias-label-tertiary);line-height:1.55;margin:5px 0 15px}
@@ -3982,7 +4140,7 @@ window.__ModuleLoader__.load({
3982
4140
 
3983
4141
  /* ── presets / profiles / schedule / rotation ────────────────────────────── */
3984
4142
  .dab-row-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
3985
- .dab-preset-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
4143
+ .dab-preset-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(150px,100%),1fr));gap:10px}
3986
4144
  .dab-preset{display:flex;flex-direction:column;align-items:flex-start;gap:3px;padding:12px;border:1.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:13px;cursor:pointer;font:inherit;text-align:left;transition:border-color .25s,transform .25s,box-shadow .25s;animation:dab-row-in .34s cubic-bezier(.22,1,.36,1) both;animation-delay:calc(var(--i,0) * 45ms)}
3987
4145
  .dab-preset:hover{transform:translateY(-2px);border-color:var(--dsw-alias-brand-primary);box-shadow:0 8px 22px -12px rgba(0,0,0,.3)}
3988
4146
  .dab-preset-dot{width:22px;height:22px;border-radius:50%;margin-bottom:4px;box-shadow:inset 0 0 0 1px rgba(0,0,0,.12),0 4px 10px -4px rgba(0,0,0,.35);background:var(--dsw-alias-bg-layer-2);display:grid;place-items:center;color:var(--dsw-alias-label-tertiary)}
@@ -4009,7 +4167,7 @@ window.__ModuleLoader__.load({
4009
4167
  .dab-schedule-wrap.is-open{grid-template-rows:1fr;visibility:visible;transition:grid-template-rows .42s cubic-bezier(.22,1,.36,1)}
4010
4168
  .dab-schedule-clip{overflow:hidden;min-height:0;opacity:0;transition:opacity .28s ease}
4011
4169
  .dab-schedule-wrap.is-open .dab-schedule-clip{opacity:1;transition:opacity .32s ease .1s}
4012
- .dab-schedule-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;padding-top:12px}
4170
+ .dab-schedule-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(220px,100%),1fr));gap:16px;padding-top:12px}
4013
4171
  .dab-schedule-cell{min-width:0;animation:dab-row-in .34s cubic-bezier(.22,1,.36,1) both;animation-delay:calc(var(--i,0) * 60ms)}
4014
4172
  .dab-time-row{display:flex;flex-direction:column;gap:9px}
4015
4173
  .dab-time-label{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary))}
@@ -4045,6 +4203,9 @@ window.__ModuleLoader__.load({
4045
4203
  .dab-crash{display:flex;flex-direction:column;gap:10px;align-items:center;padding:28px 18px;border:1px solid var(--dsw-alias-border-l2);border-radius:16px}
4046
4204
  .dab-crash-title{font-size:15px;font-weight:650}
4047
4205
  .dab-crash-desc{font-size:12px;color:var(--dsw-alias-label-tertiary);text-align:center;line-height:1.5}
4206
+ /* The crash cause: readable, scrollable, and clipped to a few lines so a long
4207
+ * stack cannot push the reset button off the panel. */
4208
+ .dab-crash-detail{margin:0;max-width:min(520px,80vw);max-height:132px;overflow:auto;padding:8px 10px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);font-family:var(--dab-mono);font-size:10.5px;line-height:1.5;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary));text-align:left;white-space:pre-wrap;word-break:break-word}
4048
4209
 
4049
4210
  /* ── keyframes ───────────────────────────────────────────────────────────── */
4050
4211
  @keyframes dab-fade-in{from{opacity:0}to{opacity:1}}
@@ -4060,6 +4221,72 @@ window.__ModuleLoader__.load({
4060
4221
  @keyframes dab-zoom-in{from{opacity:0;transform:scale(.94) translateY(8px)}to{opacity:1;transform:none}}
4061
4222
  @keyframes dab-flow{to{background-position:300% 50%}}
4062
4223
 
4224
+ /* ── sidebar surface (a dsh-better-sidebar page) ─────────────────────────────
4225
+ * Same shell, hosted in a panel that is handed a full-height column (the host's
4226
+ * native tab body is height:100%) and whose width the user drags freely. So only
4227
+ * THREE things are fixed here, all of them structural:
4228
+ * 1. the shell fills the height and the page body owns the scroll
4229
+ * (flex:1 + min-height:0), so the panel stays one scrollable column instead
4230
+ * of pushing the whole tab into a scrollbar;
4231
+ * 2. the nav is a rail ABOVE the page rather than beside it — a side rail would
4232
+ * spend 150px of a panel that is narrow by nature, and this way the page
4233
+ * keeps the full width for its own grids;
4234
+ * 3. long tokens (paths, hex, URLs) may wrap instead of widening the panel.
4235
+ *
4236
+ * Everything else — grid columns, paddings, type scale, whether the cards lift on
4237
+ * hover — is width-driven through the container queries below, which measure this
4238
+ * shell (the .dab-root container, which declares container-type:inline-size) and
4239
+ * not the viewport. A narrow sidebar compacts; a wide one falls back to the
4240
+ * dialog's own spacing and multi-column grids, because at that point it *is* a
4241
+ * dialog-sized surface. */
4242
+ [data-dab-surface="sidebar"].dab-root{height:100%;min-height:0;padding:12px 12px 0}
4243
+ [data-dab-surface="sidebar"] .dab-shell{display:flex;flex-direction:column;align-items:stretch;gap:11px;height:100%;min-height:0;max-width:none;padding-bottom:0}
4244
+ [data-dab-surface="sidebar"] .dab-nav{gap:0}
4245
+ [data-dab-surface="sidebar"] .dab-nav-list{flex-direction:row;gap:2px;overflow-x:auto;overflow-y:hidden;padding-bottom:1px}
4246
+ [data-dab-surface="sidebar"] .dab-nav-list::-webkit-scrollbar{display:none}
4247
+ [data-dab-surface="sidebar"] .dab-nav-ind{display:none}
4248
+ [data-dab-surface="sidebar"] .dab-nav-item{flex:none;height:32px;gap:7px;padding:0 10px;font-size:12px}
4249
+ [data-dab-surface="sidebar"] .dab-nav-item.is-active{background:var(--dsw-alias-bg-layer-2)}
4250
+ [data-dab-surface="sidebar"] .dab-page{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;padding-bottom:16px;scrollbar-width:thin}
4251
+ /* Last-resort safety for text that cannot wrap (paths, hex, URLs). */
4252
+ [data-dab-surface="sidebar"] .dab-hint,[data-dab-surface="sidebar"] .dab-part-name{overflow-wrap:anywhere}
4253
+ /* The panel's width is whatever the user dragged it to, so let the part cards
4254
+ * fill as many columns as actually fit instead of hard-coding one. 170px is the
4255
+ * narrowest a part card stays readable once its header can wrap (see the head
4256
+ * rules below), which puts the second column at roughly a 380px panel — the
4257
+ * default right-sidebar width. */
4258
+ [data-dab-surface="sidebar"] .dab-grid-parts{grid-template-columns:repeat(auto-fill,minmax(min(170px,100%),1fr));gap:12px}
4259
+ [data-dab-surface="sidebar"] .dab-types{grid-template-columns:repeat(auto-fit,minmax(min(150px,100%),1fr))}
4260
+ [data-dab-surface="sidebar"] .dab-card{padding:12px}
4261
+ [data-dab-surface="sidebar"] .dab-part-head{gap:8px;margin-bottom:10px}
4262
+ [data-dab-surface="sidebar"] .dab-part-ico{width:26px;height:26px;border-radius:8px}
4263
+ [data-dab-surface="sidebar"] .dab-part-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px}
4264
+ [data-dab-surface="sidebar"] .dab-part-badge{padding:2px 7px;font-size:10px}
4265
+
4266
+ /* Narrow panel: tighten the chrome so the content still has room. */
4267
+ @container (max-width:539px){
4268
+ [data-dab-surface="sidebar"] .dab-page{gap:11px}
4269
+ [data-dab-surface="sidebar"] .dab-h1{font-size:17px}
4270
+ [data-dab-surface="sidebar"] .dab-desc{font-size:12px}
4271
+ [data-dab-surface="sidebar"] .dab-orb-wrap{width:96px;height:96px}
4272
+ [data-dab-surface="sidebar"] .dab-wheel-card{padding:14px 10px;gap:16px}
4273
+ [data-dab-surface="sidebar"] .dab-wheel-glow{width:170px;height:170px}
4274
+ /* Lift-on-hover is a dialog affordance; in a cramped scrolling panel it only
4275
+ * adds jitter under the cursor. */
4276
+ [data-dab-surface="sidebar"] .dab-card-hover:hover{transform:none;box-shadow:none}
4277
+ }
4278
+ /* Roomy panel: behave like the dialog again — its spacing, its type scale, and
4279
+ * multi-column grids (a smaller track minimum than the dialog, because the page
4280
+ * here is a few hundred px narrower than a dialog's content column). */
4281
+ @container (min-width:540px){
4282
+ [data-dab-surface="sidebar"] .dab-shell{gap:16px}
4283
+ [data-dab-surface="sidebar"] .dab-page{gap:13px}
4284
+ [data-dab-surface="sidebar"] .dab-nav-item{height:34px;font-size:12.5px;padding:0 12px}
4285
+ /* The shared ≤620px rule flattens the type cards to one column, which is right
4286
+ * for a narrow phone-width dialog but wasteful in a roomy panel. */
4287
+ [data-dab-surface="sidebar"] .dab-types{grid-template-columns:repeat(auto-fit,minmax(180px,1fr))}
4288
+ }
4289
+
4063
4290
  /* ── responsive & motion preferences ─────────────────────────────────────── */
4064
4291
  @container (max-width:620px){
4065
4292
  .dab-shell{grid-template-columns:1fr;gap:14px}
@@ -4074,6 +4301,30 @@ window.__ModuleLoader__.load({
4074
4301
  @container (min-width:621px){
4075
4302
  .dab-shell{grid-template-columns:158px minmax(0,1fr);gap:26px}
4076
4303
  }
4304
+ /* Phone: on a handset the settings dialog's content column shrinks to roughly
4305
+ * the viewport, and the dialog-sized chrome (18px cards, 21px headings, a 118px
4306
+ * orb, 24px paddings) stops fitting — cards end up wider than the column or the
4307
+ * color row wraps into three cramped pieces. These are the same tightenings the
4308
+ * sidebar surface applies at its own narrow widths, so a phone settings dialog
4309
+ * and a narrow sidebar panel end up looking the same. */
4310
+ @container (max-width:480px){
4311
+ .dab-shell{gap:10px}
4312
+ .dab-page{gap:10px}
4313
+ .dab-head{margin:0 0 2px}
4314
+ .dab-h1{font-size:18px}
4315
+ .dab-desc{font-size:12px}
4316
+ .dab-card{padding:14px;border-radius:14px}
4317
+ .dab-hero-accent{gap:16px}
4318
+ .dab-orb-wrap{width:92px;height:92px}
4319
+ .dab-wheel-card{padding:14px 10px;gap:16px}
4320
+ .dab-wheel-glow{width:180px;height:180px}
4321
+ /* Three fixed type-card columns need ~400px; two adaptive ones fit 320px. */
4322
+ .dab-types{grid-template-columns:repeat(auto-fit,minmax(min(150px,100%),1fr))}
4323
+ .dab-grid-parts{gap:11px}
4324
+ .dab-part-head{margin-bottom:10px}
4325
+ .dab-slider-block + .dab-slider-block{margin-top:10px}
4326
+ .dab-footer{padding-top:10px}
4327
+ }
4077
4328
  @media (prefers-reduced-motion:reduce){
4078
4329
  .dab-root *,.dab-root *::before,.dab-root *::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}
4079
4330
  }
@@ -4404,25 +4655,32 @@ window.__ModuleLoader__.load({
4404
4655
  this.props.onReset?.();
4405
4656
  };
4406
4657
  render() {
4407
- if (this.state.error) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4408
- className: "dab-crash",
4409
- children: [
4410
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4411
- className: "dab-crash-title",
4412
- children: this.props.t("crashTitle")
4413
- }),
4414
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4415
- className: "dab-crash-desc",
4416
- children: this.props.t("crashDesc")
4417
- }),
4418
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4419
- type: "button",
4420
- className: "dab-btn",
4421
- onClick: this.reset,
4422
- children: this.props.t("crashReset")
4423
- })
4424
- ]
4425
- });
4658
+ if (this.state.error) {
4659
+ const detail = this.state.error.stack ?? `${this.state.error.name}: ${this.state.error.message}`;
4660
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4661
+ className: "dab-crash",
4662
+ children: [
4663
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4664
+ className: "dab-crash-title",
4665
+ children: this.props.t("crashTitle")
4666
+ }),
4667
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4668
+ className: "dab-crash-desc",
4669
+ children: this.props.t("crashDesc")
4670
+ }),
4671
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
4672
+ className: "dab-crash-detail",
4673
+ children: detail
4674
+ }),
4675
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4676
+ type: "button",
4677
+ className: "dab-btn",
4678
+ onClick: this.reset,
4679
+ children: this.props.t("crashReset")
4680
+ })
4681
+ ]
4682
+ });
4683
+ }
4426
4684
  return this.props.children;
4427
4685
  }
4428
4686
  };
@@ -4571,6 +4829,10 @@ window.__ModuleLoader__.load({
4571
4829
  }, [col]);
4572
4830
  const pendingRef = (0, react.useRef)(null);
4573
4831
  const rafRef = (0, react.useRef)(null);
4832
+ /** Unmount-time cleanup for a drag that never saw its mouseup (panel closed
4833
+ * mid-drag): without it the document listeners stay attached forever and the
4834
+ * pending rAF keeps calling setState on a dead component. */
4835
+ const dragCleanupRef = (0, react.useRef)(null);
4574
4836
  const flushPending = (0, react.useCallback)(() => {
4575
4837
  rafRef.current = null;
4576
4838
  const p = pendingRef.current;
@@ -4612,13 +4874,25 @@ window.__ModuleLoader__.load({
4612
4874
  schedule(colRef.current.hue, s, l);
4613
4875
  }
4614
4876
  };
4615
- const onUp = () => {
4877
+ const stopDrag = () => {
4616
4878
  document.removeEventListener("mousemove", onMove);
4617
- document.removeEventListener("mouseup", onUp);
4879
+ document.removeEventListener("mouseup", stopDrag);
4880
+ dragCleanupRef.current = null;
4618
4881
  };
4882
+ dragCleanupRef.current = stopDrag;
4619
4883
  document.addEventListener("mousemove", onMove);
4620
- document.addEventListener("mouseup", onUp);
4884
+ document.addEventListener("mouseup", stopDrag);
4621
4885
  }, [schedule]);
4886
+ (0, react.useEffect)(() => {
4887
+ return () => {
4888
+ if (rafRef.current !== null) {
4889
+ cancelAnimationFrame(rafRef.current);
4890
+ rafRef.current = null;
4891
+ }
4892
+ pendingRef.current = null;
4893
+ dragCleanupRef.current?.();
4894
+ };
4895
+ }, []);
4622
4896
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("canvas", {
4623
4897
  ref: cvsRef,
4624
4898
  width: WHEEL_SIZE,
@@ -4667,6 +4941,7 @@ window.__ModuleLoader__.load({
4667
4941
  },
4668
4942
  onChange: (e) => {
4669
4943
  setText(e.target.value);
4944
+ if (e.target.value === "") return;
4670
4945
  const v = Number(e.target.value);
4671
4946
  if (Number.isFinite(v)) onChange(clamp(v, min, max));
4672
4947
  }
@@ -4783,9 +5058,6 @@ window.__ModuleLoader__.load({
4783
5058
  //#region src/client/components/ColorPicker.tsx
4784
5059
  const MAG_SIZE = 96;
4785
5060
  const MAG_ZOOM = 8;
4786
- function toHex$1(rgb) {
4787
- return "#" + rgb.map((v) => v.toString(16).padStart(2, "0")).join("");
4788
- }
4789
5061
  /**
4790
5062
  * Eyedropper modal: shows the wallpaper full-bleed (no drag/zoom) and lets the
4791
5063
  * user click any pixel to adopt it as the theme color. A magnifier circle
@@ -4815,7 +5087,7 @@ window.__ModuleLoader__.load({
4815
5087
  canvas.height = imgRef.current.naturalHeight;
4816
5088
  const ctx = canvas.getContext("2d");
4817
5089
  if (ctx) ctx.drawImage(imgRef.current, 0, 0);
4818
- }, [ready]);
5090
+ }, [ready, url]);
4819
5091
  const sampleAt = (0, react.useCallback)((clientX, clientY) => {
4820
5092
  const canvas = canvasRef.current;
4821
5093
  if (!canvas) return null;
@@ -4930,14 +5202,14 @@ window.__ModuleLoader__.load({
4930
5202
  height: 28,
4931
5203
  borderRadius: 8,
4932
5204
  border: "1px solid rgba(255,255,255,0.4)",
4933
- background: hover ? toHex$1(hover.rgb) : "transparent"
5205
+ background: hover ? rgbToHex(hover.rgb) : "transparent"
4934
5206
  } }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4935
5207
  style: {
4936
5208
  color: "#fff",
4937
5209
  fontSize: 13,
4938
5210
  fontFamily: "var(--dab-mono, monospace)"
4939
5211
  },
4940
- children: hover ? `${toHex$1(hover.rgb)} · rgb(${hover.rgb.join(", ")})` : "—"
5212
+ children: hover ? `${rgbToHex(hover.rgb)} · rgb(${hover.rgb.join(", ")})` : "—"
4941
5213
  })]
4942
5214
  }),
4943
5215
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -5020,9 +5292,6 @@ window.__ModuleLoader__.load({
5020
5292
  .58
5021
5293
  ]
5022
5294
  ];
5023
- function toHex(rgb) {
5024
- return "#" + rgb.map((v) => Math.round(v).toString(16).padStart(2, "0")).join("");
5025
- }
5026
5295
  function ColorPage({ p, notify }) {
5027
5296
  const { t, hue, sat, lit, setColor, extractColor, setSchemeOverride, useStore } = p;
5028
5297
  const storeUrl = useStore((s) => s.url);
@@ -5037,7 +5306,7 @@ window.__ModuleLoader__.load({
5037
5306
  ];
5038
5307
  const [h, s, l] = hsvToHsl(wheel[0], wheel[1], wheel[2]);
5039
5308
  const [r, g, b] = hslToRgb(h, s, l);
5040
- const hex = toHex([
5309
+ const hex = rgbToHex([
5041
5310
  r,
5042
5311
  g,
5043
5312
  b
@@ -5167,7 +5436,7 @@ window.__ModuleLoader__.load({
5167
5436
  type: "button",
5168
5437
  className: `dab-swatch${on ? " is-on" : ""}`,
5169
5438
  style: { background: `hsl(${sh} ${Math.round(ss * 100)}% ${Math.round(sl * 100)}%)` },
5170
- title: toHex(hslToRgb(sh, ss, sl)).toUpperCase(),
5439
+ title: rgbToHex(hslToRgb(sh, ss, sl)).toUpperCase(),
5171
5440
  onClick: () => setColor(...hslToHsv(sh, ss, sl))
5172
5441
  }, i);
5173
5442
  })
@@ -5242,15 +5511,16 @@ window.__ModuleLoader__.load({
5242
5511
  const paint = (el, v) => {
5243
5512
  el.style.setProperty("--pct", String((v - min) / (max - min) * 100));
5244
5513
  };
5514
+ const fmtRef = (0, react.useRef)(fmt);
5515
+ fmtRef.current = fmt;
5245
5516
  (0, react.useEffect)(() => {
5246
5517
  if (inputRef.current) {
5247
5518
  inputRef.current.value = String(def);
5248
5519
  paint(inputRef.current, def);
5249
5520
  }
5250
- if (valRef.current) valRef.current.textContent = fmt(def);
5521
+ if (valRef.current) valRef.current.textContent = fmtRef.current(def);
5251
5522
  }, [
5252
5523
  def,
5253
- fmt,
5254
5524
  min,
5255
5525
  max
5256
5526
  ]);
@@ -5410,7 +5680,8 @@ window.__ModuleLoader__.load({
5410
5680
  }
5411
5681
  ];
5412
5682
  function InterfacePage({ p }) {
5413
- const { t, setOps, setBlurs, setSop, setPanelOp } = p;
5683
+ const { t, setOps, setBlurs, setSop, setPanelOp, useStore } = p;
5684
+ useStore((s) => s.metaRev);
5414
5685
  const hasBetterSidebar = useBetterSidebar();
5415
5686
  const parts = PARTS$1.filter((part) => !(part.isPanel && !hasBetterSidebar));
5416
5687
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
@@ -5617,7 +5888,7 @@ window.__ModuleLoader__.load({
5617
5888
  }
5618
5889
  }
5619
5890
  function FontPage({ p, notify }) {
5620
- const { t, setFont, removeFont, setFontEnabled, setStrokes } = p;
5891
+ const { t, setFont, removeFont, setFontEnabled, setStrokes, useStore } = p;
5621
5892
  const hasBetterSidebar = useBetterSidebar();
5622
5893
  const fileRef = (0, react.useRef)(null);
5623
5894
  const [strokes, setStrokesState] = (0, react.useState)(() => rStrokes());
@@ -5627,6 +5898,15 @@ window.__ModuleLoader__.load({
5627
5898
  name: null
5628
5899
  }));
5629
5900
  const [busy, setBusy] = (0, react.useState)(false);
5901
+ const metaRev = useStore((s) => s.metaRev);
5902
+ (0, react.useEffect)(() => {
5903
+ setStrokesState(rStrokes());
5904
+ setFontInfo((prev) => prev.mime === cfg.fontMime && prev.enabled === cfg.fontEnabled ? prev : {
5905
+ mime: cfg.fontMime,
5906
+ enabled: cfg.fontEnabled,
5907
+ name: null
5908
+ });
5909
+ }, [metaRev]);
5630
5910
  const hasFont = fontInfo.mime !== null;
5631
5911
  const fontLabel = fontInfo.name ?? (fontInfo.mime !== null ? fontInfo.mime.replace("font/", "").toUpperCase() : t("fontNone"));
5632
5912
  /** Write one group's stroke: cfg + live DOM now, disk on commit. `live`
@@ -5651,9 +5931,9 @@ window.__ModuleLoader__.load({
5651
5931
  ...fontInfo,
5652
5932
  name: file.name
5653
5933
  });
5654
- const ok = await setFont(file);
5934
+ const outcome = await setFont(file);
5655
5935
  setBusy(false);
5656
- if (ok) setFontInfo({
5936
+ if (outcome.ok) setFontInfo({
5657
5937
  mime: cfg.fontMime,
5658
5938
  enabled: cfg.fontEnabled,
5659
5939
  name: file.name
@@ -5664,7 +5944,7 @@ window.__ModuleLoader__.load({
5664
5944
  enabled: cfg.fontEnabled,
5665
5945
  name: null
5666
5946
  });
5667
- notify(t("fontFail"), false);
5947
+ notify(uploadRefusalText(outcome, t, "fontFail"), false);
5668
5948
  }
5669
5949
  };
5670
5950
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
@@ -5878,6 +6158,19 @@ window.__ModuleLoader__.load({
5878
6158
  });
5879
6159
  const containerRef = (0, react.useRef)(null);
5880
6160
  const imgRef = (0, react.useRef)(null);
6161
+ /**
6162
+ * The overlay node is held in STATE rather than a plain ref, so the listener
6163
+ * effect below re-runs the moment it actually exists.
6164
+ *
6165
+ * This is not a style choice. Everything here renders through <Portal>, which
6166
+ * returns null on its first render — its own effect has to create the host
6167
+ * node under <html> first. A plain ref is therefore still null when a
6168
+ * mount-only effect runs, and an effect whose deps never change again attaches
6169
+ * nothing, ever. That is exactly how the touch handlers below ended up dead on
6170
+ * real phones: the wheel handler only survived because its deps churn on every
6171
+ * zoom/pan, so the wallpaper's img.onload re-ran it once and wired it up.
6172
+ */
6173
+ const [overlayEl, setOverlayEl] = (0, react.useState)(null);
5881
6174
  const dragRef = (0, react.useRef)({
5882
6175
  active: false,
5883
6176
  sx: 0,
@@ -5885,6 +6178,16 @@ window.__ModuleLoader__.load({
5885
6178
  spx: 0,
5886
6179
  spy: 0
5887
6180
  });
6181
+ /** Unmount-time cleanup for a drag that never saw its mouseup (editor closed
6182
+ * mid-drag): without it the document listeners stay attached forever and
6183
+ * keep calling setPos on a dead component. */
6184
+ const dragStopRef = (0, react.useRef)(null);
6185
+ /** Set once a gesture actually moved, so the click that follows a drag is not
6186
+ * mistaken for a tap on the backdrop (mousedown and mouseup in different
6187
+ * subtrees make the click land on the overlay, i.e. "outside"). Reset at the
6188
+ * start of every gesture — touchstart / mousedown / wheel — so it can only
6189
+ * ever describe the gesture that just ended, never an earlier one. */
6190
+ const movedRef = (0, react.useRef)(false);
5888
6191
  const viewRef = (0, react.useRef)({
5889
6192
  zoom,
5890
6193
  pos
@@ -5930,6 +6233,7 @@ window.__ModuleLoader__.load({
5930
6233
  ]);
5931
6234
  const onDown = (0, react.useCallback)((e) => {
5932
6235
  e.preventDefault();
6236
+ movedRef.current = false;
5933
6237
  dragRef.current = {
5934
6238
  active: true,
5935
6239
  sx: e.clientX,
@@ -5939,58 +6243,50 @@ window.__ModuleLoader__.load({
5939
6243
  };
5940
6244
  const onMove = (ev) => {
5941
6245
  if (!dragRef.current.active) return;
6246
+ if (Math.abs(ev.clientX - dragRef.current.sx) > 4 || Math.abs(ev.clientY - dragRef.current.sy) > 4) movedRef.current = true;
5942
6247
  setPos({
5943
6248
  x: dragRef.current.spx + ev.clientX - dragRef.current.sx,
5944
6249
  y: dragRef.current.spy + ev.clientY - dragRef.current.sy
5945
6250
  });
5946
6251
  };
5947
- const onUp = () => {
6252
+ const stopDrag = () => {
5948
6253
  dragRef.current.active = false;
5949
6254
  document.removeEventListener("mousemove", onMove);
5950
- document.removeEventListener("mouseup", onUp);
6255
+ document.removeEventListener("mouseup", stopDrag);
6256
+ dragStopRef.current = null;
5951
6257
  };
6258
+ dragStopRef.current = stopDrag;
5952
6259
  document.addEventListener("mousemove", onMove);
5953
- document.addEventListener("mouseup", onUp);
6260
+ document.addEventListener("mouseup", stopDrag);
5954
6261
  }, [pos]);
6262
+ (0, react.useEffect)(() => () => {
6263
+ dragStopRef.current?.();
6264
+ }, []);
5955
6265
  const onWheelCb = (0, react.useCallback)((e) => {
5956
6266
  e.preventDefault();
6267
+ movedRef.current = false;
5957
6268
  const el = containerRef.current;
5958
6269
  if (!el) return;
5959
6270
  const rect = el.getBoundingClientRect();
5960
6271
  const mx = rect.width / 2, my = rect.height / 2;
5961
6272
  const factor = e.deltaY > 0 ? .97 : 1.03;
5962
- const nz = clampZoom(zoom * factor);
5963
- const nx = mx - (mx - pos.x) * (nz / zoom);
5964
- const ny = my - (my - pos.y) * (nz / zoom);
6273
+ const { zoom: z, pos: p } = viewRef.current;
6274
+ const nz = clampZoom(z * factor);
6275
+ const nx = mx - (mx - p.x) * (nz / z);
6276
+ const ny = my - (my - p.y) * (nz / z);
5965
6277
  setZoom(nz);
5966
6278
  setPos({
5967
6279
  x: nx,
5968
6280
  y: ny
5969
6281
  });
5970
- }, [zoom, pos]);
5971
- (0, react.useEffect)(() => {
5972
- const el = containerRef.current;
5973
- if (!el) return;
5974
- el.addEventListener("wheel", onWheelCb, { passive: false });
5975
- return () => el.removeEventListener("wheel", onWheelCb);
5976
- }, [onWheelCb]);
6282
+ }, []);
5977
6283
  const onTouchStartCb = (0, react.useCallback)((e) => {
5978
- const el = containerRef.current;
5979
- if (!el) return;
5980
- if (e.touches.length === 1) {
5981
- pinchRef.current = null;
5982
- const tp = e.touches[0];
5983
- touchDragRef.current = {
5984
- sx: tp.clientX,
5985
- sy: tp.clientY,
5986
- spx: viewRef.current.pos.x,
5987
- spy: viewRef.current.pos.y
5988
- };
5989
- return;
5990
- }
6284
+ const card = containerRef.current;
6285
+ if (!card) return;
6286
+ movedRef.current = false;
5991
6287
  if (e.touches.length >= 2) {
5992
6288
  touchDragRef.current = null;
5993
- const rect = el.getBoundingClientRect();
6289
+ const rect = card.getBoundingClientRect();
5994
6290
  const a = e.touches[0], b = e.touches[1];
5995
6291
  pinchRef.current = {
5996
6292
  dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY),
@@ -6000,16 +6296,32 @@ window.__ModuleLoader__.load({
6000
6296
  px: viewRef.current.pos.x,
6001
6297
  py: viewRef.current.pos.y
6002
6298
  };
6299
+ return;
6300
+ }
6301
+ if (e.touches.length === 1) {
6302
+ pinchRef.current = null;
6303
+ if (!card.contains(e.target)) {
6304
+ touchDragRef.current = null;
6305
+ return;
6306
+ }
6307
+ const tp = e.touches[0];
6308
+ touchDragRef.current = {
6309
+ sx: tp.clientX,
6310
+ sy: tp.clientY,
6311
+ spx: viewRef.current.pos.x,
6312
+ spy: viewRef.current.pos.y
6313
+ };
6003
6314
  }
6004
6315
  }, []);
6005
6316
  const onTouchMoveCb = (0, react.useCallback)((e) => {
6006
- const el = containerRef.current;
6007
- if (!el) return;
6317
+ const card = containerRef.current;
6318
+ if (!card) return;
6008
6319
  if (e.touches.length >= 2 && pinchRef.current !== null) {
6009
6320
  e.preventDefault();
6321
+ movedRef.current = true;
6010
6322
  const p = pinchRef.current;
6011
6323
  if (p.dist <= 0) return;
6012
- const rect = el.getBoundingClientRect();
6324
+ const rect = card.getBoundingClientRect();
6013
6325
  const a = e.touches[0], b = e.touches[1];
6014
6326
  const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
6015
6327
  const cx = (a.clientX + b.clientX) / 2 - rect.left;
@@ -6026,6 +6338,7 @@ window.__ModuleLoader__.load({
6026
6338
  e.preventDefault();
6027
6339
  const d = touchDragRef.current;
6028
6340
  const tp = e.touches[0];
6341
+ if (Math.abs(tp.clientX - d.sx) > 4 || Math.abs(tp.clientY - d.sy) > 4) movedRef.current = true;
6029
6342
  setPos({
6030
6343
  x: d.spx + tp.clientX - d.sx,
6031
6344
  y: d.spy + tp.clientY - d.sy
@@ -6048,19 +6361,29 @@ window.__ModuleLoader__.load({
6048
6361
  }
6049
6362
  }, []);
6050
6363
  (0, react.useEffect)(() => {
6051
- const el = containerRef.current;
6052
- if (!el) return;
6053
- el.addEventListener("touchstart", onTouchStartCb, { passive: false });
6054
- el.addEventListener("touchmove", onTouchMoveCb, { passive: false });
6055
- el.addEventListener("touchend", onTouchEndCb, { passive: false });
6056
- el.addEventListener("touchcancel", onTouchEndCb, { passive: false });
6364
+ if (!overlayEl) return;
6365
+ const noPageGesture = (e) => e.preventDefault();
6366
+ overlayEl.addEventListener("wheel", onWheelCb, { passive: false });
6367
+ overlayEl.addEventListener("touchstart", onTouchStartCb, { passive: false });
6368
+ overlayEl.addEventListener("touchmove", onTouchMoveCb, { passive: false });
6369
+ overlayEl.addEventListener("touchend", onTouchEndCb, { passive: false });
6370
+ overlayEl.addEventListener("touchcancel", onTouchEndCb, { passive: false });
6371
+ overlayEl.addEventListener("gesturestart", noPageGesture, { passive: false });
6372
+ overlayEl.addEventListener("gesturechange", noPageGesture, { passive: false });
6373
+ overlayEl.addEventListener("gestureend", noPageGesture, { passive: false });
6057
6374
  return () => {
6058
- el.removeEventListener("touchstart", onTouchStartCb);
6059
- el.removeEventListener("touchmove", onTouchMoveCb);
6060
- el.removeEventListener("touchend", onTouchEndCb);
6061
- el.removeEventListener("touchcancel", onTouchEndCb);
6375
+ overlayEl.removeEventListener("wheel", onWheelCb);
6376
+ overlayEl.removeEventListener("touchstart", onTouchStartCb);
6377
+ overlayEl.removeEventListener("touchmove", onTouchMoveCb);
6378
+ overlayEl.removeEventListener("touchend", onTouchEndCb);
6379
+ overlayEl.removeEventListener("touchcancel", onTouchEndCb);
6380
+ overlayEl.removeEventListener("gesturestart", noPageGesture);
6381
+ overlayEl.removeEventListener("gesturechange", noPageGesture);
6382
+ overlayEl.removeEventListener("gestureend", noPageGesture);
6062
6383
  };
6063
6384
  }, [
6385
+ overlayEl,
6386
+ onWheelCb,
6064
6387
  onTouchStartCb,
6065
6388
  onTouchMoveCb,
6066
6389
  onTouchEndCb
@@ -6078,10 +6401,22 @@ window.__ModuleLoader__.load({
6078
6401
  imgSize
6079
6402
  ]);
6080
6403
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Portal, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6404
+ ref: setOverlayEl,
6081
6405
  className: "dab-overlay",
6406
+ style: {
6407
+ touchAction: "none",
6408
+ overscrollBehavior: "contain"
6409
+ },
6082
6410
  onClick: (e) => {
6411
+ if (movedRef.current) {
6412
+ movedRef.current = false;
6413
+ return;
6414
+ }
6083
6415
  if (e.target === e.currentTarget) onClose();
6084
6416
  },
6417
+ onMouseDown: () => {
6418
+ movedRef.current = false;
6419
+ },
6085
6420
  children: [
6086
6421
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6087
6422
  className: "dab-overlay-title",
@@ -6177,7 +6512,7 @@ window.__ModuleLoader__.load({
6177
6512
  labelKey: "bgModeCenter"
6178
6513
  }
6179
6514
  ];
6180
- function BackgroundPage({ p }) {
6515
+ function BackgroundPage({ p, notify }) {
6181
6516
  const { t, setWpFromServer, setVideo, setWop, setBl, setBgType, setGeneratedBg, regenerateBg, setRegenerateOnReload, setRotation, addRotationItems, removeRotationItem, rotateNow, setVideoFromUrl, useStore } = p;
6182
6517
  const storeUrl = useStore((s) => s.url);
6183
6518
  const backgroundType = useStore((s) => s.backgroundType);
@@ -6194,7 +6529,11 @@ window.__ModuleLoader__.load({
6194
6529
  const [urlVal, setUrlVal] = (0, react.useState)("");
6195
6530
  const [urlBusy, setUrlBusy] = (0, react.useState)(false);
6196
6531
  const [urlErr, setUrlErr] = (0, react.useState)(null);
6532
+ const bgRev = useStore((s) => s.bgRev);
6197
6533
  const [mode, setModeState] = (0, react.useState)(rBgMode());
6534
+ (0, react.useEffect)(() => {
6535
+ setModeState(rBgMode());
6536
+ }, [bgRev]);
6198
6537
  const isVideo = backgroundType === "video";
6199
6538
  const isStatic = backgroundType === "image" || isVideo;
6200
6539
  const isGenerated = !isStatic;
@@ -6208,14 +6547,15 @@ window.__ModuleLoader__.load({
6208
6547
  genPreset,
6209
6548
  generatedBg?.seed
6210
6549
  ]);
6211
- const onFileSelect = (f) => {
6550
+ const onFileSelect = async (f) => {
6212
6551
  if (f.type.startsWith("video/")) {
6213
- setVideo(f, f.type);
6552
+ const outcome = await setVideo(f, f.type);
6553
+ if (!outcome.ok) notify(uploadRefusalText(outcome, t, "videoUploadFail"), false);
6214
6554
  return;
6215
6555
  }
6216
- uploadWallpaper(f).then((ok) => {
6217
- if (ok) setWpFromServer(WALLPAPER_SERVE_URL);
6218
- });
6556
+ const outcome = await uploadWallpaper(f);
6557
+ if (outcome.ok) setWpFromServer(WALLPAPER_SERVE_URL);
6558
+ else notify(uploadRefusalText(outcome, t, "bgUploadFail"), false);
6219
6559
  };
6220
6560
  const onDrop = (e) => {
6221
6561
  e.preventDefault();
@@ -7489,31 +7829,37 @@ window.__ModuleLoader__.load({
7489
7829
  //#endregion
7490
7830
  //#region src/client/components/ThemeSection.tsx
7491
7831
  /**
7492
- * dsh-any-background — settings section shell.
7832
+ * dsh-any-background — appearance shell for the five pages.
7493
7833
  *
7494
- * The host renders this section only while the settings dialog is open and the
7495
- * section is the active nav entry, so mounting IS being visible. The section is
7496
- * rendered INLINE inside the host settings dialog's content column (the host
7497
- * provides the modal chrome); the shell is a left nav rail + page body. Only
7498
- * the transient toast escapes through a Portal (the shell's container-type
7499
- * containment would otherwise capture its fixed positioning).
7834
+ * Two surfaces render this shell, and only the chrome differs:
7835
+ * · `settings` — injected into the host settings dialog's content column,
7836
+ * which supplies the modal frame (nav rail on the left, brand block, page
7837
+ * max-width, the dialog's own scrolling);
7838
+ * · `sidebar` — a page inside dsh-better-sidebar, where the panel is much
7839
+ * narrower, is handed a full-height column, and has to own its own scroll
7840
+ * region. The brand block is dropped (the sidebar's tab bar already names
7841
+ * the page) and the rail turns into a compact row.
7842
+ * The five pages themselves never branch on the surface: width-driven
7843
+ * adaptation lives in the stylesheet's container queries, which measure the
7844
+ * shell itself rather than assuming either surface's width.
7500
7845
  */
7501
7846
  function ThemeSection(props) {
7502
7847
  ensureUiCss();
7503
7848
  const { t } = props;
7849
+ const surface = props.surface ?? "settings";
7504
7850
  const [page, setPage] = (0, react.useState)(0);
7505
7851
  const [toast, setToast] = (0, react.useState)(null);
7506
7852
  const toastTimer = (0, react.useRef)(void 0);
7507
7853
  (0, react.useEffect)(() => () => window.clearTimeout(toastTimer.current), []);
7508
- const notify = (msg, ok = true) => {
7854
+ const notify = (0, react.useCallback)((msg, ok = true) => {
7509
7855
  setToast({
7510
7856
  msg,
7511
7857
  ok
7512
7858
  });
7513
7859
  window.clearTimeout(toastTimer.current);
7514
7860
  toastTimer.current = window.setTimeout(() => setToast(null), 2600);
7515
- };
7516
- const pages = [
7861
+ }, []);
7862
+ const pages = (0, react.useMemo)(() => [
7517
7863
  {
7518
7864
  label: t("pageColor"),
7519
7865
  Icon: DropletIcon,
@@ -7538,7 +7884,10 @@ window.__ModuleLoader__.load({
7538
7884
  {
7539
7885
  label: t("pageBackground"),
7540
7886
  Icon: PhotoIcon,
7541
- node: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BackgroundPage, { p: props })
7887
+ node: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BackgroundPage, {
7888
+ p: props,
7889
+ notify
7890
+ })
7542
7891
  },
7543
7892
  {
7544
7893
  label: t("pageProfile"),
@@ -7548,16 +7897,21 @@ window.__ModuleLoader__.load({
7548
7897
  notify
7549
7898
  })
7550
7899
  }
7551
- ];
7900
+ ], [
7901
+ t,
7902
+ props,
7903
+ notify
7904
+ ]);
7552
7905
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(ErrorBoundary, {
7553
7906
  t,
7554
7907
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7555
7908
  className: "dab-root",
7909
+ "data-dab-surface": surface,
7556
7910
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7557
7911
  className: "dab-shell",
7558
7912
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("nav", {
7559
7913
  className: "dab-nav",
7560
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7914
+ children: [surface === "sidebar" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7561
7915
  className: "dab-brand",
7562
7916
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7563
7917
  className: "dab-brand-tile",
@@ -7597,6 +7951,138 @@ window.__ModuleLoader__.load({
7597
7951
  });
7598
7952
  }
7599
7953
  //#endregion
7954
+ //#region src/client/sidebar/store-hook.ts
7955
+ /**
7956
+ * dsh-any-background — selector hook over a host store instance.
7957
+ *
7958
+ * The settings slot kit hands the pages a `useStore(selector)` hook that is
7959
+ * bound to this plugin's store instance by the host renderer. A better-sidebar
7960
+ * page gets no such kit — its tab props carry `ctx`/`store`/`scope` for
7961
+ * better-sidebar's own store — so the same contract is rebuilt here from the
7962
+ * store instance itself, which already IS an observable source
7963
+ * (`getSnapshot`/`subscribe`, see `defineStore` in the host's client store).
7964
+ *
7965
+ * Semantics match the host binding on purpose: reduce the published snapshot
7966
+ * through the selector and compare with `Object.is`, so a page subscribing to
7967
+ * one field does not re-render on unrelated writes.
7968
+ */
7969
+ /**
7970
+ * Bind a store instance to a `useStore(selector)` hook. Call once per store and
7971
+ * share the result: the returned hook keeps one subscription identity for its
7972
+ * whole lifetime.
7973
+ * @param store - observable store instance (`defineStore` handle).
7974
+ * @returns a selector hook reading that instance.
7975
+ */
7976
+ function createStoreHook(store) {
7977
+ const subscribe = (onChange) => store.subscribe(onChange);
7978
+ const { getSnapshot } = store;
7979
+ return function useStoreSelector(selector) {
7980
+ const cache = (0, react.useRef)(null);
7981
+ const read = (0, react.useCallback)(() => {
7982
+ const snapshot = getSnapshot();
7983
+ const hit = cache.current;
7984
+ if (hit !== null && hit.snapshot === snapshot) return hit.value;
7985
+ const value = selector(snapshot);
7986
+ cache.current = {
7987
+ snapshot,
7988
+ value
7989
+ };
7990
+ return value;
7991
+ }, [selector]);
7992
+ return (0, react.useSyncExternalStore)(subscribe, read, read);
7993
+ };
7994
+ }
7995
+ //#endregion
7996
+ //#region src/client/sidebar/tab.tsx
7997
+ /**
7998
+ * dsh-any-background — dsh-better-sidebar integration.
7999
+ *
8000
+ * Publishes the plugin's appearance controls as a sidebar page ("主题") in
8001
+ * better-sidebar's tab registry. The page renders the SAME five-page tree as
8002
+ * the settings section — `ThemeSection` takes a `surface` variant — so there is
8003
+ * one UI to maintain, not two forks of it.
8004
+ *
8005
+ * The dependency is optional at every level, which is why nothing here imports
8006
+ * the package:
8007
+ * · the service is read off the client context at runtime, so a DSH install
8008
+ * without better-sidebar silently registers nothing, and a better-sidebar
8009
+ * that mounts *after* this plugin still gets the page (`ctx.inject` fires
8010
+ * whenever the service shows up, never when it never does);
8011
+ * · the plugin's own types are restated in the minimal local contract below
8012
+ * instead of imported — a value import would be rejected by the host's
8013
+ * client-bundle purity gate, and even a type-only import would make the
8014
+ * package a build-time dependency for everyone who does not run it.
8015
+ */
8016
+ /**
8017
+ * The active locale id, used as a re-render signal: page labels are resolved by
8018
+ * calling `t(...)` at render time, so the props object (and with it the memoized
8019
+ * page tree) has to be rebuilt when the language flips — otherwise the page
8020
+ * would keep painting the previous language until some unrelated store write
8021
+ * happened to re-render it.
8022
+ * @param locale - host locale service.
8023
+ * @returns the active locale id (a stable string, safe for `Object.is`).
8024
+ */
8025
+ function useActiveLocale(locale) {
8026
+ const subscribe = (0, react.useCallback)((onChange) => locale.subscribe(onChange), [locale]);
8027
+ const read = (0, react.useCallback)(() => locale.getSnapshot().active, [locale]);
8028
+ return (0, react.useSyncExternalStore)(subscribe, read, read);
8029
+ }
8030
+ function ThemeTab({ face, useStore, locale }) {
8031
+ const active = useActiveLocale(locale);
8032
+ const props = (0, react.useMemo)(() => ({
8033
+ ...face,
8034
+ useStore
8035
+ }), [
8036
+ face,
8037
+ useStore,
8038
+ active
8039
+ ]);
8040
+ return (0, react.createElement)(ThemeSection, {
8041
+ ...props,
8042
+ surface: "sidebar"
8043
+ });
8044
+ }
8045
+ /**
8046
+ * Register the appearance page with better-sidebar, if it is installed.
8047
+ *
8048
+ * Call from the client `apply`. The registration is owned by the injected
8049
+ * scope's effect, so unloading the plugin (or HMR) withdraws it — without that,
8050
+ * the next activation would throw on the duplicate id.
8051
+ * @param ctx - client context.
8052
+ * @param opts.face - lazy accessor for the shared business face.
8053
+ * @param opts.store - the plugin's store instance, or null on a host where the
8054
+ * store module never resolved (in that case there is nothing to bind and no
8055
+ * page is registered).
8056
+ */
8057
+ function registerThemeSidebarTab(ctx, opts) {
8058
+ const { store } = opts;
8059
+ if (store === null || typeof store.getSnapshot !== "function" || typeof store.subscribe !== "function") {
8060
+ console.warn("dsh-any-background: no observable store instance on this host; the sidebar page stays unregistered");
8061
+ return;
8062
+ }
8063
+ const locale = ctx.locale;
8064
+ const t = locale.bind(NS);
8065
+ const useStore = createStoreHook(store);
8066
+ const ORDER = 55;
8067
+ ctx.inject?.(["betterSidebar"], (scope) => {
8068
+ const service = scope.betterSidebar;
8069
+ if (service === void 0) return;
8070
+ const face = opts.face();
8071
+ scope.effect(() => service.registerTab({
8072
+ id: "dsh-any-background:theme",
8073
+ title: () => t("nav"),
8074
+ icon: (size) => (0, react.createElement)(SunIcon, { size }),
8075
+ order: ORDER,
8076
+ single: true,
8077
+ component: () => (0, react.createElement)(ThemeTab, {
8078
+ face,
8079
+ useStore,
8080
+ locale
8081
+ })
8082
+ }));
8083
+ });
8084
+ }
8085
+ //#endregion
7600
8086
  //#region src/client/index.tsx
7601
8087
  /**
7602
8088
  * dsh-any-background — browser half entry.
@@ -7617,6 +8103,7 @@ window.__ModuleLoader__.load({
7617
8103
  initRpc((endpoint, payload) => ctx.connection.rpc.call(RPC_CHANNEL, endpoint, payload).then((res) => res));
7618
8104
  const [initH, initS, initL] = rColor();
7619
8105
  let customDispose = null;
8106
+ let registerFailed = false;
7620
8107
  const registerCustom = (h, s, l) => {
7621
8108
  customDispose?.();
7622
8109
  try {
@@ -7649,23 +8136,47 @@ window.__ModuleLoader__.load({
7649
8136
  colorScheme,
7650
8137
  tokens
7651
8138
  });
7652
- } catch {
8139
+ } catch (e) {
8140
+ if (!(() => {
8141
+ try {
8142
+ return ctx.theme.getTheme().themes.some((t) => t.id === CUSTOM_ID);
8143
+ } catch {
8144
+ return false;
8145
+ }
8146
+ })()) {
8147
+ if (!registerFailed) {
8148
+ registerFailed = true;
8149
+ console.error("dsh-any-background: host theme register failed; the custom skin will stay inactive", e);
8150
+ }
8151
+ customDispose = null;
8152
+ return false;
8153
+ }
7653
8154
  customDispose = null;
7654
8155
  }
7655
- const present = ctx.theme.getTheme().themes.some((t) => t.id === CUSTOM_ID);
8156
+ const present = (() => {
8157
+ try {
8158
+ return ctx.theme.getTheme().themes.some((t) => t.id === CUSTOM_ID);
8159
+ } catch {
8160
+ return false;
8161
+ }
8162
+ })();
7656
8163
  if (present) ctx.theme.setTheme(CUSTOM_ID);
7657
8164
  return present;
7658
8165
  };
7659
8166
  if (rHasColor()) registerCustom(initH, initS, initL);
7660
- onVerdictApplied(() => {
8167
+ const disposeVerdict = onVerdictApplied(() => {
7661
8168
  if (!rHasColor()) registerCustom();
7662
8169
  });
7663
- onColorAdopted((hsl) => {
8170
+ const disposeColorAdopted = onColorAdopted((hsl) => {
7664
8171
  registerCustom(hsl[0], hsl[1], hsl[2]);
7665
8172
  saveConfig();
7666
8173
  colorRev++;
7667
8174
  bound?.syncColor(hslToHsv(hsl[0], hsl[1], hsl[2]), colorRev);
7668
8175
  });
8176
+ ctx.effect(() => () => {
8177
+ disposeVerdict();
8178
+ disposeColorAdopted();
8179
+ }, "dsh-any-background: verdict/color listeners");
7669
8180
  ctx.effect(() => () => {
7670
8181
  customDispose?.();
7671
8182
  if (colorTimerRef.current !== null) window.clearTimeout(colorTimerRef.current);
@@ -7683,7 +8194,7 @@ window.__ModuleLoader__.load({
7683
8194
  let colorRev = 0;
7684
8195
  let bgRev = 0;
7685
8196
  const colorTimerRef = { current: null };
7686
- const store = defineStore({
8197
+ const storeSpec = defineStore === null ? null : defineStore({
7687
8198
  init: () => ({
7688
8199
  url: null,
7689
8200
  rev: -1,
@@ -7734,13 +8245,44 @@ window.__ModuleLoader__.load({
7734
8245
  }
7735
8246
  }
7736
8247
  });
8248
+ /** One shared instance, whatever the host build handed back.
8249
+ *
8250
+ * The plugin publishes a single appearance state that BOTH surfaces read —
8251
+ * the settings section (through the renderer's `useStore`) and the
8252
+ * better-sidebar page (through the hook in `sidebar/store-hook`). Handing the
8253
+ * host a declaration whose `create` always returns this very instance is what
8254
+ * keeps them from drifting: letting the renderer mint its own would give the
8255
+ * sidebar a second, permanently empty copy of the state. */
8256
+ const storeInstance = storeSpec === null ? null : typeof storeSpec.getSnapshot === "function" ? storeSpec : storeSpec.create();
8257
+ /** What gets registered: the instance on newer builds, a single-instance
8258
+ * declaration on the ones that expect a declaration. */
8259
+ const store = storeSpec === null || storeInstance === storeSpec ? storeSpec : {
8260
+ spec: storeSpec.spec ?? {},
8261
+ create: () => storeInstance
8262
+ };
7737
8263
  let bound = null;
8264
+ if (storeInstance !== null) bound = storeInstance.actions;
7738
8265
  const syncBg = () => {
7739
8266
  rev++;
7740
8267
  bgRev++;
7741
8268
  bound?.syncBg(rWp(), rev, cfg.backgroundType, cfg.generatedBg, bgRev, cfg.regenerateOnReload);
7742
8269
  };
7743
- onGeneratedSnapshot(syncBg);
8270
+ const disposeSnapshot = onGeneratedSnapshot(syncBg);
8271
+ ctx.effect(() => () => disposeSnapshot(), "dsh-any-background: snapshot listener");
8272
+ /** Capture the first frame of the video now applied and finish its
8273
+ * adaptation: the frame stands in for every still-image API (previews,
8274
+ * color extraction) and the brightness verdict follows it. The stale guard
8275
+ * skips a capture whose video was swapped out from under it (another
8276
+ * upload or a rotation switch landing mid-decode). */
8277
+ const captureAndApplyVideo = (url) => {
8278
+ if (url === null) return;
8279
+ captureVideoSnapshot(url).then((snap) => {
8280
+ if (rWpVideo() !== url) return;
8281
+ setWpVideoSnapshot(snap);
8282
+ applyThemeColor();
8283
+ syncBg();
8284
+ });
8285
+ };
7744
8286
  let metaRev = 0;
7745
8287
  const syncMetaNow = () => {
7746
8288
  metaRev++;
@@ -7861,7 +8403,7 @@ window.__ModuleLoader__.load({
7861
8403
  const scheduleTick = () => {
7862
8404
  const sc = rSchedule();
7863
8405
  if (!sc.enabled) return;
7864
- const want = (sc.mode === "system" ? window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false : isNightNow(sc)) ? sc.nightProfile : sc.dayProfile;
8406
+ const want = (sc.mode === "system" ? window.matchMedia?.("(prefers-color-scheme: dark)")?.matches ?? false : isNightNow(sc)) ? sc.nightProfile : sc.dayProfile;
7865
8407
  if (!want || want === cfg.activeProfile) return;
7866
8408
  applyProfileById(want);
7867
8409
  };
@@ -7889,12 +8431,7 @@ window.__ModuleLoader__.load({
7889
8431
  if (cfg.backgroundType === "video") {
7890
8432
  const v = rWpVideo();
7891
8433
  if (v) {
7892
- captureVideoSnapshot(v).then((snap) => {
7893
- if (rWpVideo() !== v) return;
7894
- setWpVideoSnapshot(snap);
7895
- applyThemeColor();
7896
- syncBg();
7897
- });
8434
+ captureAndApplyVideo(v);
7898
8435
  applyWp();
7899
8436
  } else {
7900
8437
  cfg.backgroundType = "image";
@@ -7944,19 +8481,25 @@ window.__ModuleLoader__.load({
7944
8481
  document.body.append(sentinel);
7945
8482
  const viewportObserver = new ResizeObserver(applySoon);
7946
8483
  viewportObserver.observe(sentinel);
7947
- const dprQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
7948
- dprQuery.addEventListener("change", applySoon);
8484
+ const dprQuery = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`);
8485
+ dprQuery?.addEventListener?.("change", applySoon);
7949
8486
  ctx.effect(() => () => {
7950
8487
  viewportObserver.disconnect();
7951
- dprQuery.removeEventListener("change", applySoon);
8488
+ dprQuery?.removeEventListener?.("change", applySoon);
7952
8489
  sentinel.remove();
7953
8490
  }, "dsh-any-background: viewport watch");
7954
8491
  ctx.effect(() => ctx.locale.register(NS, {
7955
8492
  zh,
7956
8493
  en
7957
8494
  }), "dsh-any-background: i18n");
7958
- const sectionInject = (actions) => {
7959
- bound = actions;
8495
+ /** The business face, built once on first use and shared by both surfaces
8496
+ * (the settings section and the better-sidebar page). Memoizing is not just
8497
+ * about cost: the face closes over per-surface state — the font preview blob
8498
+ * URL behind releaseFontPreview — so a second build would give the sidebar
8499
+ * its own preview slot and leak whichever copy never gets released. */
8500
+ let themeFace = null;
8501
+ const buildFace = () => {
8502
+ if (themeFace !== null) return themeFace;
7960
8503
  syncBg();
7961
8504
  syncMetaNow();
7962
8505
  const playVideoFromBlob = (blob, mime) => {
@@ -7967,15 +8510,10 @@ window.__ModuleLoader__.load({
7967
8510
  setWpVideoUrl(localUrl, mime ?? blob.type ?? "video/mp4");
7968
8511
  applyWp();
7969
8512
  syncBg();
7970
- const applied = rWpVideo();
7971
- captureVideoSnapshot(localUrl).then((snap) => {
7972
- if (rWpVideo() !== applied) return;
7973
- setWpVideoSnapshot(snap);
7974
- applyThemeColor();
7975
- syncBg();
7976
- });
7977
- uploadVideo(blob, mime ?? blob.type ?? "video/mp4").then((ok) => {
7978
- if (ok) persistConfig();
8513
+ captureAndApplyVideo(rWpVideo());
8514
+ return uploadVideo(blob, mime ?? blob.type ?? "video/mp4").then((outcome) => {
8515
+ if (outcome.ok) persistConfig();
8516
+ return outcome;
7979
8517
  });
7980
8518
  };
7981
8519
  const FONT_EXT_MIME = {
@@ -8002,7 +8540,7 @@ window.__ModuleLoader__.load({
8002
8540
  };
8003
8541
  const [wh, ws, wl] = rColor();
8004
8542
  const [dh, ds, dv] = hslToHsv(wh, ws, wl);
8005
- return {
8543
+ const built = {
8006
8544
  t: ctx.locale.bind(NS),
8007
8545
  hue: dh,
8008
8546
  sat: ds,
@@ -8052,13 +8590,11 @@ window.__ModuleLoader__.load({
8052
8590
  applyThemeColor();
8053
8591
  syncBg();
8054
8592
  saveConfig();
8055
- return;
8056
- }
8057
- if (typeof u !== "string") {
8058
- playVideoFromBlob(u, mime);
8059
- return;
8593
+ return { ok: true };
8060
8594
  }
8061
- const live = await persistVideo(u) ? VIDEO_SERVE_URL : u;
8595
+ if (typeof u !== "string") return playVideoFromBlob(u, mime);
8596
+ const ok = await persistVideo(u);
8597
+ const live = ok ? VIDEO_SERVE_URL : u;
8062
8598
  cfg.backgroundType = "video";
8063
8599
  setWpUrl(null);
8064
8600
  cfg.videoBgState = { ...DEFAULT_CONFIG.bgState };
@@ -8066,13 +8602,8 @@ window.__ModuleLoader__.load({
8066
8602
  applyWp();
8067
8603
  saveConfig();
8068
8604
  syncBg();
8069
- const applied = rWpVideo();
8070
- captureVideoSnapshot(live).then((snap) => {
8071
- if (rWpVideo() !== applied) return;
8072
- setWpVideoSnapshot(snap);
8073
- applyThemeColor();
8074
- syncBg();
8075
- });
8605
+ captureAndApplyVideo(rWpVideo());
8606
+ return { ok };
8076
8607
  },
8077
8608
  setBgType: (type) => {
8078
8609
  setBackgroundType(type);
@@ -8116,18 +8647,21 @@ window.__ModuleLoader__.load({
8116
8647
  const localUrl = URL.createObjectURL(file);
8117
8648
  fontPreviewUrl = localUrl;
8118
8649
  applyFontFace(localUrl, true, fontMimeFromName(file.name));
8119
- const mime = await uploadFont(file);
8120
- if (mime === null) {
8650
+ const outcome = await uploadFont(file);
8651
+ if (!outcome.ok || outcome.mime === void 0) {
8121
8652
  applyStoredFont();
8122
8653
  releaseFontPreview(4e3);
8123
- return false;
8654
+ return {
8655
+ ok: false,
8656
+ refusal: outcome.refusal
8657
+ };
8124
8658
  }
8125
- cfg.fontMime = mime;
8659
+ cfg.fontMime = outcome.mime;
8126
8660
  cfg.fontEnabled = true;
8127
- applyFontFace(FONT_SERVE_URL, true, mime);
8661
+ applyFontFace(FONT_SERVE_URL, true, outcome.mime);
8128
8662
  releaseFontPreview(4e3);
8129
8663
  persistConfig();
8130
- return true;
8664
+ return { ok: true };
8131
8665
  },
8132
8666
  removeFont: () => {
8133
8667
  releaseFontPreview(0);
@@ -8213,7 +8747,7 @@ window.__ModuleLoader__.load({
8213
8747
  a.href = url;
8214
8748
  a.download = "dsh-any-theme.json";
8215
8749
  a.click();
8216
- URL.revokeObjectURL(url);
8750
+ window.setTimeout(() => URL.revokeObjectURL(url), 4e3);
8217
8751
  },
8218
8752
  importTheme: async (file) => {
8219
8753
  try {
@@ -8233,16 +8767,9 @@ window.__ModuleLoader__.load({
8233
8767
  }
8234
8768
  if (blob !== null) playVideoFromBlob(blob, cfg.videoMime);
8235
8769
  else {
8236
- const live = await persistVideo(video) ? VIDEO_SERVE_URL : video;
8237
- setWpVideoUrl(live, cfg.videoMime);
8770
+ setWpVideoUrl(await persistVideo(video) ? VIDEO_SERVE_URL : video, cfg.videoMime);
8238
8771
  applyWp();
8239
- const applied = rWpVideo();
8240
- captureVideoSnapshot(live).then((snap) => {
8241
- if (rWpVideo() !== applied) return;
8242
- setWpVideoSnapshot(snap);
8243
- applyThemeColor();
8244
- syncBg();
8245
- });
8772
+ captureAndApplyVideo(rWpVideo());
8246
8773
  }
8247
8774
  } else {
8248
8775
  setWpVideoUrl(null, null);
@@ -8401,18 +8928,19 @@ window.__ModuleLoader__.load({
8401
8928
  applyWp();
8402
8929
  saveConfig();
8403
8930
  syncBg();
8404
- const applied = rWpVideo();
8405
- captureVideoSnapshot(applied).then((snap) => {
8406
- if (rWpVideo() !== applied) return;
8407
- setWpVideoSnapshot(snap);
8408
- applyThemeColor();
8409
- syncBg();
8410
- });
8931
+ captureAndApplyVideo(rWpVideo());
8411
8932
  return true;
8412
8933
  }
8413
8934
  };
8935
+ themeFace = built;
8936
+ return built;
8414
8937
  };
8415
- ctx.slots.inject("settings.section", () => ctx.slots.register({
8938
+ const sectionInject = (actions) => {
8939
+ bound = actions;
8940
+ return buildFace();
8941
+ };
8942
+ if (store === null) console.error("dsh-any-background: settings panel disabled on this host (no store module)");
8943
+ else ctx.slots.inject("settings.section", () => ctx.slots.register({
8416
8944
  name: "settings.section",
8417
8945
  id: "dsh-any-background",
8418
8946
  order: 35,
@@ -8421,6 +8949,10 @@ window.__ModuleLoader__.load({
8421
8949
  store,
8422
8950
  inject: sectionInject
8423
8951
  }, ThemeSection));
8952
+ registerThemeSidebarTab(ctx, {
8953
+ face: buildFace,
8954
+ store: storeInstance
8955
+ });
8424
8956
  const navLabel = () => ctx.locale.bind(NS)("nav");
8425
8957
  const applyNavIcon = () => {
8426
8958
  const nav = document.querySelector("[role=\"dialog\"][aria-modal=\"true\"][aria-labelledby]")?.querySelector("nav");