dsh-any-background 0.2.8 → 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";
@@ -24,10 +30,12 @@ window.__ModuleLoader__.load({
24
30
  close: "关闭",
25
31
  pageColor: "色彩",
26
32
  pageInterface: "界面",
33
+ pageFont: "字体",
27
34
  pageBackground: "背景",
28
35
  pageProfile: "配置",
29
36
  descColor: "拖动色轮或输入精确数值,整个界面的配色将实时跟随变化",
30
37
  descInterface: "为主界面的各个区域单独调节透明度与模糊,营造空间层次感",
38
+ descFont: "上传自定义字体为整套界面换字形,并按区域为文字添加描边,让内容在复杂的壁纸上依然清晰",
31
39
  descBackground: "上传一张图片或一段视频作为壁纸并自由摆放,或让算法为你实时生成动态背景",
32
40
  descProfile: "一键套用外观预设,保存多套配置随时切换,设置昼夜自动换装,或导出/导入完整主题",
33
41
  colorTitle: "主题色",
@@ -46,6 +54,28 @@ window.__ModuleLoader__.load({
46
54
  uiTrajectory: "轨迹页",
47
55
  uiPanelRegion: "better-sidebar 工作台",
48
56
  uiProduced: "产出物/高亮内容",
57
+ fontPageTitle: "字体与描边",
58
+ fontTitle: "自定义字体",
59
+ fontHint: "支持 ttf/otf/woff/woff2 字体文件(上限 100MB),将应用到整个界面的文字;代码块的等宽字体保持不变。中文字体通常有几十 MB,首次上传与刷新加载需要片刻",
60
+ fontUpload: "上传字体",
61
+ fontUploading: "上传中…",
62
+ fontRemove: "移除字体",
63
+ fontEnabled: "启用字体",
64
+ fontNone: "未设置,使用宿主默认字体",
65
+ fontFail: "字体上传失败,请确认是有效的 ttf/otf/woff/woff2 文件",
66
+ uploadTooLarge: "文件过大,上限 {limit}",
67
+ bgUploadFail: "壁纸上传失败",
68
+ videoUploadFail: "视频上传失败,本次播放不会被保存",
69
+ strokeTitle: "文字描边",
70
+ strokeHint: "按区域为文字添加描边:粗细为 0 表示关闭,「自动」会生成与文字颜色相反的颜色,「主题色」跟随当前主题色。代码块与图标已自动豁免,多色语法不会糊成一团",
71
+ strokeWidth: "描边粗细",
72
+ strokeColor: "描边颜色",
73
+ strokeAuto: "自动反色",
74
+ strokeGray: "灰色",
75
+ strokeBlack: "黑色",
76
+ strokeWhite: "白色",
77
+ strokeTheme: "主题色",
78
+ strokeCustom: "自定义",
49
79
  bgTitle: "背景",
50
80
  bgChoose: "选择图片或视频",
51
81
  bgRemove: "移除背景",
@@ -73,7 +103,7 @@ window.__ModuleLoader__.load({
73
103
  liveBadge: "动态壁纸",
74
104
  bgHint: "「适应」布局模式下悬停预览可编辑图片/视频的位置与缩放(其余布局模式按模式自动排布);动态生成模式下可选择类型、预设与参数。绑定种子后刷新页面壁纸保持不变,取消绑定则每次刷新随机换新",
75
105
  editorTitle: "背景编辑器",
76
- editorHint: "拖动移动画面,滚轮缩放大小",
106
+ editorHint: "拖动移动画面,滚轮或双指缩放大小",
77
107
  editorCommit: "确认",
78
108
  editorCancel: "取消",
79
109
  editorReset: "重置",
@@ -189,10 +219,12 @@ window.__ModuleLoader__.load({
189
219
  close: "Close",
190
220
  pageColor: "Color",
191
221
  pageInterface: "Interface",
222
+ pageFont: "Font",
192
223
  pageBackground: "Background",
193
224
  pageProfile: "Profile",
194
225
  descColor: "Drag the wheel or type exact values — the whole UI follows live",
195
226
  descInterface: "Tune opacity and blur per surface to build depth",
227
+ descFont: "Swap the interface typeface with your own font file, and outline text per region so it stays legible over busy wallpapers",
196
228
  descBackground: "Upload an image or a video as wallpaper and place it freely, or let the algorithm paint a live background",
197
229
  descProfile: "Apply a one-click preset, save multiple named profiles, schedule a day/night switch, or export/import the full theme",
198
230
  colorTitle: "Theme color",
@@ -211,6 +243,28 @@ window.__ModuleLoader__.load({
211
243
  uiTrajectory: "Trajectory view",
212
244
  uiPanelRegion: "better-sidebar workbench",
213
245
  uiProduced: "Produced / highlights",
246
+ fontPageTitle: "Font & outline",
247
+ fontTitle: "Custom font",
248
+ fontHint: "Accepts ttf/otf/woff/woff2 files (up to 100MB) and applies them to the whole interface; code blocks keep their monospace stack. CJK fonts routinely span tens of MB, so the first upload and each reload take a moment",
249
+ fontUpload: "Upload font",
250
+ fontUploading: "Uploading…",
251
+ fontRemove: "Remove font",
252
+ fontEnabled: "Font enabled",
253
+ fontNone: "None — host default",
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",
258
+ strokeTitle: "Text outline",
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",
260
+ strokeWidth: "Outline width",
261
+ strokeColor: "Outline color",
262
+ strokeAuto: "Auto contrast",
263
+ strokeGray: "Gray",
264
+ strokeBlack: "Black",
265
+ strokeWhite: "White",
266
+ strokeTheme: "Accent",
267
+ strokeCustom: "Custom",
214
268
  bgTitle: "Background",
215
269
  bgChoose: "Choose image or video",
216
270
  bgRemove: "Remove background",
@@ -238,7 +292,7 @@ window.__ModuleLoader__.load({
238
292
  liveBadge: "Live wallpaper",
239
293
  bgHint: "In Fit layout mode hover the preview to reposition and zoom the image/video (other modes arrange it automatically); in generated mode pick a type, preset and parameters. With the seed locked the wallpaper stays the same across refreshes; unlocked, every refresh randomizes it",
240
294
  editorTitle: "Background editor",
241
- editorHint: "Drag to move, scroll to zoom",
295
+ editorHint: "Drag to move; scroll or pinch with two fingers to zoom",
242
296
  editorCommit: "Confirm",
243
297
  editorCancel: "Cancel",
244
298
  editorReset: "Reset",
@@ -353,25 +407,72 @@ window.__ModuleLoader__.load({
353
407
  const DEFAULT_CONFIG = {
354
408
  color: null,
355
409
  opacities: {
356
- bg: .85,
357
- sidebar: .93,
358
- card: 1,
359
- input: 1
410
+ bg: .5,
411
+ sidebar: .5,
412
+ card: .5,
413
+ input: .5
360
414
  },
361
415
  blurs: {
362
- bg: 0,
363
- sidebar: 0,
364
- card: 0,
365
- settings: 0,
366
- chat: 0,
367
- trajectory: 0,
368
- input: 0,
369
- panel: 0,
370
- produced: 0
416
+ bg: 30,
417
+ sidebar: 30,
418
+ card: 30,
419
+ settings: 30,
420
+ chat: 30,
421
+ trajectory: 30,
422
+ input: 30,
423
+ panel: 30,
424
+ produced: 30
425
+ },
426
+ strokes: {
427
+ bg: {
428
+ width: 0,
429
+ color: "auto",
430
+ customColor: "#808080"
431
+ },
432
+ sidebar: {
433
+ width: 0,
434
+ color: "auto",
435
+ customColor: "#808080"
436
+ },
437
+ card: {
438
+ width: 0,
439
+ color: "auto",
440
+ customColor: "#808080"
441
+ },
442
+ settings: {
443
+ width: 0,
444
+ color: "auto",
445
+ customColor: "#808080"
446
+ },
447
+ chat: {
448
+ width: 0,
449
+ color: "auto",
450
+ customColor: "#808080"
451
+ },
452
+ trajectory: {
453
+ width: 0,
454
+ color: "auto",
455
+ customColor: "#808080"
456
+ },
457
+ input: {
458
+ width: 0,
459
+ color: "auto",
460
+ customColor: "#808080"
461
+ },
462
+ panel: {
463
+ width: 0,
464
+ color: "auto",
465
+ customColor: "#808080"
466
+ },
467
+ produced: {
468
+ width: 0,
469
+ color: "auto",
470
+ customColor: "#808080"
471
+ }
371
472
  },
372
- settingsOpacity: 1,
473
+ settingsOpacity: .5,
373
474
  wallpaperOpacity: 1,
374
- blur: 0,
475
+ blur: 30,
375
476
  bgState: {
376
477
  zoom: 1,
377
478
  x: 0,
@@ -389,12 +490,14 @@ window.__ModuleLoader__.load({
389
490
  backgroundType: "image",
390
491
  bgMode: "fit",
391
492
  videoMime: null,
493
+ fontMime: null,
494
+ fontEnabled: true,
392
495
  generatedBg: null,
393
496
  regenerateOnReload: false,
394
- chatTextOpacity: 0,
395
- trajectoryOpacity: 1,
396
- panelOpacity: 1,
397
- producedOpacity: 1,
497
+ chatTextOpacity: .5,
498
+ trajectoryOpacity: .5,
499
+ panelOpacity: .5,
500
+ producedOpacity: .5,
398
501
  profiles: [],
399
502
  rotation: {
400
503
  enabled: false,
@@ -420,6 +523,7 @@ window.__ModuleLoader__.load({
420
523
  ...DEFAULT_CONFIG,
421
524
  opacities: { ...DEFAULT_CONFIG.opacities },
422
525
  blurs: { ...DEFAULT_CONFIG.blurs },
526
+ strokes: structuredClone(DEFAULT_CONFIG.strokes),
423
527
  bgState: { ...DEFAULT_CONFIG.bgState },
424
528
  videoBgState: { ...DEFAULT_CONFIG.videoBgState }
425
529
  };
@@ -540,6 +644,49 @@ window.__ModuleLoader__.load({
540
644
  }
541
645
  return out;
542
646
  }
647
+ const STROKE_GROUPS = [
648
+ "bg",
649
+ "sidebar",
650
+ "card",
651
+ "settings",
652
+ "chat",
653
+ "trajectory",
654
+ "input",
655
+ "panel",
656
+ "produced"
657
+ ];
658
+ const STROKE_COLOR_KEYS = [
659
+ "auto",
660
+ "gray",
661
+ "black",
662
+ "white",
663
+ "theme",
664
+ "custom"
665
+ ];
666
+ const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
667
+ const DEFAULT_STROKE = {
668
+ width: 0,
669
+ color: "auto",
670
+ customColor: "#808080"
671
+ };
672
+ function adoptStroke(raw) {
673
+ const s = raw ?? {};
674
+ return {
675
+ width: typeof s.width === "number" && isFinite(s.width) ? Math.min(4, Math.max(0, s.width)) : DEFAULT_STROKE.width,
676
+ color: STROKE_COLOR_KEYS.includes(s.color) ? s.color : DEFAULT_STROKE.color,
677
+ customColor: typeof s.customColor === "string" && HEX_RE.test(s.customColor) ? s.customColor : DEFAULT_STROKE.customColor
678
+ };
679
+ }
680
+ /** Move a possibly-partial strokes map into the full shape the UI reads. */
681
+ function strokesFrom(raw) {
682
+ const s = raw ?? {};
683
+ const out = {};
684
+ for (const k of STROKE_GROUPS) out[k] = adoptStroke(s[k]);
685
+ return out;
686
+ }
687
+ function rStrokes() {
688
+ return strokesFrom(cfg.strokes);
689
+ }
543
690
  function rWop() {
544
691
  return clamp01(cfg.wallpaperOpacity, DEFAULT_CONFIG.wallpaperOpacity);
545
692
  }
@@ -611,6 +758,7 @@ window.__ModuleLoader__.load({
611
758
  color: cfg.color,
612
759
  opacities: { ...rOps() },
613
760
  blurs: { ...rBlurs() },
761
+ strokes: rStrokes(),
614
762
  settingsOpacity: rSop(),
615
763
  wallpaperOpacity: rWop(),
616
764
  blur: rBl(),
@@ -631,6 +779,7 @@ window.__ModuleLoader__.load({
631
779
  ...DEFAULT_CONFIG.blurs,
632
780
  ...ap.blurs ?? {}
633
781
  };
782
+ cfg.strokes = strokesFrom(ap.strokes);
634
783
  cfg.settingsOpacity = clamp01(ap.settingsOpacity, DEFAULT_CONFIG.settingsOpacity);
635
784
  cfg.wallpaperOpacity = clamp01(ap.wallpaperOpacity, DEFAULT_CONFIG.wallpaperOpacity);
636
785
  cfg.blur = typeof ap.blur === "number" ? Math.min(60, Math.max(0, ap.blur)) : DEFAULT_CONFIG.blur;
@@ -664,6 +813,7 @@ window.__ModuleLoader__.load({
664
813
  ...DEFAULT_CONFIG.blurs,
665
814
  ...ac.blurs ?? {}
666
815
  },
816
+ strokes: strokesFrom(ac.strokes),
667
817
  settingsOpacity: clamp01(ac.settingsOpacity, DEFAULT_CONFIG.settingsOpacity),
668
818
  wallpaperOpacity: clamp01(ac.wallpaperOpacity, DEFAULT_CONFIG.wallpaperOpacity),
669
819
  blur: num(ac.blur, DEFAULT_CONFIG.blur),
@@ -761,6 +911,7 @@ window.__ModuleLoader__.load({
761
911
  input: num(ops.input, DEFAULT_CONFIG.opacities.input)
762
912
  },
763
913
  blurs,
914
+ strokes: strokesFrom(c.strokes),
764
915
  settingsOpacity: num(c.settingsOpacity, DEFAULT_CONFIG.settingsOpacity),
765
916
  wallpaperOpacity: num(c.wallpaperOpacity, DEFAULT_CONFIG.wallpaperOpacity),
766
917
  blur: num(c.blur, DEFAULT_CONFIG.blur),
@@ -769,6 +920,8 @@ window.__ModuleLoader__.load({
769
920
  backgroundType: bgType,
770
921
  bgMode,
771
922
  videoMime: typeof c.videoMime === "string" ? c.videoMime : null,
923
+ fontMime: typeof c.fontMime === "string" ? c.fontMime : null,
924
+ fontEnabled: typeof c.fontEnabled === "boolean" ? c.fontEnabled : DEFAULT_CONFIG.fontEnabled,
772
925
  generatedBg: generatedBg ? normalizeGeneratedBg(generatedBg) : null,
773
926
  regenerateOnReload: typeof c.regenerateOnReload === "boolean" ? c.regenerateOnReload : DEFAULT_CONFIG.regenerateOnReload,
774
927
  chatTextOpacity: clamp01(c.chatTextOpacity, DEFAULT_CONFIG.chatTextOpacity),
@@ -828,9 +981,15 @@ window.__ModuleLoader__.load({
828
981
  const WALLPAPER_UPLOAD_URL = "/dsh-any-background/wallpaper/upload";
829
982
  /** HTTP route new videos are POSTed to as raw bytes (see uploadVideo). */
830
983
  const VIDEO_UPLOAD_URL = "/dsh-any-background/video/upload";
984
+ /** Same-origin serve URL of the persisted custom font (enough for @font-face). */
985
+ const FONT_SERVE_URL = "/dsh-any-background/font";
986
+ /** HTTP route custom fonts are POSTed to as raw bytes (see uploadFont). */
987
+ const FONT_UPLOAD_URL = "/dsh-any-background/font/upload";
831
988
  const RPC_NS = "dshAnyBackground";
832
989
  const rpcEndpoint = (method) => `${RPC_NS}/${method}`;
833
990
  let rpcCallFn = null;
991
+ /** Serve URL of the persisted custom font, filled by loadPersisted. */
992
+ let fontServeUrl = null;
834
993
  function initRpc(call) {
835
994
  rpcCallFn = call;
836
995
  }
@@ -868,7 +1027,10 @@ window.__ModuleLoader__.load({
868
1027
  /** Load the persisted theme (config + wallpaper URL + video URL) from the node
869
1028
  * half. Image and video both travel as serve URLs — never bytes — so this RPC
870
1029
  * stays tiny. Resolves true when the server advanced a due wallpaper rotation
871
- * during the read — the restored wallpaper is then already the new pick. */
1030
+ * during the read — the restored wallpaper is then already the new pick.
1031
+ * `firstRun` means the server had no theme-config.json and just materialized
1032
+ * its defaults: the caller should persist the browser side's own defaults and
1033
+ * re-read once so every slider starts from a value that is really on disk. */
872
1034
  async function loadPersisted() {
873
1035
  const data = await rpcCall("read", {});
874
1036
  if (data && typeof data === "object") {
@@ -878,10 +1040,18 @@ window.__ModuleLoader__.load({
878
1040
  else if (d.wallpaperUrl === null) setWpImageUrl(null);
879
1041
  if (typeof d.videoUrl === "string") setWpVideoUrl(d.videoUrl, cfg.videoMime);
880
1042
  else if (d.videoUrl === null) setWpVideoUrl(null, null);
1043
+ if (typeof d.fontUrl === "string") fontServeUrl = d.fontUrl;
1044
+ else if (d.fontUrl === null) fontServeUrl = null;
881
1045
  if (cfg.backgroundType === "image") setWpUrl(rWpImage());
882
- return d.rotated === true;
1046
+ return {
1047
+ rotated: d.rotated === true,
1048
+ firstRun: d.firstRun === true
1049
+ };
883
1050
  }
884
- return false;
1051
+ return {
1052
+ rotated: false,
1053
+ firstRun: false
1054
+ };
885
1055
  }
886
1056
  /** Persist a wallpaper (null removes it); one-shot, no debounce. */
887
1057
  function persistWallpaper(dataUrl) {
@@ -959,34 +1129,96 @@ window.__ModuleLoader__.load({
959
1129
  error: v?.error ?? "failed"
960
1130
  };
961
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
+ }
962
1166
  /** Upload a video's raw bytes over HTTP (MIME in Content-Type, body untouched
963
1167
  * — no base64 inflation that would blow the RPC body limit on large clips). */
964
1168
  async function uploadVideo(blob, mime) {
965
1169
  try {
966
- return (await fetch(VIDEO_UPLOAD_URL, {
1170
+ const res = await fetch(VIDEO_UPLOAD_URL, {
967
1171
  method: "POST",
968
1172
  headers: { "Content-Type": mime || "application/octet-stream" },
969
1173
  body: blob
970
- })).ok;
1174
+ });
1175
+ return res.ok ? { ok: true } : await refusedUpload(res);
971
1176
  } catch (e) {
972
1177
  console.warn("dsh-any-background: video upload failed", e);
973
- return false;
1178
+ return { ok: false };
974
1179
  }
975
1180
  }
976
1181
  /** Upload a wallpaper's raw bytes over HTTP — same streaming model as videos,
977
1182
  * original pixels preserved, zero base64 round-trips. */
978
1183
  async function uploadWallpaper(blob) {
979
1184
  try {
980
- return (await fetch(WALLPAPER_UPLOAD_URL, {
1185
+ const res = await fetch(WALLPAPER_UPLOAD_URL, {
981
1186
  method: "POST",
982
1187
  headers: { "Content-Type": blob.type || "image/jpeg" },
983
1188
  body: blob
984
- })).ok;
1189
+ });
1190
+ return res.ok ? { ok: true } : await refusedUpload(res);
985
1191
  } catch (e) {
986
1192
  console.warn("dsh-any-background: wallpaper upload failed", e);
987
- return false;
1193
+ return { ok: false };
988
1194
  }
989
1195
  }
1196
+ /** Upload a custom font's raw bytes over HTTP; resolves the sniffed MIME on
1197
+ * success, or a refusal the caller can report (an oversized font used to be
1198
+ * indistinguishable from a corrupt one). */
1199
+ async function uploadFont(blob) {
1200
+ try {
1201
+ const res = await fetch(FONT_UPLOAD_URL, {
1202
+ method: "POST",
1203
+ headers: { "Content-Type": blob.type || "application/octet-stream" },
1204
+ body: blob
1205
+ });
1206
+ if (!res.ok) return await refusedUpload(res);
1207
+ const data = await res.json().catch(() => null);
1208
+ return {
1209
+ ok: true,
1210
+ mime: typeof data?.mime === "string" ? data.mime : "font/ttf"
1211
+ };
1212
+ } catch (e) {
1213
+ console.warn("dsh-any-background: font upload failed", e);
1214
+ return { ok: false };
1215
+ }
1216
+ }
1217
+ /** Remove the stored custom font (server deletes every variant + clears the
1218
+ * recorded MIME); resolves true once the slot is empty. */
1219
+ async function removeFont() {
1220
+ return await rpcCall("removeFont", {}) === true;
1221
+ }
990
1222
  /** Add an image (data URL + small thumbnail) to the server-side rotation pool. */
991
1223
  async function rotationAdd(dataUrl, thumb) {
992
1224
  if (!rpcCallFn) return {
@@ -1160,6 +1392,12 @@ window.__ModuleLoader__.load({
1160
1392
  v
1161
1393
  ];
1162
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
+ }
1163
1401
  let tokensCacheKey = "";
1164
1402
  let tokensCache = null;
1165
1403
  /**
@@ -2303,9 +2541,15 @@ window.__ModuleLoader__.load({
2303
2541
  wpController = null;
2304
2542
  }
2305
2543
  /** Register a callback fired once a generated snapshot is ready (so the caller
2306
- * 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). */
2307
2548
  function onGeneratedSnapshot(cb) {
2308
2549
  snapshotListener = cb;
2550
+ return () => {
2551
+ if (snapshotListener === cb) snapshotListener = null;
2552
+ };
2309
2553
  }
2310
2554
  function ensureTokenStyle() {
2311
2555
  if (tokenStyleEl?.isConnected) return tokenStyleEl;
@@ -2400,14 +2644,34 @@ window.__ModuleLoader__.load({
2400
2644
  if (rSchemeOverride() !== "auto") return genTokens(220, .04, scheme === "dark" ? .14 : .92, scheme).tokens;
2401
2645
  return null;
2402
2646
  }
2647
+ /** Surface colors the opacity sliders fade when the plugin has NO palette of
2648
+ * its own (no picked color, no forced scheme), read live from the host's
2649
+ * resolved tokens on `:root` — the same trick applyPanelOverrides uses for the
2650
+ * workbench panel. Without this source the per-part alpha is never emitted at
2651
+ * all and every opacity slider looks inert until the user drags it once.
2652
+ * The sliders only ever supply the alpha: the host still decides the colors,
2653
+ * so a custom host skin survives. Returns null when the host exposes none. */
2654
+ function readHostOpacityTokens() {
2655
+ if (typeof getComputedStyle === "undefined") return null;
2656
+ const cs = getComputedStyle(document.documentElement);
2657
+ const out = {};
2658
+ let found = false;
2659
+ for (const g of OPACITY_TOKEN_GROUPS) for (const name of g.names) {
2660
+ const v = cs.getPropertyValue(name).trim();
2661
+ if (v === "") continue;
2662
+ out[name] = v;
2663
+ found = true;
2664
+ }
2665
+ return found ? out : null;
2666
+ }
2403
2667
  function applyCustomTokensNow(ops) {
2404
2668
  const hasColor = rHasColor();
2405
2669
  const override = rSchemeOverride();
2406
2670
  const [h, s, l] = rColor();
2407
2671
  const scheme = rScheme();
2408
2672
  const verdict = rBgDark();
2409
- const palette = paletteTokens();
2410
- const tokens = { ...palette };
2673
+ const surfaces = paletteTokens() ?? readHostOpacityTokens();
2674
+ const tokens = { ...surfaces };
2411
2675
  if (override === "auto") {
2412
2676
  const fontDark = hasColor ? rColorScheme() === "dark" : verdict;
2413
2677
  if (fontDark !== null && fontDark !== void 0) {
@@ -2431,10 +2695,17 @@ window.__ModuleLoader__.load({
2431
2695
  for (const name of appliedTokenNames) document.body.style.removeProperty(name);
2432
2696
  appliedTokenNames = Object.keys(tokens);
2433
2697
  }
2434
- if (palette === null) return;
2698
+ if (surfaces === null) {
2699
+ if (!hasColor && override === "auto" && verdict === null) clearThemeTokens();
2700
+ return;
2701
+ }
2435
2702
  const root = document.documentElement;
2436
- for (const g of OPACITY_TOKEN_GROUPS) for (const name of g.names) root.style.setProperty(OPACITY_VARS[name], toRgba(tokens[name] ?? "#000", ops[g.part]));
2437
- root.style.setProperty("--dsh-any-op-menu-cordis", toRgba(tokens["--dsw-specific-menu"] ?? "#000", ops.input));
2703
+ for (const g of OPACITY_TOKEN_GROUPS) for (const name of g.names) {
2704
+ if (surfaces[name] === void 0) continue;
2705
+ root.style.setProperty(OPACITY_VARS[name], toRgba(surfaces[name], ops[g.part]));
2706
+ }
2707
+ const menu = surfaces["--dsw-specific-menu"];
2708
+ if (menu !== void 0) root.style.setProperty("--dsh-any-op-menu-cordis", toRgba(menu, ops.input));
2438
2709
  const bgKey = `${baseTokenKey}|${ops.bg}`;
2439
2710
  if (bgKey !== lastBgKey) {
2440
2711
  lastBgKey = bgKey;
@@ -2461,6 +2732,149 @@ window.__ModuleLoader__.load({
2461
2732
  const PANEL_TOKEN_RULE = "[data-dsh-bottom-panel],[data-sidebar-right-panel]{--dsw-alias-bg-base:var(--dsh-any-panel-bg-base);--dsw-alias-bg-layer-1:var(--dsh-any-panel-layer-1);--dsw-alias-bg-layer-2:var(--dsh-any-panel-layer-2);--dsw-alias-bg-layer-3:var(--dsh-any-panel-layer-3);position:fixed!important;z-index:26!important}[data-sidebar-right-panel][data-sidebar-right-panel=\"fullscreen\"]{z-index:40!important}";
2462
2733
  const PANEL_BLUR_RULE = "[data-dsh-bottom-panel],[data-sidebar-right-panel]{-webkit-backdrop-filter:var(--dsh-any-blur-panel,none);backdrop-filter:var(--dsh-any-blur-panel,none)}";
2463
2734
  const PRODUCED_RULE = "[data-code-block-content] pre,[data-code-block-banner],[data-composer-chip],:not(pre)>code{-webkit-backdrop-filter:var(--dsh-any-blur-prod,none);backdrop-filter:var(--dsh-any-blur-prod,none)}@supports (background:color-mix(in srgb,red 50%,transparent)){.md-code-block{background:transparent!important}.md-code-block>div{background-color:transparent!important}.md-code-block [data-code-block-content] pre{background-color:color-mix(in srgb,var(--dsl-code-block-background,var(--dsw-alias-markdown-code-block,transparent)) var(--dsh-any-prod-pct,100%),transparent)!important}.md-code-block [data-code-block-banner]{background-color:color-mix(in srgb,var(--dsl-code-block-banner-background-color,var(--dsw-alias-markdown-code-block-banner,transparent)) var(--dsh-any-prod-pct,100%),transparent)!important}[data-code-block-content]{--shiki-background:color-mix(in srgb,var(--dsw-alias-markdown-code-block,var(--dsl-code-block-background,transparent)) var(--dsh-any-prod-pct,100%),transparent)}:not(pre)>code{background-color:color-mix(in srgb,var(--dsw-alias-markdown-inline-code,transparent) var(--dsh-any-prod-pct,100%),transparent)!important}[data-composer-chip]>*{background-color:color-mix(in srgb,var(--dsw-alias-interactive-bg-hover,transparent) var(--dsh-any-prod-pct,100%),transparent)!important}}";
2735
+ const STROKE_RULE = [
2736
+ `${SETTINGS_PANEL_SEL}{-webkit-text-stroke:var(--dsh-any-stroke-settings-w,0px) var(--dsh-any-stroke-settings-c,transparent);paint-order:stroke fill}`,
2737
+ "[role=\"menu\"]:not([data-dockkit-tab-menu]),[role=\"listbox\"],body>[role=\"tree\"],body>[role=\"dialog\"]:not([aria-modal=\"true\"]){-webkit-text-stroke:var(--dsh-any-stroke-card-w,0px) var(--dsh-any-stroke-card-c,transparent);paint-order:stroke fill}",
2738
+ "[data-composer-card],[data-cordis-panel],[data-composer-card] textarea,[data-composer-card] input,[data-composer-card] [contenteditable],[data-cordis-panel] input,[data-cordis-panel] textarea{-webkit-text-stroke:var(--dsh-any-stroke-input-w,0px) var(--dsh-any-stroke-input-c,transparent);paint-order:stroke fill}",
2739
+ `[data-chat-flow]{-webkit-text-stroke:var(--dsh-any-stroke-chat-w,0px) var(--dsh-any-stroke-chat-c,transparent);paint-order:stroke fill}`,
2740
+ `[data-conversation-composer-overlay]{-webkit-text-stroke:var(--dsh-any-stroke-trajectory-w,0px) var(--dsh-any-stroke-trajectory-c,transparent);paint-order:stroke fill}`,
2741
+ "[data-code-block-content] pre,[data-code-block-content],[data-code-block-banner],[data-composer-chip],:not(pre)>code{-webkit-text-stroke:var(--dsh-any-stroke-produced-w,0px) var(--dsh-any-stroke-produced-c,transparent);paint-order:stroke fill}",
2742
+ `[data-dsh-bottom-panel],[data-sidebar-right-panel]{-webkit-text-stroke:var(--dsh-any-stroke-panel-w,0px) var(--dsh-any-stroke-panel-c,transparent);paint-order:stroke fill}`,
2743
+ `svg{-webkit-text-stroke-width:0!important;paint-order:normal!important}`,
2744
+ `::placeholder{-webkit-text-stroke-width:0!important}`
2745
+ ].join("");
2746
+ /** Groups whose stroke lands on the structurally-discovered columns (inline),
2747
+ * versus every group served by the static STROKE_RULE selectors. */
2748
+ const STROKE_INLINE_GROUPS = ["bg", "sidebar"];
2749
+ const STROKE_VAR_GROUPS = [
2750
+ "card",
2751
+ "settings",
2752
+ "chat",
2753
+ "trajectory",
2754
+ "input",
2755
+ "panel",
2756
+ "produced"
2757
+ ];
2758
+ /** Resolve one group's stroke color key into a concrete CSS color.
2759
+ * 'auto' contrasts the FONT direction (white fonts → black stroke and vice
2760
+ * versa — mirrors the label flip in applyCustomTokensNow); 'theme' follows
2761
+ * the live palette's brand primary (picked color → generated palette, else
2762
+ * the host's own resolved token). */
2763
+ function strokeColor(s) {
2764
+ switch (s.color) {
2765
+ case "gray": return "#808080";
2766
+ case "black": return "#000";
2767
+ case "white": return "#fff";
2768
+ case "custom": return s.customColor;
2769
+ case "theme":
2770
+ if (rHasColor() || rSchemeOverride() !== "auto") {
2771
+ const [h, sa, l] = rColor();
2772
+ return genTokens(h, sa, l, rColorScheme()).tokens["--dsw-alias-brand-primary"] ?? "#808080";
2773
+ }
2774
+ if (typeof getComputedStyle !== "undefined") {
2775
+ const v = getComputedStyle(document.documentElement).getPropertyValue("--dsw-alias-brand-primary").trim();
2776
+ if (v !== "") return v;
2777
+ }
2778
+ return "#808080";
2779
+ default: {
2780
+ let fontDark;
2781
+ if (rHasColor()) fontDark = rColorScheme() === "dark";
2782
+ else if (rSchemeOverride() !== "auto") fontDark = rScheme() === "dark";
2783
+ else if (rBgDark() !== null) fontDark = rBgDark() === true;
2784
+ else fontDark = false;
2785
+ return fontDark ? "#000" : "#fff";
2786
+ }
2787
+ }
2788
+ }
2789
+ /** Write every group's stroke width/color variables + the two column strokes.
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
+ }
2803
+ function applyStrokes() {
2804
+ const strokes = rStrokes();
2805
+ const root = document.documentElement;
2806
+ for (const g of STROKE_VAR_GROUPS) {
2807
+ const s = strokes[g];
2808
+ root.style.setProperty(`--dsh-any-stroke-${g}-w`, `${s.width}px`);
2809
+ root.style.setProperty(`--dsh-any-stroke-${g}-c`, strokeColor(s));
2810
+ }
2811
+ discoverParts();
2812
+ for (const g of STROKE_INLINE_GROUPS) {
2813
+ const el = g === "bg" ? centerEl : sidebarEl;
2814
+ if (el !== null) applyInlineStroke(el, strokes[g]);
2815
+ }
2816
+ }
2817
+ /** Live per-group stroke update during slider drag (no full re-apply). */
2818
+ function setPartStroke(part, s) {
2819
+ if (STROKE_VAR_GROUPS.includes(part)) {
2820
+ document.documentElement.style.setProperty(`--dsh-any-stroke-${part}-w`, `${s.width}px`);
2821
+ document.documentElement.style.setProperty(`--dsh-any-stroke-${part}-c`, strokeColor(s));
2822
+ return;
2823
+ }
2824
+ discoverParts();
2825
+ const el = part === "bg" ? centerEl : sidebarEl;
2826
+ if (el === null) return;
2827
+ applyInlineStroke(el, s);
2828
+ }
2829
+ /** Teardown only: drop every stroke variable and column inline stroke. */
2830
+ function removeStrokes() {
2831
+ const root = document.documentElement;
2832
+ for (const g of [...STROKE_VAR_GROUPS, ...STROKE_INLINE_GROUPS]) {
2833
+ root.style.removeProperty(`--dsh-any-stroke-${g}-w`);
2834
+ root.style.removeProperty(`--dsh-any-stroke-${g}-c`);
2835
+ }
2836
+ for (const el of [centerEl, sidebarEl]) {
2837
+ if (el === null) continue;
2838
+ el.style.removeProperty("-webkit-text-stroke");
2839
+ el.style.removeProperty("paint-order");
2840
+ }
2841
+ }
2842
+ const FONT_FAMILY = "DAnyFont";
2843
+ let fontStyleEl = null;
2844
+ function fontFormatForMime(mime) {
2845
+ switch (mime) {
2846
+ case "font/woff2": return "woff2";
2847
+ case "font/woff": return "woff";
2848
+ case "font/otf": return "opentype";
2849
+ default: return "truetype";
2850
+ }
2851
+ }
2852
+ /** Apply or clear the custom interface font. `url` is the serve URL (null =
2853
+ * nothing stored); `enabled` gates the token override without deleting the
2854
+ * file. The original host stack is re-read on every apply so a host skin
2855
+ * change is picked up, and stays as the fallback after 'DAnyFont'. */
2856
+ function applyFontFace(url, enabled, mime) {
2857
+ if (url === null || !enabled) {
2858
+ fontStyleEl?.remove();
2859
+ fontStyleEl = null;
2860
+ return;
2861
+ }
2862
+ if (fontStyleEl === null || !fontStyleEl.isConnected) {
2863
+ fontStyleEl = document.createElement("style");
2864
+ fontStyleEl.dataset.plugin = "dsh-any-background-font";
2865
+ document.head.appendChild(fontStyleEl);
2866
+ }
2867
+ let stack = "";
2868
+ if (typeof getComputedStyle !== "undefined") stack = getComputedStyle(document.documentElement).getPropertyValue("--dsw-font-family").trim();
2869
+ if (stack === "") stack = "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif";
2870
+ const fmt = fontFormatForMime(mime);
2871
+ fontStyleEl.textContent = `@font-face{font-family:'${FONT_FAMILY}';src:url('${url}') format('${fmt}');font-display:swap}body{--dsw-font-family:'${FONT_FAMILY}',${stack}}input,textarea,select,button{font-family:var(--dsw-font-family)}`;
2872
+ }
2873
+ /** Teardown only: drop the @font-face + token override. */
2874
+ function removeFontFace() {
2875
+ fontStyleEl?.remove();
2876
+ fontStyleEl = null;
2877
+ }
2464
2878
  /** The host's own default colors for the panel layer tokens, used when the
2465
2879
  * plugin has no palette (no picked color, no wallpaper verdict, no forced
2466
2880
  * scheme). Without these the panel's re-scope would resolve to invalid vars
@@ -2515,27 +2929,35 @@ window.__ModuleLoader__.load({
2515
2929
  if (px > 0) document.documentElement.style.setProperty("--dsh-any-blur-panel", `blur(${px}px)`);
2516
2930
  else document.documentElement.style.removeProperty("--dsh-any-blur-panel");
2517
2931
  }
2518
- 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) {
2519
2940
  const [h, s, l] = rColor();
2520
2941
  const tokens = genTokens(h, s, l, rColorScheme()).tokens;
2521
- const layer1 = tokens["--dsw-alias-bg-layer-1"];
2522
- const layer2 = tokens["--dsw-alias-bg-layer-2"];
2523
- const layer3 = tokens["--dsw-alias-bg-layer-3"];
2524
- if (layer2 !== void 0) document.documentElement.style.setProperty("--dsh-any-bg-settings-surface", toRgba(layer2, op));
2525
- if (layer1 !== void 0) document.documentElement.style.setProperty("--dsh-any-bg-settings-layer-1", toRgba(layer1, op));
2526
- if (layer2 !== void 0) document.documentElement.style.setProperty("--dsh-any-bg-settings-layer-2", toRgba(layer2, op));
2527
- 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);
2528
2957
  }
2529
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)}";
2530
2959
  function applyTrajectoryOverrides(op) {
2531
- const [h, s, l] = rColor();
2532
- const tokens = genTokens(h, s, l, rColorScheme()).tokens;
2533
- const layer1 = tokens["--dsw-alias-bg-layer-1"];
2534
- const layer2 = tokens["--dsw-alias-bg-layer-2"];
2535
- const layer3 = tokens["--dsw-alias-bg-layer-3"];
2536
- if (layer1 !== void 0) document.documentElement.style.setProperty("--dsh-any-traj-layer-1", toRgba(layer1, op));
2537
- if (layer2 !== void 0) document.documentElement.style.setProperty("--dsh-any-traj-layer-2", toRgba(layer2, op));
2538
- if (layer3 !== void 0) document.documentElement.style.setProperty("--dsh-any-traj-layer-3", toRgba(layer3, op));
2960
+ applyLayerOverrides("--dsh-any-traj", op, false);
2539
2961
  }
2540
2962
  /** Register a callback fired when a wallpaper-extracted theme color is
2541
2963
  * adopted by the auto-adaptation path, so the section can re-register the
@@ -2544,6 +2966,9 @@ window.__ModuleLoader__.load({
2544
2966
  let colorAdoptedListener = null;
2545
2967
  function onColorAdopted(cb) {
2546
2968
  colorAdoptedListener = cb;
2969
+ return () => {
2970
+ if (colorAdoptedListener === cb) colorAdoptedListener = null;
2971
+ };
2547
2972
  }
2548
2973
  /** Apply the theme color: use the saved pick directly, or fall back to
2549
2974
  * extracting a dominant color from the current wallpaper. */
@@ -2559,8 +2984,8 @@ window.__ModuleLoader__.load({
2559
2984
  if (hsl && rWp() === url) {
2560
2985
  cfg.color = hsl;
2561
2986
  colorAdoptedListener?.(hsl);
2987
+ applyWp();
2562
2988
  }
2563
- applyWp();
2564
2989
  });
2565
2990
  } else applyWp();
2566
2991
  }
@@ -2592,7 +3017,7 @@ window.__ModuleLoader__.load({
2592
3017
  * preserving the user's scale/intensity/speed/density/preset choices. */
2593
3018
  function regenerateGeneratedBg() {
2594
3019
  const params = cfg.generatedBg;
2595
- if (!params || cfg.backgroundType === "image") return;
3020
+ if (!params || cfg.backgroundType === "image" || cfg.backgroundType === "video") return;
2596
3021
  cfg.generatedBg = {
2597
3022
  ...params,
2598
3023
  seed: randomSeed()
@@ -2615,9 +3040,10 @@ window.__ModuleLoader__.load({
2615
3040
  wpEl.appendChild(wpController.canvas);
2616
3041
  }
2617
3042
  requestAnimationFrame(() => {
2618
- if (!wpController) return;
2619
3043
  const controller = wpController;
3044
+ if (controller === null) return;
2620
3045
  const frame = controller.snapshot();
3046
+ if (wpController !== controller) return;
2621
3047
  setWpUrl(frame);
2622
3048
  applyWp();
2623
3049
  snapshotListener?.();
@@ -2644,35 +3070,36 @@ window.__ModuleLoader__.load({
2644
3070
  partBlurStyleEl.textContent = PART_BLUR_RULE;
2645
3071
  document.head.appendChild(partBlurStyleEl);
2646
3072
  }
2647
- /** Find one direct child of `frame` whose `[data-rightbar-col]` marker matches.
2648
- * The rightbar is its own grid track, NOT a "details" twin of the center
2649
- * column, so it must never receive the main-bg tint or blur underlay. */
2650
- function findRightbarInFrame(frame) {
2651
- for (let i = 0; i < frame.children.length; i++) {
2652
- const child = frame.children[i];
2653
- if (child instanceof HTMLElement && child.dataset.rightbarCol !== void 0) return child;
2654
- }
2655
- return null;
2656
- }
2657
3073
  function discoverParts() {
2658
3074
  const overlay = document.querySelector("[data-shell-overlay]");
2659
3075
  if (overlay === null) return;
2660
3076
  const frame = overlay.parentElement;
2661
3077
  if (frame === null) return;
2662
3078
  frameEl = frame;
2663
- rightEl = findRightbarInFrame(frame);
2664
- const idx = Array.from(frame.children).indexOf(overlay);
2665
- if (rightEl !== null) {
2666
- const rightIdx = Array.from(frame.children).indexOf(rightEl);
2667
- sidebarEl = frame.children[rightIdx - 2] ?? null;
2668
- 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;
2669
3095
  } else {
2670
- sidebarEl = frame.children[idx - 3] ?? null;
2671
- centerEl = frame.children[idx - 2] ?? null;
3096
+ sidebarEl = children[overlayIdx - 3] ?? null;
3097
+ centerEl = children[overlayIdx - 2] ?? null;
2672
3098
  }
2673
3099
  }
2674
3100
  function setBlur(el, px) {
2675
3101
  if (el === null) return;
3102
+ if (px === 0 && !el.classList.contains(PART_BLUR_CLASS) && el.getAttribute("data-dab-pos-patched") !== "1") return;
2676
3103
  const underlay = el.querySelector(`:scope > .${PART_UNDERLAY_CLASS}`);
2677
3104
  if (px > 0) {
2678
3105
  ensurePartBlurStyle();
@@ -2806,6 +3233,34 @@ window.__ModuleLoader__.load({
2806
3233
  plain: true
2807
3234
  }];
2808
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
+ }
2809
3264
  function isScrollableY(el) {
2810
3265
  const oy = getComputedStyle(el).overflowY;
2811
3266
  return oy === "auto" || oy === "scroll" || oy === "overlay";
@@ -2850,6 +3305,7 @@ window.__ModuleLoader__.load({
2850
3305
  const marked = centerEl.querySelector(spec.sel);
2851
3306
  const cached = viewTargets[idx];
2852
3307
  if (marked !== null) {
3308
+ markerSeen.add(idx);
2853
3309
  if (cached !== null && cached !== marked) {
2854
3310
  setBlur(cached, 0);
2855
3311
  restoreCardHost(cached, spec.mark, spec.prev, spec.plain === true);
@@ -2888,6 +3344,7 @@ window.__ModuleLoader__.load({
2888
3344
  const ds = el.dataset;
2889
3345
  ds[prev + "Bg"] = el.style.getPropertyValue("background");
2890
3346
  if (plain) return;
3347
+ ds[prev + "BoxSizing"] = el.style.getPropertyValue("box-sizing");
2891
3348
  ds[prev + "Border"] = el.style.getPropertyValue("border");
2892
3349
  ds[prev + "Radius"] = el.style.getPropertyValue("border-radius");
2893
3350
  ds[prev + "Padding"] = el.style.getPropertyValue("padding");
@@ -2902,9 +3359,11 @@ window.__ModuleLoader__.load({
2902
3359
  };
2903
3360
  restore("background", ds[prev + "Bg"]);
2904
3361
  if (!plain) {
3362
+ restore("box-sizing", ds[prev + "BoxSizing"]);
2905
3363
  restore("border", ds[prev + "Border"]);
2906
3364
  restore("border-radius", ds[prev + "Radius"]);
2907
3365
  restore("padding", ds[prev + "Padding"]);
3366
+ delete ds[prev + "BoxSizing"];
2908
3367
  delete ds[prev + "Border"];
2909
3368
  delete ds[prev + "Radius"];
2910
3369
  delete ds[prev + "Padding"];
@@ -2974,6 +3433,7 @@ window.__ModuleLoader__.load({
2974
3433
  target.style.border = surface !== void 0 ? `1px solid ${toRgba(surface, borderAlpha)}` : "1px solid transparent";
2975
3434
  target.style.borderRadius = "16px";
2976
3435
  target.style.padding = "18px";
3436
+ target.style.boxSizing = "border-box";
2977
3437
  }
2978
3438
  target.setAttribute(spec.mark, "1");
2979
3439
  setBlur(target, blurPx);
@@ -2981,15 +3441,48 @@ window.__ModuleLoader__.load({
2981
3441
  syncTableFix();
2982
3442
  }
2983
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
+ }
2984
3469
  /** Watch for the AppFrame mounting so persisted blurs land even when the shell
2985
- * renders after this plugin's apply. Cheap: once all parts are found, the
2986
- * callback returns. */
3470
+ * renders after this plugin's apply. */
2987
3471
  function watchParts() {
2988
3472
  if (partsObserver !== null || typeof MutationObserver === "undefined") return;
2989
3473
  partsObserver = new MutationObserver(() => {
2990
- if (frameEl !== null && sidebarEl !== null && centerEl !== null && rightEl !== null && document.body.contains(frameEl) && viewTargets.every((el) => el !== null && document.body.contains(el))) return;
2991
- applyPartBlurs(rBlurs());
2992
- 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
+ });
2993
3486
  });
2994
3487
  partsObserver.observe(document.body, {
2995
3488
  childList: true,
@@ -2999,6 +3492,13 @@ window.__ModuleLoader__.load({
2999
3492
  function stopWatchingParts() {
3000
3493
  partsObserver?.disconnect();
3001
3494
  partsObserver = null;
3495
+ if (partsApplyRaf !== 0) {
3496
+ cancelAnimationFrame(partsApplyRaf);
3497
+ partsApplyRaf = 0;
3498
+ }
3499
+ appliedPartsKey = "";
3500
+ appliedTargets = [];
3501
+ markerSeen.clear();
3002
3502
  }
3003
3503
  let themeObserver = null;
3004
3504
  let themeRaf = 0;
@@ -3151,6 +3651,9 @@ window.__ModuleLoader__.load({
3151
3651
  * be re-registered through the host theme service. */
3152
3652
  function onVerdictApplied(cb) {
3153
3653
  verdictListener = cb;
3654
+ return () => {
3655
+ if (verdictListener === cb) verdictListener = null;
3656
+ };
3154
3657
  }
3155
3658
  function applyVerdict(dark) {
3156
3659
  if (rBgDark() === dark) return;
@@ -3278,6 +3781,8 @@ window.__ModuleLoader__.load({
3278
3781
  clearVideoEl();
3279
3782
  wpEl?.remove();
3280
3783
  wpEl = null;
3784
+ wpVerdict = null;
3785
+ setBgDark(null);
3281
3786
  }
3282
3787
  } else if (cfg.backgroundType !== "image" && cfg.generatedBg) {
3283
3788
  clearVideoEl();
@@ -3297,14 +3802,14 @@ window.__ModuleLoader__.load({
3297
3802
  wpVerdict = null;
3298
3803
  setBgDark(null);
3299
3804
  }
3300
- if (rHasColor() || rBgDark() !== null || rSchemeOverride() !== "auto") applyCustomTokens(rOps());
3301
- else if (!url) clearThemeTokens();
3805
+ applyCustomTokens(rOps());
3302
3806
  if (rHasColor()) {
3303
3807
  applySettingsOverrides(rSop());
3304
3808
  applyTrajectoryOverrides(rTrajectoryOpacity());
3305
3809
  }
3306
3810
  applyPanelOverrides(rPanelOpacity());
3307
3811
  applyPartBlurs(rBlurs());
3812
+ applyStrokes();
3308
3813
  }
3309
3814
  function teardownWp() {
3310
3815
  clearDynamicBg();
@@ -3349,6 +3854,14 @@ window.__ModuleLoader__.load({
3349
3854
  pendingOps = null;
3350
3855
  tableFixStyleEl?.remove();
3351
3856
  tableFixStyleEl = null;
3857
+ partBlurStyleEl?.remove();
3858
+ partBlurStyleEl = null;
3859
+ removeStrokes();
3860
+ removeFontFace();
3861
+ imgNat = null;
3862
+ lowResUrl = null;
3863
+ lowResFor = "";
3864
+ dragLow = false;
3352
3865
  setBlur(frameEl, 0);
3353
3866
  setBlur(sidebarEl, 0);
3354
3867
  setBlur(centerEl, 0);
@@ -3484,7 +3997,10 @@ window.__ModuleLoader__.load({
3484
3997
  /* ── wheel card ──────────────────────────────────────────────────────────── */
3485
3998
  .dab-wheel-card{position:relative;display:flex;align-items:center;justify-content:center;gap:28px;flex-wrap:wrap;padding:24px 18px}
3486
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}
3487
- .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)}
3488
4004
  .dab-hint{font-size:11.5px;line-height:1.55;color:var(--dsw-alias-label-tertiary);padding:0 4px}
3489
4005
 
3490
4006
  /* ── precise color inputs ────────────────────────────────────────────────── */
@@ -3538,7 +4054,7 @@ window.__ModuleLoader__.load({
3538
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)}
3539
4055
 
3540
4056
  /* ── interface part cards ────────────────────────────────────────────────── */
3541
- .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}
3542
4058
  .dab-part-head{display:flex;align-items:center;gap:11px;margin-bottom:14px}
3543
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)}
3544
4060
  .dab-part-name{font-size:13.5px;font-weight:600}
@@ -3556,6 +4072,29 @@ window.__ModuleLoader__.load({
3556
4072
  .dab-toggle.is-on{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary)}
3557
4073
  .dab-toggle.is-on .dab-toggle-knob{transform:translateX(18px);background:#fff}
3558
4074
 
4075
+ /* ── custom font card ────────────────────────────────────────────────────── */
4076
+ .dab-font-title{display:flex;align-items:center;gap:11px}
4077
+ .dab-font-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:14px}
4078
+ .dab-font-name{font-family:var(--dab-mono);font-size:13.5px;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary));background:var(--dsw-alias-bg-layer-2);border-radius:99px;padding:5px 11px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
4079
+ .dab-font-name.is-empty{color:var(--dsw-alias-label-tertiary)}
4080
+ .dab-font-actions{display:flex;align-items:center;gap:8px;margin-left:auto}
4081
+ /* The font card's labels read one step larger than the rest of the panel —
4082
+ they carry most of the explanation on this page. Scoped to the card so the
4083
+ same .dab-hint / .dab-btn elsewhere keep their size. */
4084
+ .dab-font-actions .dab-btn{font-size:14.5px}
4085
+ .dab-font-card .dab-hint{font-size:13.5px}
4086
+
4087
+ /* ── text-stroke color dots ──────────────────────────────────────────────── */
4088
+ .dab-stroke-dots{display:flex;align-items:center;gap:9px;margin-top:14px}
4089
+ .dab-stroke-dot-wrap{position:relative;width:18px;height:18px;flex:none}
4090
+ .dab-stroke-dot{width:18px;height:18px;padding:0;border-radius:50%;border:1.5px solid var(--dsw-alias-border-l2);background-clip:padding-box;cursor:pointer;transition:transform .18s cubic-bezier(.34,1.56,.64,1),box-shadow .18s ease}
4091
+ .dab-stroke-dot:hover{transform:scale(1.16)}
4092
+ /* 'auto' has no fixed value — half light / half dark reads as "derived". */
4093
+ .dab-stroke-dot-auto{background:conic-gradient(from -90deg,#fff 0 50%,#000 50% 100%)}
4094
+ .dab-stroke-dot.is-active{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-1),0 0 0 3.5px var(--dsw-alias-brand-primary);border-color:transparent}
4095
+ /* The native color picker sits invisible on top of the custom swatch. */
4096
+ .dab-stroke-dot-input{position:absolute;inset:0;width:100%;height:100%;margin:0;padding:0;border:0;border-radius:50%;background:none;opacity:0;cursor:pointer}
4097
+
3559
4098
  /* ── background preview hero ─────────────────────────────────────────────── */
3560
4099
  .dab-hero{position:relative;border-radius:16px;overflow:hidden;aspect-ratio:16/9;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2)}
3561
4100
  .dab-hero-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transition:transform .5s cubic-bezier(.22,1,.36,1)}
@@ -3592,7 +4131,7 @@ window.__ModuleLoader__.load({
3592
4131
  .dab-spin{animation:dab-rotate .7s cubic-bezier(.3,.7,.3,1) 1}
3593
4132
 
3594
4133
  /* ── profile page ────────────────────────────────────────────────────────── */
3595
- .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}
3596
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)}
3597
4136
  .dab-profile-title{font-size:14px;font-weight:650}
3598
4137
  .dab-profile-desc{font-size:12px;color:var(--dsw-alias-label-tertiary);line-height:1.55;margin:5px 0 15px}
@@ -3601,7 +4140,7 @@ window.__ModuleLoader__.load({
3601
4140
 
3602
4141
  /* ── presets / profiles / schedule / rotation ────────────────────────────── */
3603
4142
  .dab-row-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
3604
- .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}
3605
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)}
3606
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)}
3607
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)}
@@ -3628,7 +4167,7 @@ window.__ModuleLoader__.load({
3628
4167
  .dab-schedule-wrap.is-open{grid-template-rows:1fr;visibility:visible;transition:grid-template-rows .42s cubic-bezier(.22,1,.36,1)}
3629
4168
  .dab-schedule-clip{overflow:hidden;min-height:0;opacity:0;transition:opacity .28s ease}
3630
4169
  .dab-schedule-wrap.is-open .dab-schedule-clip{opacity:1;transition:opacity .32s ease .1s}
3631
- .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}
3632
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)}
3633
4172
  .dab-time-row{display:flex;flex-direction:column;gap:9px}
3634
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))}
@@ -3664,6 +4203,9 @@ window.__ModuleLoader__.load({
3664
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}
3665
4204
  .dab-crash-title{font-size:15px;font-weight:650}
3666
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}
3667
4209
 
3668
4210
  /* ── keyframes ───────────────────────────────────────────────────────────── */
3669
4211
  @keyframes dab-fade-in{from{opacity:0}to{opacity:1}}
@@ -3679,6 +4221,72 @@ window.__ModuleLoader__.load({
3679
4221
  @keyframes dab-zoom-in{from{opacity:0;transform:scale(.94) translateY(8px)}to{opacity:1;transform:none}}
3680
4222
  @keyframes dab-flow{to{background-position:300% 50%}}
3681
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
+
3682
4290
  /* ── responsive & motion preferences ─────────────────────────────────────── */
3683
4291
  @container (max-width:620px){
3684
4292
  .dab-shell{grid-template-columns:1fr;gap:14px}
@@ -3693,6 +4301,30 @@ window.__ModuleLoader__.load({
3693
4301
  @container (min-width:621px){
3694
4302
  .dab-shell{grid-template-columns:158px minmax(0,1fr);gap:26px}
3695
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
+ }
3696
4328
  @media (prefers-reduced-motion:reduce){
3697
4329
  .dab-root *,.dab-root *::before,.dab-root *::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}
3698
4330
  }
@@ -3985,6 +4617,12 @@ window.__ModuleLoader__.load({
3985
4617
  className,
3986
4618
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4 4l8 8M12 4l-8 8" })
3987
4619
  });
4620
+ /** Letterform glyph: a stroked capital A (the font/type page + nav entry). */
4621
+ const FontIcon = ({ size, className }) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Glyph, {
4622
+ size,
4623
+ className,
4624
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4 12.2 8 3.8l4 8.4" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5.6 9.6h4.8" })]
4625
+ });
3988
4626
  const PlayIcon = ({ size, className }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Glyph, {
3989
4627
  size,
3990
4628
  className,
@@ -4017,25 +4655,32 @@ window.__ModuleLoader__.load({
4017
4655
  this.props.onReset?.();
4018
4656
  };
4019
4657
  render() {
4020
- if (this.state.error) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4021
- className: "dab-crash",
4022
- children: [
4023
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4024
- className: "dab-crash-title",
4025
- children: this.props.t("crashTitle")
4026
- }),
4027
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4028
- className: "dab-crash-desc",
4029
- children: this.props.t("crashDesc")
4030
- }),
4031
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4032
- type: "button",
4033
- className: "dab-btn",
4034
- onClick: this.reset,
4035
- children: this.props.t("crashReset")
4036
- })
4037
- ]
4038
- });
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
+ }
4039
4684
  return this.props.children;
4040
4685
  }
4041
4686
  };
@@ -4184,6 +4829,10 @@ window.__ModuleLoader__.load({
4184
4829
  }, [col]);
4185
4830
  const pendingRef = (0, react.useRef)(null);
4186
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);
4187
4836
  const flushPending = (0, react.useCallback)(() => {
4188
4837
  rafRef.current = null;
4189
4838
  const p = pendingRef.current;
@@ -4225,13 +4874,25 @@ window.__ModuleLoader__.load({
4225
4874
  schedule(colRef.current.hue, s, l);
4226
4875
  }
4227
4876
  };
4228
- const onUp = () => {
4877
+ const stopDrag = () => {
4229
4878
  document.removeEventListener("mousemove", onMove);
4230
- document.removeEventListener("mouseup", onUp);
4879
+ document.removeEventListener("mouseup", stopDrag);
4880
+ dragCleanupRef.current = null;
4231
4881
  };
4882
+ dragCleanupRef.current = stopDrag;
4232
4883
  document.addEventListener("mousemove", onMove);
4233
- document.addEventListener("mouseup", onUp);
4884
+ document.addEventListener("mouseup", stopDrag);
4234
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
+ }, []);
4235
4896
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("canvas", {
4236
4897
  ref: cvsRef,
4237
4898
  width: WHEEL_SIZE,
@@ -4280,6 +4941,7 @@ window.__ModuleLoader__.load({
4280
4941
  },
4281
4942
  onChange: (e) => {
4282
4943
  setText(e.target.value);
4944
+ if (e.target.value === "") return;
4283
4945
  const v = Number(e.target.value);
4284
4946
  if (Number.isFinite(v)) onChange(clamp(v, min, max));
4285
4947
  }
@@ -4396,9 +5058,6 @@ window.__ModuleLoader__.load({
4396
5058
  //#region src/client/components/ColorPicker.tsx
4397
5059
  const MAG_SIZE = 96;
4398
5060
  const MAG_ZOOM = 8;
4399
- function toHex$1(rgb) {
4400
- return "#" + rgb.map((v) => v.toString(16).padStart(2, "0")).join("");
4401
- }
4402
5061
  /**
4403
5062
  * Eyedropper modal: shows the wallpaper full-bleed (no drag/zoom) and lets the
4404
5063
  * user click any pixel to adopt it as the theme color. A magnifier circle
@@ -4428,7 +5087,7 @@ window.__ModuleLoader__.load({
4428
5087
  canvas.height = imgRef.current.naturalHeight;
4429
5088
  const ctx = canvas.getContext("2d");
4430
5089
  if (ctx) ctx.drawImage(imgRef.current, 0, 0);
4431
- }, [ready]);
5090
+ }, [ready, url]);
4432
5091
  const sampleAt = (0, react.useCallback)((clientX, clientY) => {
4433
5092
  const canvas = canvasRef.current;
4434
5093
  if (!canvas) return null;
@@ -4543,14 +5202,14 @@ window.__ModuleLoader__.load({
4543
5202
  height: 28,
4544
5203
  borderRadius: 8,
4545
5204
  border: "1px solid rgba(255,255,255,0.4)",
4546
- background: hover ? toHex$1(hover.rgb) : "transparent"
5205
+ background: hover ? rgbToHex(hover.rgb) : "transparent"
4547
5206
  } }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4548
5207
  style: {
4549
5208
  color: "#fff",
4550
5209
  fontSize: 13,
4551
5210
  fontFamily: "var(--dab-mono, monospace)"
4552
5211
  },
4553
- children: hover ? `${toHex$1(hover.rgb)} · rgb(${hover.rgb.join(", ")})` : "—"
5212
+ children: hover ? `${rgbToHex(hover.rgb)} · rgb(${hover.rgb.join(", ")})` : "—"
4554
5213
  })]
4555
5214
  }),
4556
5215
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -4633,9 +5292,6 @@ window.__ModuleLoader__.load({
4633
5292
  .58
4634
5293
  ]
4635
5294
  ];
4636
- function toHex(rgb) {
4637
- return "#" + rgb.map((v) => Math.round(v).toString(16).padStart(2, "0")).join("");
4638
- }
4639
5295
  function ColorPage({ p, notify }) {
4640
5296
  const { t, hue, sat, lit, setColor, extractColor, setSchemeOverride, useStore } = p;
4641
5297
  const storeUrl = useStore((s) => s.url);
@@ -4650,7 +5306,7 @@ window.__ModuleLoader__.load({
4650
5306
  ];
4651
5307
  const [h, s, l] = hsvToHsl(wheel[0], wheel[1], wheel[2]);
4652
5308
  const [r, g, b] = hslToRgb(h, s, l);
4653
- const hex = toHex([
5309
+ const hex = rgbToHex([
4654
5310
  r,
4655
5311
  g,
4656
5312
  b
@@ -4780,7 +5436,7 @@ window.__ModuleLoader__.load({
4780
5436
  type: "button",
4781
5437
  className: `dab-swatch${on ? " is-on" : ""}`,
4782
5438
  style: { background: `hsl(${sh} ${Math.round(ss * 100)}% ${Math.round(sl * 100)}%)` },
4783
- title: toHex(hslToRgb(sh, ss, sl)).toUpperCase(),
5439
+ title: rgbToHex(hslToRgb(sh, ss, sl)).toUpperCase(),
4784
5440
  onClick: () => setColor(...hslToHsv(sh, ss, sl))
4785
5441
  }, i);
4786
5442
  })
@@ -4855,15 +5511,16 @@ window.__ModuleLoader__.load({
4855
5511
  const paint = (el, v) => {
4856
5512
  el.style.setProperty("--pct", String((v - min) / (max - min) * 100));
4857
5513
  };
5514
+ const fmtRef = (0, react.useRef)(fmt);
5515
+ fmtRef.current = fmt;
4858
5516
  (0, react.useEffect)(() => {
4859
5517
  if (inputRef.current) {
4860
5518
  inputRef.current.value = String(def);
4861
5519
  paint(inputRef.current, def);
4862
5520
  }
4863
- if (valRef.current) valRef.current.textContent = fmt(def);
5521
+ if (valRef.current) valRef.current.textContent = fmtRef.current(def);
4864
5522
  }, [
4865
5523
  def,
4866
- fmt,
4867
5524
  min,
4868
5525
  max
4869
5526
  ]);
@@ -4975,7 +5632,7 @@ window.__ModuleLoader__.load({
4975
5632
  }
4976
5633
  //#endregion
4977
5634
  //#region src/client/components/pages/InterfacePage.tsx
4978
- const PARTS = [
5635
+ const PARTS$1 = [
4979
5636
  {
4980
5637
  opKey: "bg",
4981
5638
  labelKey: "uiOpacityBg",
@@ -5023,9 +5680,10 @@ window.__ModuleLoader__.load({
5023
5680
  }
5024
5681
  ];
5025
5682
  function InterfacePage({ p }) {
5026
- const { t, setOps, setBlurs, setSop, setPanelOp } = p;
5683
+ const { t, setOps, setBlurs, setSop, setPanelOp, useStore } = p;
5684
+ useStore((s) => s.metaRev);
5027
5685
  const hasBetterSidebar = useBetterSidebar();
5028
- const parts = PARTS.filter((part) => !(part.isPanel && !hasBetterSidebar));
5686
+ const parts = PARTS$1.filter((part) => !(part.isPanel && !hasBetterSidebar));
5029
5687
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
5030
5688
  className: "dab-head dab-rise",
5031
5689
  style: { "--d": 0 },
@@ -5152,10 +5810,336 @@ window.__ModuleLoader__.load({
5152
5810
  })] });
5153
5811
  }
5154
5812
  //#endregion
5813
+ //#region src/client/components/pages/FontPage.tsx
5814
+ const PARTS = [
5815
+ {
5816
+ key: "bg",
5817
+ labelKey: "uiOpacityBg",
5818
+ Icon: CanvasIcon
5819
+ },
5820
+ {
5821
+ key: "sidebar",
5822
+ labelKey: "uiOpacitySide",
5823
+ Icon: SidebarIcon
5824
+ },
5825
+ {
5826
+ key: "card",
5827
+ labelKey: "uiOpacityCard",
5828
+ Icon: ChatIcon
5829
+ },
5830
+ {
5831
+ key: "input",
5832
+ labelKey: "uiOpacityInput",
5833
+ Icon: InputIcon
5834
+ },
5835
+ {
5836
+ key: "settings",
5837
+ labelKey: "uiSop",
5838
+ Icon: GearIcon
5839
+ },
5840
+ {
5841
+ key: "chat",
5842
+ labelKey: "uiChatRegion",
5843
+ Icon: TextIcon
5844
+ },
5845
+ {
5846
+ key: "trajectory",
5847
+ labelKey: "uiTrajectory",
5848
+ Icon: TrajectoryIcon
5849
+ },
5850
+ {
5851
+ key: "produced",
5852
+ labelKey: "uiProduced",
5853
+ Icon: TextIcon
5854
+ },
5855
+ {
5856
+ key: "panel",
5857
+ labelKey: "uiPanelRegion",
5858
+ Icon: PanelIcon,
5859
+ needsSidebar: true
5860
+ }
5861
+ ];
5862
+ const COLOR_KEYS = [
5863
+ "auto",
5864
+ "gray",
5865
+ "black",
5866
+ "white",
5867
+ "theme",
5868
+ "custom"
5869
+ ];
5870
+ const COLOR_LABEL_KEYS = {
5871
+ auto: "strokeAuto",
5872
+ gray: "strokeGray",
5873
+ black: "strokeBlack",
5874
+ white: "strokeWhite",
5875
+ theme: "strokeTheme",
5876
+ custom: "strokeCustom"
5877
+ };
5878
+ /** Swatch fill for one preset key. 'auto' gets its half-black/half-white
5879
+ * conic gradient from CSS, and 'custom' shows the stored hex. */
5880
+ function dotStyle(key, s) {
5881
+ switch (key) {
5882
+ case "gray": return { background: "#808080" };
5883
+ case "black": return { background: "#000" };
5884
+ case "white": return { background: "#fff" };
5885
+ case "theme": return { background: "var(--dsw-alias-brand-primary)" };
5886
+ case "custom": return { background: s.customColor };
5887
+ default: return {};
5888
+ }
5889
+ }
5890
+ function FontPage({ p, notify }) {
5891
+ const { t, setFont, removeFont, setFontEnabled, setStrokes, useStore } = p;
5892
+ const hasBetterSidebar = useBetterSidebar();
5893
+ const fileRef = (0, react.useRef)(null);
5894
+ const [strokes, setStrokesState] = (0, react.useState)(() => rStrokes());
5895
+ const [fontInfo, setFontInfo] = (0, react.useState)(() => ({
5896
+ mime: cfg.fontMime,
5897
+ enabled: cfg.fontEnabled,
5898
+ name: null
5899
+ }));
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]);
5910
+ const hasFont = fontInfo.mime !== null;
5911
+ const fontLabel = fontInfo.name ?? (fontInfo.mime !== null ? fontInfo.mime.replace("font/", "").toUpperCase() : t("fontNone"));
5912
+ /** Write one group's stroke: cfg + live DOM now, disk on commit. `live`
5913
+ * marks slider drags (debounced save, no full re-apply). */
5914
+ const patch = (key, next, live) => {
5915
+ const map = {
5916
+ ...strokes,
5917
+ [key]: {
5918
+ ...strokes[key],
5919
+ ...next
5920
+ }
5921
+ };
5922
+ setStrokesState(map);
5923
+ cfg.strokes = map;
5924
+ setPartStroke(key, map[key]);
5925
+ if (live) saveConfig();
5926
+ else setStrokes(map);
5927
+ };
5928
+ const pickFont = async (file) => {
5929
+ setBusy(true);
5930
+ setFontInfo({
5931
+ ...fontInfo,
5932
+ name: file.name
5933
+ });
5934
+ const outcome = await setFont(file);
5935
+ setBusy(false);
5936
+ if (outcome.ok) setFontInfo({
5937
+ mime: cfg.fontMime,
5938
+ enabled: cfg.fontEnabled,
5939
+ name: file.name
5940
+ });
5941
+ else {
5942
+ setFontInfo({
5943
+ mime: cfg.fontMime,
5944
+ enabled: cfg.fontEnabled,
5945
+ name: null
5946
+ });
5947
+ notify(uploadRefusalText(outcome, t, "fontFail"), false);
5948
+ }
5949
+ };
5950
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
5951
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
5952
+ className: "dab-head dab-rise",
5953
+ style: { "--d": 0 },
5954
+ children: [
5955
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5956
+ className: "dab-overline",
5957
+ children: "Typography"
5958
+ }),
5959
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
5960
+ className: "dab-h1",
5961
+ children: t("fontPageTitle")
5962
+ }),
5963
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5964
+ className: "dab-desc",
5965
+ children: t("descFont")
5966
+ })
5967
+ ]
5968
+ }),
5969
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
5970
+ className: "dab-card dab-rise dab-font-card",
5971
+ style: { "--d": 1 },
5972
+ children: [
5973
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5974
+ className: "dab-row-head",
5975
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5976
+ className: "dab-font-title",
5977
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5978
+ className: "dab-part-ico",
5979
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TextIcon, { size: 16 })
5980
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5981
+ className: "dab-part-name",
5982
+ children: t("fontTitle")
5983
+ })]
5984
+ }), hasFont ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5985
+ type: "button",
5986
+ className: `dab-toggle${fontInfo.enabled ? " is-on" : ""}`,
5987
+ role: "switch",
5988
+ "aria-checked": fontInfo.enabled,
5989
+ "aria-label": t("fontEnabled"),
5990
+ onClick: () => {
5991
+ const v = !fontInfo.enabled;
5992
+ setFontEnabled(v);
5993
+ setFontInfo({
5994
+ ...fontInfo,
5995
+ enabled: v
5996
+ });
5997
+ },
5998
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dab-toggle-knob" })
5999
+ }) : null]
6000
+ }),
6001
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6002
+ className: "dab-font-row",
6003
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6004
+ className: `dab-font-name${hasFont ? "" : " is-empty"}`,
6005
+ title: fontLabel,
6006
+ children: fontLabel
6007
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6008
+ className: "dab-font-actions",
6009
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
6010
+ type: "button",
6011
+ className: "dab-btn dab-btn-primary",
6012
+ disabled: busy,
6013
+ onClick: () => fileRef.current?.click(),
6014
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UploadIcon, { size: 14 }), busy ? t("fontUploading") : t("fontUpload")]
6015
+ }), hasFont ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
6016
+ type: "button",
6017
+ className: "dab-btn dab-btn-danger",
6018
+ onClick: () => {
6019
+ removeFont();
6020
+ setFontInfo({
6021
+ mime: null,
6022
+ enabled: cfg.fontEnabled,
6023
+ name: null
6024
+ });
6025
+ },
6026
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TrashIcon, { size: 14 }), t("fontRemove")]
6027
+ }) : null]
6028
+ })]
6029
+ }),
6030
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
6031
+ className: "dab-hint",
6032
+ style: { marginTop: 10 },
6033
+ children: t("fontHint")
6034
+ }),
6035
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
6036
+ ref: fileRef,
6037
+ type: "file",
6038
+ accept: ".ttf,.otf,.woff,.woff2,font/*",
6039
+ style: { display: "none" },
6040
+ onChange: (e) => {
6041
+ const f = e.target.files?.[0];
6042
+ e.target.value = "";
6043
+ if (f) pickFont(f);
6044
+ }
6045
+ })
6046
+ ]
6047
+ }),
6048
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
6049
+ className: "dab-card dab-rise",
6050
+ style: { "--d": 2 },
6051
+ children: [
6052
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6053
+ className: "dab-part-head",
6054
+ style: { marginBottom: 10 },
6055
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6056
+ className: "dab-part-name",
6057
+ children: t("strokeTitle")
6058
+ })
6059
+ }),
6060
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
6061
+ className: "dab-hint",
6062
+ style: { marginBottom: 14 },
6063
+ children: t("strokeHint")
6064
+ }),
6065
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6066
+ className: "dab-grid-parts",
6067
+ children: PARTS.filter((part) => !(part.needsSidebar && !hasBetterSidebar)).map((part, i) => {
6068
+ const s = strokes[part.key];
6069
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
6070
+ className: "dab-card dab-card-hover dab-rise",
6071
+ style: { "--d": i + 3 },
6072
+ children: [
6073
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6074
+ className: "dab-part-head",
6075
+ children: [
6076
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6077
+ className: "dab-part-ico",
6078
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(part.Icon, { size: 16 })
6079
+ }),
6080
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6081
+ className: "dab-part-name",
6082
+ children: t(part.labelKey)
6083
+ }),
6084
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
6085
+ className: "dab-part-badge",
6086
+ children: [s.width, "px"]
6087
+ })
6088
+ ]
6089
+ }),
6090
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LiveSlider, {
6091
+ label: t("strokeWidth"),
6092
+ min: 0,
6093
+ max: 4,
6094
+ step: .5,
6095
+ def: s.width,
6096
+ fmt: (v) => `${v}px`,
6097
+ onInput: (v) => patch(part.key, { width: v }, true),
6098
+ onChange: (v) => patch(part.key, { width: v }, false)
6099
+ }),
6100
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6101
+ className: "dab-stroke-dots",
6102
+ children: COLOR_KEYS.map((key) => {
6103
+ const active = s.color === key;
6104
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6105
+ className: "dab-stroke-dot-wrap",
6106
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6107
+ type: "button",
6108
+ className: `dab-stroke-dot${key === "auto" ? " dab-stroke-dot-auto" : ""}${active ? " is-active" : ""}`,
6109
+ style: dotStyle(key, s),
6110
+ title: t(COLOR_LABEL_KEYS[key]),
6111
+ "aria-label": t(COLOR_LABEL_KEYS[key]),
6112
+ "aria-pressed": active,
6113
+ onClick: () => patch(part.key, { color: key }, false)
6114
+ }), key === "custom" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
6115
+ type: "color",
6116
+ className: "dab-stroke-dot-input",
6117
+ value: s.customColor,
6118
+ "aria-label": t("strokeCustom"),
6119
+ onChange: (e) => patch(part.key, {
6120
+ color: "custom",
6121
+ customColor: e.target.value
6122
+ }, false)
6123
+ }) : null]
6124
+ }, key);
6125
+ })
6126
+ })
6127
+ ]
6128
+ }, part.key);
6129
+ })
6130
+ })
6131
+ ]
6132
+ })
6133
+ ] });
6134
+ }
6135
+ //#endregion
5155
6136
  //#region src/client/components/BgEditor.tsx
5156
6137
  /** Placement slot for the active background type: video framing lives in its
5157
6138
  * own state so image and video edits never overwrite each other. */
5158
6139
  const activeState = () => cfg.backgroundType === "video" ? cfg.videoBgState : cfg.bgState;
6140
+ /** Zoom is shared by every input path (wheel + pinch) so all pictures animate
6141
+ * between the same bounds. */
6142
+ const clampZoom = (z) => Math.max(.1, Math.min(10, z));
5159
6143
  function BgEditor({ url, t, onClose, onCommit }) {
5160
6144
  const pw = Math.min(window.innerWidth * .75, 860);
5161
6145
  const ph = Math.round(pw * window.innerHeight / window.innerWidth);
@@ -5174,6 +6158,19 @@ window.__ModuleLoader__.load({
5174
6158
  });
5175
6159
  const containerRef = (0, react.useRef)(null);
5176
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);
5177
6174
  const dragRef = (0, react.useRef)({
5178
6175
  active: false,
5179
6176
  sx: 0,
@@ -5181,6 +6178,29 @@ window.__ModuleLoader__.load({
5181
6178
  spx: 0,
5182
6179
  spy: 0
5183
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);
6191
+ const viewRef = (0, react.useRef)({
6192
+ zoom,
6193
+ pos
6194
+ });
6195
+ viewRef.current = {
6196
+ zoom,
6197
+ pos
6198
+ };
6199
+ /** Pinch gesture baseline (two fingers down): finger spread + midpoint and
6200
+ * the transform that was live at that moment. */
6201
+ const pinchRef = (0, react.useRef)(null);
6202
+ /** Single-finger pan baseline. */
6203
+ const touchDragRef = (0, react.useRef)(null);
5184
6204
  (0, react.useEffect)(() => {
5185
6205
  const img = new Image();
5186
6206
  img.onload = () => {
@@ -5213,6 +6233,7 @@ window.__ModuleLoader__.load({
5213
6233
  ]);
5214
6234
  const onDown = (0, react.useCallback)((e) => {
5215
6235
  e.preventDefault();
6236
+ movedRef.current = false;
5216
6237
  dragRef.current = {
5217
6238
  active: true,
5218
6239
  sx: e.clientX,
@@ -5222,41 +6243,151 @@ window.__ModuleLoader__.load({
5222
6243
  };
5223
6244
  const onMove = (ev) => {
5224
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;
5225
6247
  setPos({
5226
6248
  x: dragRef.current.spx + ev.clientX - dragRef.current.sx,
5227
6249
  y: dragRef.current.spy + ev.clientY - dragRef.current.sy
5228
6250
  });
5229
6251
  };
5230
- const onUp = () => {
6252
+ const stopDrag = () => {
5231
6253
  dragRef.current.active = false;
5232
6254
  document.removeEventListener("mousemove", onMove);
5233
- document.removeEventListener("mouseup", onUp);
6255
+ document.removeEventListener("mouseup", stopDrag);
6256
+ dragStopRef.current = null;
5234
6257
  };
6258
+ dragStopRef.current = stopDrag;
5235
6259
  document.addEventListener("mousemove", onMove);
5236
- document.addEventListener("mouseup", onUp);
6260
+ document.addEventListener("mouseup", stopDrag);
5237
6261
  }, [pos]);
6262
+ (0, react.useEffect)(() => () => {
6263
+ dragStopRef.current?.();
6264
+ }, []);
5238
6265
  const onWheelCb = (0, react.useCallback)((e) => {
5239
6266
  e.preventDefault();
6267
+ movedRef.current = false;
5240
6268
  const el = containerRef.current;
5241
6269
  if (!el) return;
5242
6270
  const rect = el.getBoundingClientRect();
5243
6271
  const mx = rect.width / 2, my = rect.height / 2;
5244
6272
  const factor = e.deltaY > 0 ? .97 : 1.03;
5245
- const nz = Math.max(.1, Math.min(10, zoom * factor));
5246
- const nx = mx - (mx - pos.x) * (nz / zoom);
5247
- 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);
5248
6277
  setZoom(nz);
5249
6278
  setPos({
5250
6279
  x: nx,
5251
6280
  y: ny
5252
6281
  });
5253
- }, [zoom, pos]);
6282
+ }, []);
6283
+ const onTouchStartCb = (0, react.useCallback)((e) => {
6284
+ const card = containerRef.current;
6285
+ if (!card) return;
6286
+ movedRef.current = false;
6287
+ if (e.touches.length >= 2) {
6288
+ touchDragRef.current = null;
6289
+ const rect = card.getBoundingClientRect();
6290
+ const a = e.touches[0], b = e.touches[1];
6291
+ pinchRef.current = {
6292
+ dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY),
6293
+ zoom: viewRef.current.zoom,
6294
+ cx: (a.clientX + b.clientX) / 2 - rect.left,
6295
+ cy: (a.clientY + b.clientY) / 2 - rect.top,
6296
+ px: viewRef.current.pos.x,
6297
+ py: viewRef.current.pos.y
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
+ };
6314
+ }
6315
+ }, []);
6316
+ const onTouchMoveCb = (0, react.useCallback)((e) => {
6317
+ const card = containerRef.current;
6318
+ if (!card) return;
6319
+ if (e.touches.length >= 2 && pinchRef.current !== null) {
6320
+ e.preventDefault();
6321
+ movedRef.current = true;
6322
+ const p = pinchRef.current;
6323
+ if (p.dist <= 0) return;
6324
+ const rect = card.getBoundingClientRect();
6325
+ const a = e.touches[0], b = e.touches[1];
6326
+ const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
6327
+ const cx = (a.clientX + b.clientX) / 2 - rect.left;
6328
+ const cy = (a.clientY + b.clientY) / 2 - rect.top;
6329
+ const nz = clampZoom(p.zoom * (dist / p.dist));
6330
+ setZoom(nz);
6331
+ setPos({
6332
+ x: cx - (p.cx - p.px) / p.zoom * nz,
6333
+ y: cy - (p.cy - p.py) / p.zoom * nz
6334
+ });
6335
+ return;
6336
+ }
6337
+ if (e.touches.length === 1 && touchDragRef.current !== null) {
6338
+ e.preventDefault();
6339
+ const d = touchDragRef.current;
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;
6342
+ setPos({
6343
+ x: d.spx + tp.clientX - d.sx,
6344
+ y: d.spy + tp.clientY - d.sy
6345
+ });
6346
+ }
6347
+ }, []);
6348
+ const onTouchEndCb = (0, react.useCallback)((e) => {
6349
+ if (e.touches.length === 1) {
6350
+ const tp = e.touches[0];
6351
+ touchDragRef.current = {
6352
+ sx: tp.clientX,
6353
+ sy: tp.clientY,
6354
+ spx: viewRef.current.pos.x,
6355
+ spy: viewRef.current.pos.y
6356
+ };
6357
+ pinchRef.current = null;
6358
+ } else if (e.touches.length === 0) {
6359
+ touchDragRef.current = null;
6360
+ pinchRef.current = null;
6361
+ }
6362
+ }, []);
5254
6363
  (0, react.useEffect)(() => {
5255
- const el = containerRef.current;
5256
- if (!el) return;
5257
- el.addEventListener("wheel", onWheelCb, { passive: false });
5258
- return () => el.removeEventListener("wheel", onWheelCb);
5259
- }, [onWheelCb]);
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 });
6374
+ return () => {
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);
6383
+ };
6384
+ }, [
6385
+ overlayEl,
6386
+ onWheelCb,
6387
+ onTouchStartCb,
6388
+ onTouchMoveCb,
6389
+ onTouchEndCb
6390
+ ]);
5260
6391
  const resetView = (0, react.useCallback)(() => {
5261
6392
  if (imgSize.w === 0) return;
5262
6393
  setZoom(1);
@@ -5270,10 +6401,22 @@ window.__ModuleLoader__.load({
5270
6401
  imgSize
5271
6402
  ]);
5272
6403
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Portal, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6404
+ ref: setOverlayEl,
5273
6405
  className: "dab-overlay",
6406
+ style: {
6407
+ touchAction: "none",
6408
+ overscrollBehavior: "contain"
6409
+ },
5274
6410
  onClick: (e) => {
6411
+ if (movedRef.current) {
6412
+ movedRef.current = false;
6413
+ return;
6414
+ }
5275
6415
  if (e.target === e.currentTarget) onClose();
5276
6416
  },
6417
+ onMouseDown: () => {
6418
+ movedRef.current = false;
6419
+ },
5277
6420
  children: [
5278
6421
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5279
6422
  className: "dab-overlay-title",
@@ -5290,7 +6433,10 @@ window.__ModuleLoader__.load({
5290
6433
  background: "#000",
5291
6434
  cursor: "grab",
5292
6435
  width: pw,
5293
- height: ph
6436
+ height: ph,
6437
+ touchAction: "none",
6438
+ WebkitUserSelect: "none",
6439
+ userSelect: "none"
5294
6440
  },
5295
6441
  onMouseDown: onDown,
5296
6442
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
@@ -5366,7 +6512,7 @@ window.__ModuleLoader__.load({
5366
6512
  labelKey: "bgModeCenter"
5367
6513
  }
5368
6514
  ];
5369
- function BackgroundPage({ p }) {
6515
+ function BackgroundPage({ p, notify }) {
5370
6516
  const { t, setWpFromServer, setVideo, setWop, setBl, setBgType, setGeneratedBg, regenerateBg, setRegenerateOnReload, setRotation, addRotationItems, removeRotationItem, rotateNow, setVideoFromUrl, useStore } = p;
5371
6517
  const storeUrl = useStore((s) => s.url);
5372
6518
  const backgroundType = useStore((s) => s.backgroundType);
@@ -5383,7 +6529,11 @@ window.__ModuleLoader__.load({
5383
6529
  const [urlVal, setUrlVal] = (0, react.useState)("");
5384
6530
  const [urlBusy, setUrlBusy] = (0, react.useState)(false);
5385
6531
  const [urlErr, setUrlErr] = (0, react.useState)(null);
6532
+ const bgRev = useStore((s) => s.bgRev);
5386
6533
  const [mode, setModeState] = (0, react.useState)(rBgMode());
6534
+ (0, react.useEffect)(() => {
6535
+ setModeState(rBgMode());
6536
+ }, [bgRev]);
5387
6537
  const isVideo = backgroundType === "video";
5388
6538
  const isStatic = backgroundType === "image" || isVideo;
5389
6539
  const isGenerated = !isStatic;
@@ -5397,14 +6547,15 @@ window.__ModuleLoader__.load({
5397
6547
  genPreset,
5398
6548
  generatedBg?.seed
5399
6549
  ]);
5400
- const onFileSelect = (f) => {
6550
+ const onFileSelect = async (f) => {
5401
6551
  if (f.type.startsWith("video/")) {
5402
- setVideo(f, f.type);
6552
+ const outcome = await setVideo(f, f.type);
6553
+ if (!outcome.ok) notify(uploadRefusalText(outcome, t, "videoUploadFail"), false);
5403
6554
  return;
5404
6555
  }
5405
- uploadWallpaper(f).then((ok) => {
5406
- if (ok) setWpFromServer(WALLPAPER_SERVE_URL);
5407
- });
6556
+ const outcome = await uploadWallpaper(f);
6557
+ if (outcome.ok) setWpFromServer(WALLPAPER_SERVE_URL);
6558
+ else notify(uploadRefusalText(outcome, t, "bgUploadFail"), false);
5408
6559
  };
5409
6560
  const onDrop = (e) => {
5410
6561
  e.preventDefault();
@@ -6075,6 +7226,27 @@ window.__ModuleLoader__.load({
6075
7226
  panel: 0,
6076
7227
  produced: 0
6077
7228
  };
7229
+ /** Presets ship with strokes off — text outlines are an opt-in look. Fresh
7230
+ * objects per call so a profile restore can never alias another preset's
7231
+ * stroke config (applyAppearance copies, but mutation-proofing is free). */
7232
+ const zeroStrokes = () => {
7233
+ const off = () => ({
7234
+ width: 0,
7235
+ color: "auto",
7236
+ customColor: "#808080"
7237
+ });
7238
+ return {
7239
+ bg: off(),
7240
+ sidebar: off(),
7241
+ card: off(),
7242
+ settings: off(),
7243
+ chat: off(),
7244
+ trajectory: off(),
7245
+ input: off(),
7246
+ panel: off(),
7247
+ produced: off()
7248
+ };
7249
+ };
6078
7250
  const BUILTIN_PRESETS = [
6079
7251
  {
6080
7252
  key: "default",
@@ -6087,6 +7259,7 @@ window.__ModuleLoader__.load({
6087
7259
  input: 1
6088
7260
  },
6089
7261
  blurs: { ...zeroBlurs },
7262
+ strokes: zeroStrokes(),
6090
7263
  settingsOpacity: 1,
6091
7264
  wallpaperOpacity: 1,
6092
7265
  blur: 0,
@@ -6120,6 +7293,7 @@ window.__ModuleLoader__.load({
6120
7293
  input: 14,
6121
7294
  panel: 12
6122
7295
  },
7296
+ strokes: zeroStrokes(),
6123
7297
  settingsOpacity: .88,
6124
7298
  wallpaperOpacity: 1,
6125
7299
  blur: 0,
@@ -6140,6 +7314,7 @@ window.__ModuleLoader__.load({
6140
7314
  input: 1
6141
7315
  },
6142
7316
  blurs: { ...zeroBlurs },
7317
+ strokes: zeroStrokes(),
6143
7318
  settingsOpacity: 1,
6144
7319
  wallpaperOpacity: 1,
6145
7320
  blur: 0,
@@ -6173,6 +7348,7 @@ window.__ModuleLoader__.load({
6173
7348
  input: 16,
6174
7349
  panel: 16
6175
7350
  },
7351
+ strokes: zeroStrokes(),
6176
7352
  settingsOpacity: .85,
6177
7353
  wallpaperOpacity: .92,
6178
7354
  blur: 2,
@@ -6206,6 +7382,7 @@ window.__ModuleLoader__.load({
6206
7382
  input: 20,
6207
7383
  panel: 18
6208
7384
  },
7385
+ strokes: zeroStrokes(),
6209
7386
  settingsOpacity: .8,
6210
7387
  wallpaperOpacity: 1,
6211
7388
  blur: 0,
@@ -6230,6 +7407,7 @@ window.__ModuleLoader__.load({
6230
7407
  input: 1
6231
7408
  },
6232
7409
  blurs: { ...zeroBlurs },
7410
+ strokes: zeroStrokes(),
6233
7411
  settingsOpacity: 1,
6234
7412
  wallpaperOpacity: 1,
6235
7413
  blur: 0,
@@ -6651,31 +7829,37 @@ window.__ModuleLoader__.load({
6651
7829
  //#endregion
6652
7830
  //#region src/client/components/ThemeSection.tsx
6653
7831
  /**
6654
- * dsh-any-background — settings section shell.
7832
+ * dsh-any-background — appearance shell for the five pages.
6655
7833
  *
6656
- * The host renders this section only while the settings dialog is open and the
6657
- * section is the active nav entry, so mounting IS being visible. The section is
6658
- * rendered INLINE inside the host settings dialog's content column (the host
6659
- * provides the modal chrome); the shell is a left nav rail + page body. Only
6660
- * the transient toast escapes through a Portal (the shell's container-type
6661
- * 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.
6662
7845
  */
6663
7846
  function ThemeSection(props) {
6664
7847
  ensureUiCss();
6665
7848
  const { t } = props;
7849
+ const surface = props.surface ?? "settings";
6666
7850
  const [page, setPage] = (0, react.useState)(0);
6667
7851
  const [toast, setToast] = (0, react.useState)(null);
6668
7852
  const toastTimer = (0, react.useRef)(void 0);
6669
7853
  (0, react.useEffect)(() => () => window.clearTimeout(toastTimer.current), []);
6670
- const notify = (msg, ok = true) => {
7854
+ const notify = (0, react.useCallback)((msg, ok = true) => {
6671
7855
  setToast({
6672
7856
  msg,
6673
7857
  ok
6674
7858
  });
6675
7859
  window.clearTimeout(toastTimer.current);
6676
7860
  toastTimer.current = window.setTimeout(() => setToast(null), 2600);
6677
- };
6678
- const pages = [
7861
+ }, []);
7862
+ const pages = (0, react.useMemo)(() => [
6679
7863
  {
6680
7864
  label: t("pageColor"),
6681
7865
  Icon: DropletIcon,
@@ -6689,10 +7873,21 @@ window.__ModuleLoader__.load({
6689
7873
  Icon: LayersIcon,
6690
7874
  node: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(InterfacePage, { p: props })
6691
7875
  },
7876
+ {
7877
+ label: t("pageFont"),
7878
+ Icon: FontIcon,
7879
+ node: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FontPage, {
7880
+ p: props,
7881
+ notify
7882
+ })
7883
+ },
6692
7884
  {
6693
7885
  label: t("pageBackground"),
6694
7886
  Icon: PhotoIcon,
6695
- 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
+ })
6696
7891
  },
6697
7892
  {
6698
7893
  label: t("pageProfile"),
@@ -6702,16 +7897,21 @@ window.__ModuleLoader__.load({
6702
7897
  notify
6703
7898
  })
6704
7899
  }
6705
- ];
7900
+ ], [
7901
+ t,
7902
+ props,
7903
+ notify
7904
+ ]);
6706
7905
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(ErrorBoundary, {
6707
7906
  t,
6708
7907
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6709
7908
  className: "dab-root",
7909
+ "data-dab-surface": surface,
6710
7910
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6711
7911
  className: "dab-shell",
6712
7912
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("nav", {
6713
7913
  className: "dab-nav",
6714
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7914
+ children: [surface === "sidebar" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6715
7915
  className: "dab-brand",
6716
7916
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6717
7917
  className: "dab-brand-tile",
@@ -6751,6 +7951,138 @@ window.__ModuleLoader__.load({
6751
7951
  });
6752
7952
  }
6753
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
6754
8086
  //#region src/client/index.tsx
6755
8087
  /**
6756
8088
  * dsh-any-background — browser half entry.
@@ -6771,6 +8103,7 @@ window.__ModuleLoader__.load({
6771
8103
  initRpc((endpoint, payload) => ctx.connection.rpc.call(RPC_CHANNEL, endpoint, payload).then((res) => res));
6772
8104
  const [initH, initS, initL] = rColor();
6773
8105
  let customDispose = null;
8106
+ let registerFailed = false;
6774
8107
  const registerCustom = (h, s, l) => {
6775
8108
  customDispose?.();
6776
8109
  try {
@@ -6803,30 +8136,54 @@ window.__ModuleLoader__.load({
6803
8136
  colorScheme,
6804
8137
  tokens
6805
8138
  });
6806
- } 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
+ }
6807
8154
  customDispose = null;
6808
8155
  }
6809
- 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
+ })();
6810
8163
  if (present) ctx.theme.setTheme(CUSTOM_ID);
6811
8164
  return present;
6812
8165
  };
6813
8166
  if (rHasColor()) registerCustom(initH, initS, initL);
6814
- onVerdictApplied(() => {
8167
+ const disposeVerdict = onVerdictApplied(() => {
6815
8168
  if (!rHasColor()) registerCustom();
6816
8169
  });
6817
- onColorAdopted((hsl) => {
8170
+ const disposeColorAdopted = onColorAdopted((hsl) => {
6818
8171
  registerCustom(hsl[0], hsl[1], hsl[2]);
6819
8172
  saveConfig();
6820
8173
  colorRev++;
6821
8174
  bound?.syncColor(hslToHsv(hsl[0], hsl[1], hsl[2]), colorRev);
6822
8175
  });
8176
+ ctx.effect(() => () => {
8177
+ disposeVerdict();
8178
+ disposeColorAdopted();
8179
+ }, "dsh-any-background: verdict/color listeners");
6823
8180
  ctx.effect(() => () => {
6824
8181
  customDispose?.();
6825
8182
  if (colorTimerRef.current !== null) window.clearTimeout(colorTimerRef.current);
6826
8183
  }, "dsh-any-background: skin dispose");
6827
8184
  const styleEl = document.createElement("style");
6828
8185
  styleEl.dataset.plugin = "dsh-any-background";
6829
- styleEl.textContent = `body[data-ds-dark-theme="dsh-any-background"]::before{content:'';position:fixed;inset:0;z-index:-1;pointer-events:none;background:radial-gradient(ellipse 80% 60% at 50% 0%,rgba(255,255,255,0.03) 0%,transparent 60%)}${SETTINGS_STYLE_RULE}${POPOVER_BLUR_RULE}${TRAJECTORY_STYLE_RULE}${INPUT_BLUR_RULE}${MOBILE_HEADER_RULE}${PANEL_TOKEN_RULE}${PANEL_BLUR_RULE}${PRODUCED_RULE}[data-composer-card] textarea::placeholder,[data-composer-card] input::placeholder,[data-composer-card] [contenteditable]::placeholder,[data-cordis-panel] input::placeholder,[data-cordis-panel] textarea::placeholder,.dab-input::placeholder,.dab-input textarea::placeholder,.dab-input input::placeholder{color:var(--dsh-any-placeholder,var(--dsw-alias-label-caption,#8a8f98))!important;font-style:italic;opacity:.85}`;
8186
+ styleEl.textContent = `body[data-ds-dark-theme="dsh-any-background"]::before{content:'';position:fixed;inset:0;z-index:-1;pointer-events:none;background:radial-gradient(ellipse 80% 60% at 50% 0%,rgba(255,255,255,0.03) 0%,transparent 60%)}${SETTINGS_STYLE_RULE}${POPOVER_BLUR_RULE}${TRAJECTORY_STYLE_RULE}${INPUT_BLUR_RULE}${MOBILE_HEADER_RULE}${PANEL_TOKEN_RULE}${PANEL_BLUR_RULE}${PRODUCED_RULE}${STROKE_RULE}[data-composer-card] textarea::placeholder,[data-composer-card] input::placeholder,[data-composer-card] [contenteditable]::placeholder,[data-cordis-panel] input::placeholder,[data-cordis-panel] textarea::placeholder,.dab-input::placeholder,.dab-input textarea::placeholder,.dab-input input::placeholder{color:var(--dsh-any-placeholder,var(--dsw-alias-label-caption,#8a8f98))!important;font-style:italic;opacity:.85}`;
6830
8187
  document.head.appendChild(styleEl);
6831
8188
  ctx.effect(() => () => {
6832
8189
  styleEl?.parentNode?.removeChild(styleEl);
@@ -6837,7 +8194,7 @@ window.__ModuleLoader__.load({
6837
8194
  let colorRev = 0;
6838
8195
  let bgRev = 0;
6839
8196
  const colorTimerRef = { current: null };
6840
- const store = defineStore({
8197
+ const storeSpec = defineStore === null ? null : defineStore({
6841
8198
  init: () => ({
6842
8199
  url: null,
6843
8200
  rev: -1,
@@ -6888,13 +8245,44 @@ window.__ModuleLoader__.load({
6888
8245
  }
6889
8246
  }
6890
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
+ };
6891
8263
  let bound = null;
8264
+ if (storeInstance !== null) bound = storeInstance.actions;
6892
8265
  const syncBg = () => {
6893
8266
  rev++;
6894
8267
  bgRev++;
6895
8268
  bound?.syncBg(rWp(), rev, cfg.backgroundType, cfg.generatedBg, bgRev, cfg.regenerateOnReload);
6896
8269
  };
6897
- 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
+ };
6898
8286
  let metaRev = 0;
6899
8287
  const syncMetaNow = () => {
6900
8288
  metaRev++;
@@ -7015,15 +8403,21 @@ window.__ModuleLoader__.load({
7015
8403
  const scheduleTick = () => {
7016
8404
  const sc = rSchedule();
7017
8405
  if (!sc.enabled) return;
7018
- 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;
7019
8407
  if (!want || want === cfg.activeProfile) return;
7020
8408
  applyProfileById(want);
7021
8409
  };
7022
8410
  applyWp();
7023
8411
  syncBg();
7024
8412
  watchParts();
7025
- loadPersisted().then(async (serverRotated) => {
8413
+ loadPersisted().then(async ({ rotated: serverRotated, firstRun }) => {
8414
+ if (firstRun) {
8415
+ persistConfig();
8416
+ await loadPersisted();
8417
+ applyWp();
8418
+ }
7026
8419
  syncMetaNow();
8420
+ applyFontFace(fontServeUrl, cfg.fontEnabled, cfg.fontMime);
7027
8421
  if (rHasColor()) {
7028
8422
  const [h, s, l] = rColor();
7029
8423
  registerCustom(h, s, l);
@@ -7037,12 +8431,7 @@ window.__ModuleLoader__.load({
7037
8431
  if (cfg.backgroundType === "video") {
7038
8432
  const v = rWpVideo();
7039
8433
  if (v) {
7040
- captureVideoSnapshot(v).then((snap) => {
7041
- if (rWpVideo() !== v) return;
7042
- setWpVideoSnapshot(snap);
7043
- applyThemeColor();
7044
- syncBg();
7045
- });
8434
+ captureAndApplyVideo(v);
7046
8435
  applyWp();
7047
8436
  } else {
7048
8437
  cfg.backgroundType = "image";
@@ -7092,19 +8481,25 @@ window.__ModuleLoader__.load({
7092
8481
  document.body.append(sentinel);
7093
8482
  const viewportObserver = new ResizeObserver(applySoon);
7094
8483
  viewportObserver.observe(sentinel);
7095
- const dprQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
7096
- dprQuery.addEventListener("change", applySoon);
8484
+ const dprQuery = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`);
8485
+ dprQuery?.addEventListener?.("change", applySoon);
7097
8486
  ctx.effect(() => () => {
7098
8487
  viewportObserver.disconnect();
7099
- dprQuery.removeEventListener("change", applySoon);
8488
+ dprQuery?.removeEventListener?.("change", applySoon);
7100
8489
  sentinel.remove();
7101
8490
  }, "dsh-any-background: viewport watch");
7102
8491
  ctx.effect(() => ctx.locale.register(NS, {
7103
8492
  zh,
7104
8493
  en
7105
8494
  }), "dsh-any-background: i18n");
7106
- const sectionInject = (actions) => {
7107
- 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;
7108
8503
  syncBg();
7109
8504
  syncMetaNow();
7110
8505
  const playVideoFromBlob = (blob, mime) => {
@@ -7115,20 +8510,37 @@ window.__ModuleLoader__.load({
7115
8510
  setWpVideoUrl(localUrl, mime ?? blob.type ?? "video/mp4");
7116
8511
  applyWp();
7117
8512
  syncBg();
7118
- const applied = rWpVideo();
7119
- captureVideoSnapshot(localUrl).then((snap) => {
7120
- if (rWpVideo() !== applied) return;
7121
- setWpVideoSnapshot(snap);
7122
- applyThemeColor();
7123
- syncBg();
7124
- });
7125
- uploadVideo(blob, mime ?? blob.type ?? "video/mp4").then((ok) => {
7126
- 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;
7127
8517
  });
7128
8518
  };
8519
+ const FONT_EXT_MIME = {
8520
+ woff2: "font/woff2",
8521
+ woff: "font/woff",
8522
+ otf: "font/otf",
8523
+ ttf: "font/ttf"
8524
+ };
8525
+ const fontMimeFromName = (name) => {
8526
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
8527
+ return FONT_EXT_MIME[ext] ?? "font/ttf";
8528
+ };
8529
+ let fontPreviewUrl = null;
8530
+ const releaseFontPreview = (delay) => {
8531
+ if (fontPreviewUrl === null) return;
8532
+ const url = fontPreviewUrl;
8533
+ fontPreviewUrl = null;
8534
+ window.setTimeout(() => URL.revokeObjectURL(url), delay);
8535
+ };
8536
+ /** Point the @font-face at whatever is authoritative right now: the
8537
+ * persisted slot when one exists, otherwise nothing. */
8538
+ const applyStoredFont = () => {
8539
+ applyFontFace(cfg.fontMime !== null ? FONT_SERVE_URL : null, cfg.fontEnabled, cfg.fontMime);
8540
+ };
7129
8541
  const [wh, ws, wl] = rColor();
7130
8542
  const [dh, ds, dv] = hslToHsv(wh, ws, wl);
7131
- return {
8543
+ const built = {
7132
8544
  t: ctx.locale.bind(NS),
7133
8545
  hue: dh,
7134
8546
  sat: ds,
@@ -7178,13 +8590,11 @@ window.__ModuleLoader__.load({
7178
8590
  applyThemeColor();
7179
8591
  syncBg();
7180
8592
  saveConfig();
7181
- return;
7182
- }
7183
- if (typeof u !== "string") {
7184
- playVideoFromBlob(u, mime);
7185
- return;
8593
+ return { ok: true };
7186
8594
  }
7187
- 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;
7188
8598
  cfg.backgroundType = "video";
7189
8599
  setWpUrl(null);
7190
8600
  cfg.videoBgState = { ...DEFAULT_CONFIG.bgState };
@@ -7192,13 +8602,8 @@ window.__ModuleLoader__.load({
7192
8602
  applyWp();
7193
8603
  saveConfig();
7194
8604
  syncBg();
7195
- const applied = rWpVideo();
7196
- captureVideoSnapshot(live).then((snap) => {
7197
- if (rWpVideo() !== applied) return;
7198
- setWpVideoSnapshot(snap);
7199
- applyThemeColor();
7200
- syncBg();
7201
- });
8605
+ captureAndApplyVideo(rWpVideo());
8606
+ return { ok };
7202
8607
  },
7203
8608
  setBgType: (type) => {
7204
8609
  setBackgroundType(type);
@@ -7232,6 +8637,44 @@ window.__ModuleLoader__.load({
7232
8637
  syncBg();
7233
8638
  saveConfig();
7234
8639
  },
8640
+ setStrokes: (strokes) => {
8641
+ cfg.strokes = strokes;
8642
+ applyStrokes();
8643
+ saveConfig();
8644
+ },
8645
+ setFont: async (file) => {
8646
+ releaseFontPreview(0);
8647
+ const localUrl = URL.createObjectURL(file);
8648
+ fontPreviewUrl = localUrl;
8649
+ applyFontFace(localUrl, true, fontMimeFromName(file.name));
8650
+ const outcome = await uploadFont(file);
8651
+ if (!outcome.ok || outcome.mime === void 0) {
8652
+ applyStoredFont();
8653
+ releaseFontPreview(4e3);
8654
+ return {
8655
+ ok: false,
8656
+ refusal: outcome.refusal
8657
+ };
8658
+ }
8659
+ cfg.fontMime = outcome.mime;
8660
+ cfg.fontEnabled = true;
8661
+ applyFontFace(FONT_SERVE_URL, true, outcome.mime);
8662
+ releaseFontPreview(4e3);
8663
+ persistConfig();
8664
+ return { ok: true };
8665
+ },
8666
+ removeFont: () => {
8667
+ releaseFontPreview(0);
8668
+ cfg.fontMime = null;
8669
+ applyFontFace(null, false, null);
8670
+ removeFont();
8671
+ persistConfig();
8672
+ },
8673
+ setFontEnabled: (v) => {
8674
+ cfg.fontEnabled = v;
8675
+ applyStoredFont();
8676
+ persistConfig();
8677
+ },
7235
8678
  setWop: (v) => {
7236
8679
  cfg.wallpaperOpacity = v;
7237
8680
  applyWp();
@@ -7304,7 +8747,7 @@ window.__ModuleLoader__.load({
7304
8747
  a.href = url;
7305
8748
  a.download = "dsh-any-theme.json";
7306
8749
  a.click();
7307
- URL.revokeObjectURL(url);
8750
+ window.setTimeout(() => URL.revokeObjectURL(url), 4e3);
7308
8751
  },
7309
8752
  importTheme: async (file) => {
7310
8753
  try {
@@ -7324,16 +8767,9 @@ window.__ModuleLoader__.load({
7324
8767
  }
7325
8768
  if (blob !== null) playVideoFromBlob(blob, cfg.videoMime);
7326
8769
  else {
7327
- const live = await persistVideo(video) ? VIDEO_SERVE_URL : video;
7328
- setWpVideoUrl(live, cfg.videoMime);
8770
+ setWpVideoUrl(await persistVideo(video) ? VIDEO_SERVE_URL : video, cfg.videoMime);
7329
8771
  applyWp();
7330
- const applied = rWpVideo();
7331
- captureVideoSnapshot(live).then((snap) => {
7332
- if (rWpVideo() !== applied) return;
7333
- setWpVideoSnapshot(snap);
7334
- applyThemeColor();
7335
- syncBg();
7336
- });
8772
+ captureAndApplyVideo(rWpVideo());
7337
8773
  }
7338
8774
  } else {
7339
8775
  setWpVideoUrl(null, null);
@@ -7492,18 +8928,19 @@ window.__ModuleLoader__.load({
7492
8928
  applyWp();
7493
8929
  saveConfig();
7494
8930
  syncBg();
7495
- const applied = rWpVideo();
7496
- captureVideoSnapshot(applied).then((snap) => {
7497
- if (rWpVideo() !== applied) return;
7498
- setWpVideoSnapshot(snap);
7499
- applyThemeColor();
7500
- syncBg();
7501
- });
8931
+ captureAndApplyVideo(rWpVideo());
7502
8932
  return true;
7503
8933
  }
7504
8934
  };
8935
+ themeFace = built;
8936
+ return built;
8937
+ };
8938
+ const sectionInject = (actions) => {
8939
+ bound = actions;
8940
+ return buildFace();
7505
8941
  };
7506
- ctx.slots.inject("settings.section", () => ctx.slots.register({
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({
7507
8944
  name: "settings.section",
7508
8945
  id: "dsh-any-background",
7509
8946
  order: 35,
@@ -7512,6 +8949,10 @@ window.__ModuleLoader__.load({
7512
8949
  store,
7513
8950
  inject: sectionInject
7514
8951
  }, ThemeSection));
8952
+ registerThemeSidebarTab(ctx, {
8953
+ face: buildFace,
8954
+ store: storeInstance
8955
+ });
7515
8956
  const navLabel = () => ctx.locale.bind(NS)("nav");
7516
8957
  const applyNavIcon = () => {
7517
8958
  const nav = document.querySelector("[role=\"dialog\"][aria-modal=\"true\"][aria-labelledby]")?.querySelector("nav");