dsh-chat-background 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  本项目遵循 [SemVer](https://semver.org/lang/zh-CN/);版本号与 `package.json`、`client.js` banner 保持一致。
4
4
 
5
+ ## [1.1.0] - 2026-11-04
6
+
7
+ 阅读体验版本:对话内容按轮次分区显示,设置页控件在不同屏幕/缩放下保持稳定。
8
+
9
+ ### 新增
10
+
11
+ - **按轮次绘制的对话遮罩**:每一轮对话(用户消息 + 对应回复)单独一张遮罩卡,卡片宽度 = 阅读列宽 + 两侧各 24px 留白,随阅读列拖拽手柄自动变化;轮次之间露出壁纸,不再整列铺一层遮罩。
12
+ - **舒适轮次间距**:把 DSH 的轮次布局间距从原生 12px 调到 28px(写回宿主的 `--dsh-chat-flow-gap`),配合卡片上边 6px 出血,卡片间保留约 14px 壁纸缝,用户消息气泡完整包在遮罩内。
13
+
14
+ ### 变更
15
+
16
+ - **主窗口遮罩层**现在控制“每轮对话卡片”的透明度,而不是整块主窗口;0 为无遮罩,100 为不透明卡片。
17
+ - **设置页控件模块化**:所有设置行改为两列 grid,右侧控件组保持自身宽度并右对齐;轮换设置使用固定的 caption/输入/数值槽位;下拉框、按钮、颜色块固定尺寸,不再随语言、字体缩放或 DPI 漂移。
18
+ - **滑块**改用尺寸变量推导 thumb 偏移,替代手调像素值;窄屏(≤640px)控件堆叠到说明下方。
19
+
20
+ ### 修复
21
+
22
+ - 切换窗口后主窗口/右侧栏偶发变白:通过稳定的槽位标记清除 app 画在布局 frame、主窗口列和右侧栏上的不透明底色,并在标题栏标记变化、窗口重新获得焦点或恢复可见时重新测量。
23
+ - 设置页滑块圆点在部分缩放/DPI 下浮到轨道上方、控件互相挤压错位。
24
+ - 对话遮罩层被宿主的 flow 间距下推 6px,导致用户消息气泡顶部露在遮罩外。
25
+ - npm 安装说明与实际发布状态不一致。
26
+
27
+ ### 安装与兼容
28
+
29
+ - DSH 桌面端 `>=0.2.1-rc.3`;Node `^22.19.0 || >=24.0.0`。
30
+ - 支持从 GitHub、npm 或手动安装到 profile,详见 [README](README.md#安装)。
31
+
5
32
  ## [1.0.0] - 2026-11-02
6
33
 
7
34
  首个公开版本。
package/README.en.md CHANGED
@@ -11,9 +11,10 @@
11
11
 
12
12
  ## What it does
13
13
 
14
- Pick a few pictures, put them in the background folder, and the chat window gets a wallpaper. Keep one image fixed
15
- or rotate through a group of favourites; tune the left sidebar, main window and right sidebar scrims separately so
16
- text stays readable. The plugin also adds a bar under the input, a task progress bar and a total token usage line.
14
+ Pick a few pictures, put them in the background folder (default `~/.dsh/backgrounds/`, created automatically if
15
+ missing), and the chat window gets a wallpaper. Keep one image fixed or rotate through a group of favourites; tune the
16
+ left sidebar, main window and right sidebar scrims separately so text stays readable. The plugin also adds a bar under
17
+ the input, a task progress bar and a total token usage line.
17
18
 
18
19
  ## Features
19
20
 
@@ -44,14 +45,14 @@ dsh plugin --profile desktop add github:LittleLemonJam/dsh-chat-background
44
45
  Fully quit and reopen DSH afterwards — the host half loads at boot; refreshing the page is not enough.
45
46
  Check **Plugins → Installed** to confirm `dsh-chat-background` is enabled.
46
47
 
47
- ### From npm (once published)
48
-
49
- > The first release is distributed through GitHub; use the command below after the npm package is published.
48
+ ### From npm
50
49
 
51
50
  ```bash
52
51
  dsh plugin --profile desktop add dsh-chat-background
53
52
  ```
54
53
 
54
+ The npm package tracks the GitHub release (currently `1.1.0`); either channel works.
55
+
55
56
  ### Manual install
56
57
 
57
58
  1. Copy `package.json`, `index.js`, `host/`, `client.js`, `cordis.patch.yml`, `locale/` and `icon.svg` into
@@ -68,10 +69,11 @@ dsh plugin --profile desktop add dsh-chat-background
68
69
 
69
70
  4. Restart DSH.
70
71
 
71
- ### Add backgrounds
72
+ ### Where pictures go
72
73
 
73
- Put images in `~/.dsh/backgrounds/` (or point `DSH_BACKGROUNDS_DIR` elsewhere), then open
74
- **Settings → Page settings → Background** and press **Rescan**, or **Open folder**.
74
+ Pictures live in `~/.dsh/backgrounds/` (`C:\Users\<you>\.dsh\backgrounds\` on Windows; set `DSH_BACKGROUNDS_DIR` to use
75
+ another folder). **You do not need to create the folder first** — opening the settings page or pressing **Open folder**
76
+ creates it automatically. Drop images in, then press **Rescan** under **Settings → Page settings → Background**.
75
77
 
76
78
  ## Usage
77
79
 
package/README.md CHANGED
@@ -10,8 +10,8 @@
10
10
 
11
11
  ## 简介
12
12
 
13
- 这是一个本地运行的 DSH 插件。选几张喜欢的图片放进背景文件夹,就能让聊天界面拥有自己的壁纸:
14
- 可以固定一张,也可以按间隔自动轮换;左侧栏、主窗口、右侧栏的遮罩深浅可以分别调,界面文字仍然清楚可读。
13
+ 这是一个本地运行的 DSH 插件。选几张喜欢的图片放进背景文件夹(默认 `~/.dsh/backgrounds/`,不存在会自动创建),
14
+ 就能让聊天界面拥有自己的壁纸:可以固定一张,也可以按间隔自动轮换;左侧栏、主窗口、右侧栏的遮罩深浅可以分别调,界面文字仍然清楚可读。
15
15
  另外还附带输入栏背景条、任务进度条和全会话 Token 用量等小工具。
16
16
 
17
17
  ## 功能
@@ -43,14 +43,14 @@ dsh plugin --profile desktop add github:LittleLemonJam/dsh-chat-background
43
43
  安装后**完全退出并重新打开** DSH 桌面端(宿主半在启动时加载,只刷新页面不够)。
44
44
  可以在 **插件 → 已安装** 里确认 `dsh-chat-background` 处于启用状态。
45
45
 
46
- ### 从 npm 安装(发布后可用)
47
-
48
- > 当前首发渠道是 GitHub;npm 包发布后可使用下面的命令。
46
+ ### 从 npm 安装
49
47
 
50
48
  ```bash
51
49
  dsh plugin --profile desktop add dsh-chat-background
52
50
  ```
53
51
 
52
+ npm 包与 GitHub 发布同步(当前 `1.1.0`),两条安装方式任选其一即可。
53
+
54
54
  ### 手动安装
55
55
 
56
56
  1. 把仓库(或 `npm pack` 解压后)的 `package.json`、`index.js`、`host/`、`client.js`、`cordis.patch.yml`、
@@ -68,10 +68,11 @@ dsh plugin --profile desktop add dsh-chat-background
68
68
 
69
69
  4. 重启 DSH。
70
70
 
71
- ### 准备背景图
71
+ ### 背景图放在哪里
72
72
 
73
- 把图片放进 `~/.dsh/backgrounds/`(可用环境变量 `DSH_BACKGROUNDS_DIR` 指向其他目录),
74
- 然后在 **设置 → 页面设置 → 背景** 点「重新扫描」,或点「打开文件夹」直接打开目录。
73
+ 图片固定放在 `~/.dsh/backgrounds/`(Windows 为 `C:\Users\<用户名>\.dsh\backgrounds\`;也可以用环境变量
74
+ `DSH_BACKGROUNDS_DIR` 指向其他目录)。**目录不存在不用手动创建**:第一次打开设置页或点「打开文件夹」时插件会自动建好。
75
+ 把图片放进去后,在 **设置 → 页面设置 → 背景** 点「重新扫描」即可看到。
75
76
 
76
77
  ## 使用
77
78
 
package/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-chat-background v1.0.0
2
+ * dsh-chat-background v1.1.0
3
3
  * Chat background image, sidebar veil, composer strip, task progress bar and all-session token total for the DeepSeek Harness (DSH) Web GUI.
4
4
  * @author LittleLemonJam
5
5
  * @license MIT
@@ -40,6 +40,7 @@
40
40
  var RIGHT_COLUMN_ATTRIBUTE = "data-dsh-chat-background-right";
41
41
  var RIGHT_PANEL_ATTRIBUTE = "data-dsh-chat-background-right-panel";
42
42
  var RIGHT_PANEL_FULL_ATTRIBUTE = "data-dsh-chat-background-right-panel-full";
43
+ var READ_COLUMN_ATTRIBUTE = "data-dsh-chat-background-read-column";
43
44
  var LAYOUT_FRAME_ATTRIBUTE = "data-dsh-chat-background-frame";
44
45
  var FRAME_ANIMATING_ATTRIBUTE = "data-dsh-chat-background-frame-animating";
45
46
  var RIGHT_FULLSCREEN_ATTRIBUTE = "data-dsh-chat-background-right-fullscreen";
@@ -94,7 +95,7 @@
94
95
  }
95
96
  function diagnosticsSnapshot() {
96
97
  return {
97
- version: true ? "1.0.0" : "unknown",
98
+ version: true ? "1.1.0" : "unknown",
98
99
  updatedAt: Date.now(),
99
100
  ...state
100
101
  };
@@ -762,24 +763,273 @@
762
763
  return element;
763
764
  }
764
765
 
766
+ // src/lib/turn-masks.js
767
+ function sidePadding(gap, padding, minGap) {
768
+ if (!Number.isFinite(gap)) return padding;
769
+ if (gap <= 0) return 0;
770
+ return Math.max(0, Math.min(padding, (gap - minGap) / 2));
771
+ }
772
+ function computeTurnCards(ranges, options = {}) {
773
+ const padding = options.padding ?? 10;
774
+ const minGap = options.minGap ?? 6;
775
+ return ranges.map((range, index) => {
776
+ const previous = ranges[index - 1];
777
+ const next = ranges[index + 1];
778
+ const gapAbove = previous === void 0 ? Infinity : range.top - previous.bottom;
779
+ const gapBelow = next === void 0 ? Infinity : next.top - range.bottom;
780
+ const padTop = sidePadding(gapAbove, padding, minGap);
781
+ const padBottom = sidePadding(gapBelow, padding, minGap);
782
+ return {
783
+ key: range.key,
784
+ top: range.top - padTop,
785
+ height: Math.max(0, range.bottom - range.top + padTop + padBottom)
786
+ };
787
+ });
788
+ }
789
+
790
+ // src/turn-masks.js
791
+ var FLOW_SELECTOR = '[data-slot="conversation.chat.flow"]';
792
+ var ITEM_SELECTOR = "[data-chat-turn]";
793
+ var LAYER_CLASS = "dshcb-turn-scrims";
794
+ var SCRIM_CLASS = "dshcb-turn-scrim";
795
+ var PADDING = 10;
796
+ var TOP_BLEED = 6;
797
+ var MIN_GAP = 20;
798
+ var SIDE_PADDING = 24;
799
+ var TURN_GAP = 28;
800
+ var GAP_PROPERTY = "--dsh-chat-flow-gap";
801
+ var column = null;
802
+ var layer = null;
803
+ var resizeObserver = null;
804
+ var frame = 0;
805
+ var scrims = /* @__PURE__ */ new Map();
806
+ var applied = /* @__PURE__ */ new Map();
807
+ var boundaries = /* @__PURE__ */ new Map();
808
+ var lastReport = "";
809
+ function findFlow() {
810
+ for (const flow of document.querySelectorAll(FLOW_SELECTOR)) {
811
+ const items = flow.querySelectorAll(ITEM_SELECTOR);
812
+ if (items.length === 0) continue;
813
+ if (flow.getClientRects().length === 0 && getComputedStyle(flow).display !== "contents") continue;
814
+ let visible = false;
815
+ for (const item of items) {
816
+ const rect = item.getBoundingClientRect();
817
+ if (rect.width > 0 && rect.height > 0) {
818
+ visible = true;
819
+ break;
820
+ }
821
+ }
822
+ if (!visible) continue;
823
+ return flow;
824
+ }
825
+ return null;
826
+ }
827
+ function findColumn(flow) {
828
+ let element = flow;
829
+ while (element !== null && element !== document.body) {
830
+ const style = getComputedStyle(element);
831
+ if (style.position === "fixed") return null;
832
+ if (style.display !== "contents" && element.clientWidth > 0 && element.clientHeight > 0) return element;
833
+ element = element.parentElement;
834
+ }
835
+ return null;
836
+ }
837
+ function disposeLayer() {
838
+ resizeObserver?.disconnect();
839
+ resizeObserver = null;
840
+ layer?.remove();
841
+ layer = null;
842
+ if (column !== null) column.removeAttribute(READ_COLUMN_ATTRIBUTE);
843
+ column = null;
844
+ for (const [element] of boundaries) element.style.removeProperty(GAP_PROPERTY);
845
+ boundaries.clear();
846
+ scrims.clear();
847
+ applied.clear();
848
+ }
849
+ function ensureLayer(nextColumn) {
850
+ if (column !== nextColumn) {
851
+ disposeLayer();
852
+ column = nextColumn;
853
+ column.setAttribute(READ_COLUMN_ATTRIBUTE, "");
854
+ }
855
+ if (layer === null || !layer.isConnected) {
856
+ layer = document.createElement("div");
857
+ layer.className = LAYER_CLASS;
858
+ layer.setAttribute("aria-hidden", "true");
859
+ column.appendChild(layer);
860
+ }
861
+ if (resizeObserver === null && typeof ResizeObserver !== "undefined" && column !== null) {
862
+ resizeObserver = new ResizeObserver(() => {
863
+ scheduleTurnMaskUpdate();
864
+ });
865
+ resizeObserver.observe(column);
866
+ }
867
+ if (layer.parentElement !== column) column.appendChild(layer);
868
+ return layer;
869
+ }
870
+ function measureTurns(flow, originTop) {
871
+ const turns = [];
872
+ const byKey = /* @__PURE__ */ new Map();
873
+ for (const item of flow.querySelectorAll(ITEM_SELECTOR)) {
874
+ if (item.closest("[hidden]") !== null) continue;
875
+ const itemRect = item.getBoundingClientRect();
876
+ if (itemRect.width <= 0 || itemRect.height <= 0) continue;
877
+ const turn = item.getAttribute("data-chat-turn") ?? "";
878
+ if (turn === "") continue;
879
+ const top = itemRect.top - originTop;
880
+ const bottom = itemRect.bottom - originTop;
881
+ const existing = byKey.get(turn);
882
+ if (existing === void 0) {
883
+ const group = { key: turn, top, bottom, first: item };
884
+ byKey.set(turn, group);
885
+ turns.push(group);
886
+ } else {
887
+ if (top < existing.top) existing.top = top;
888
+ if (bottom > existing.bottom) existing.bottom = bottom;
889
+ }
890
+ }
891
+ turns.sort((left, right) => left.top - right.top);
892
+ return turns;
893
+ }
894
+ function syncTurnGaps(turns) {
895
+ const desired = /* @__PURE__ */ new Map();
896
+ for (let index = 1; index < turns.length; index += 1) {
897
+ const first = turns[index].first;
898
+ if (first !== null) desired.set(first, TURN_GAP + "px");
899
+ }
900
+ let changed = false;
901
+ for (const [element] of boundaries) {
902
+ if (element.isConnected && desired.has(element)) continue;
903
+ element.style.removeProperty(GAP_PROPERTY);
904
+ boundaries.delete(element);
905
+ changed = true;
906
+ }
907
+ for (const [element, value] of desired) {
908
+ if (boundaries.get(element) === value) continue;
909
+ element.style.setProperty(GAP_PROPERTY, value);
910
+ boundaries.set(element, value);
911
+ changed = true;
912
+ }
913
+ return changed;
914
+ }
915
+ function refresh() {
916
+ frame = 0;
917
+ if (!document.documentElement.hasAttribute(ACTIVE_ATTRIBUTE)) {
918
+ disposeLayer();
919
+ recordTurnMaskDiagnostics({ active: false });
920
+ return;
921
+ }
922
+ const flow = findFlow();
923
+ const nextColumn = flow === null ? null : findColumn(flow);
924
+ if (nextColumn === null) {
925
+ disposeLayer();
926
+ recordTurnMaskDiagnostics({ active: true, column: false, turns: 0 });
927
+ return;
928
+ }
929
+ const cardLayer = ensureLayer(nextColumn);
930
+ const style = getComputedStyle(nextColumn);
931
+ const paddingLeft = Number.parseFloat(style.paddingLeft) || 0;
932
+ const paddingRight = Number.parseFloat(style.paddingRight) || 0;
933
+ const borderLeft = Number.parseFloat(style.borderLeftWidth) || 0;
934
+ const borderTop = Number.parseFloat(style.borderTopWidth) || 0;
935
+ const rect = nextColumn.getBoundingClientRect();
936
+ const scrollTop = nextColumn.scrollTop || 0;
937
+ const scrollLeft = nextColumn.scrollLeft || 0;
938
+ const originTop = rect.top + borderTop - scrollTop;
939
+ const originLeft = rect.left + borderLeft - scrollLeft;
940
+ const contentWidth = Math.max(0, nextColumn.clientWidth - paddingLeft - paddingRight);
941
+ let turns = measureTurns(flow, originTop);
942
+ if (syncTurnGaps(turns)) turns = measureTurns(flow, originTop);
943
+ const cards = computeTurnCards(turns, { padding: PADDING, minGap: MIN_GAP });
944
+ const cardLeft = paddingLeft - SIDE_PADDING;
945
+ const cardWidth = contentWidth + SIDE_PADDING * 2;
946
+ cardLayer.style.left = cardLeft + "px";
947
+ cardLayer.style.width = cardWidth + "px";
948
+ const live = /* @__PURE__ */ new Set();
949
+ for (const card of cards) {
950
+ const rawTop = Math.round(card.top) - TOP_BLEED;
951
+ const top = Math.max(0, rawTop);
952
+ const height = Math.max(0, Math.round(card.height) + TOP_BLEED - (top - rawTop));
953
+ const key = card.key;
954
+ live.add(key);
955
+ let element = scrims.get(key);
956
+ if (element === void 0) {
957
+ element = document.createElement("div");
958
+ element.className = SCRIM_CLASS;
959
+ element.setAttribute("data-turn", key);
960
+ cardLayer.appendChild(element);
961
+ scrims.set(key, element);
962
+ applied.delete(key);
963
+ }
964
+ const previous = applied.get(key);
965
+ if (previous === void 0 || previous.top !== top || previous.height !== height) {
966
+ element.style.top = top + "px";
967
+ element.style.height = height + "px";
968
+ applied.set(key, { top, height });
969
+ }
970
+ }
971
+ for (const [key, element] of scrims) {
972
+ if (live.has(key)) continue;
973
+ element.remove();
974
+ scrims.delete(key);
975
+ applied.delete(key);
976
+ }
977
+ recordTurnMaskDiagnostics({
978
+ active: true,
979
+ column: true,
980
+ turns: cards.length,
981
+ width: Math.round(cardWidth),
982
+ textWidth: Math.round(contentWidth)
983
+ });
984
+ }
985
+ function recordTurnMaskDiagnostics(value) {
986
+ const next = JSON.stringify(value);
987
+ if (next === lastReport) return;
988
+ lastReport = next;
989
+ recordDiagnostics({ turnMasks: value });
990
+ }
991
+ function scheduleTurnMaskUpdate() {
992
+ if (frame !== 0) return;
993
+ if (typeof window.requestAnimationFrame !== "function") {
994
+ refresh();
995
+ return;
996
+ }
997
+ frame = window.requestAnimationFrame(refresh);
998
+ }
999
+ function updateTurnMasks() {
1000
+ if (frame !== 0) {
1001
+ window.cancelAnimationFrame(frame);
1002
+ frame = 0;
1003
+ }
1004
+ refresh();
1005
+ }
1006
+ function teardownTurnMasks() {
1007
+ if (frame !== 0) {
1008
+ window.cancelAnimationFrame(frame);
1009
+ frame = 0;
1010
+ }
1011
+ disposeLayer();
1012
+ recordTurnMaskDiagnostics({ active: false });
1013
+ }
1014
+
765
1015
  // src/background.js
766
1016
  function ensureBackgroundLayer(name) {
767
1017
  if (document.body === null || name === null) return null;
768
- let layer2 = document.querySelector("[" + BACKGROUND_LAYER_ATTRIBUTE + "]");
769
- if (layer2 === null || layer2.tagName !== "IMG") {
770
- layer2?.remove();
771
- layer2 = document.createElement("img");
772
- layer2.className = "dshcb-background-layer";
773
- layer2.setAttribute(BACKGROUND_LAYER_ATTRIBUTE, "");
774
- layer2.setAttribute("aria-hidden", "true");
775
- layer2.setAttribute("alt", "");
776
- layer2.setAttribute("decoding", "async");
777
- layer2.setAttribute("draggable", "false");
778
- document.body.appendChild(layer2);
1018
+ let layer3 = document.querySelector("[" + BACKGROUND_LAYER_ATTRIBUTE + "]");
1019
+ if (layer3 === null || layer3.tagName !== "IMG") {
1020
+ layer3?.remove();
1021
+ layer3 = document.createElement("img");
1022
+ layer3.className = "dshcb-background-layer";
1023
+ layer3.setAttribute(BACKGROUND_LAYER_ATTRIBUTE, "");
1024
+ layer3.setAttribute("aria-hidden", "true");
1025
+ layer3.setAttribute("alt", "");
1026
+ layer3.setAttribute("decoding", "async");
1027
+ layer3.setAttribute("draggable", "false");
1028
+ document.body.appendChild(layer3);
779
1029
  }
780
1030
  const src = FILE_URL + encodeURIComponent(name);
781
- if (layer2.getAttribute("src") !== src) layer2.setAttribute("src", src);
782
- return layer2;
1031
+ if (layer3.getAttribute("src") !== src) layer3.setAttribute("src", src);
1032
+ return layer3;
783
1033
  }
784
1034
  function removeBackgroundLayer() {
785
1035
  for (const element of document.querySelectorAll("[" + BACKGROUND_LAYER_ATTRIBUTE + "]")) element.remove();
@@ -815,7 +1065,7 @@
815
1065
  function readRightScrim() {
816
1066
  return readRegionScrim(SCRIM_RIGHT_KEY, VEIL_KEY, VEIL_DEFAULT);
817
1067
  }
818
- function layer(alpha) {
1068
+ function layer2(alpha) {
819
1069
  const rgb = isDark() ? "9,11,15" : "250,250,250";
820
1070
  const value = (alpha / 100).toFixed(3);
821
1071
  return `linear-gradient(rgba(${rgb},${value}), rgba(${rgb},${value}))`;
@@ -860,16 +1110,16 @@
860
1110
  }
861
1111
  function markColumn(attribute, slotName) {
862
1112
  const outlet = document.querySelector(`[data-slot="${slotName}"]`);
863
- const column = outlet === null ? null : outlet.parentElement;
864
- if (column !== null && !column.hasAttribute(attribute)) column.setAttribute(attribute, "");
1113
+ const column2 = outlet === null ? null : outlet.parentElement;
1114
+ if (column2 !== null && !column2.hasAttribute(attribute)) column2.setAttribute(attribute, "");
865
1115
  }
866
1116
  function clearColumn(attribute) {
867
1117
  for (const element of document.querySelectorAll(`[${attribute}]`)) element.removeAttribute(attribute);
868
1118
  }
869
1119
  function markSidebarColumn() {
870
1120
  const outlet = document.querySelector('[data-slot="sidebar"]');
871
- const column = outlet === null ? null : outlet.parentElement;
872
- if (column !== null) column.setAttribute(SIDEBAR_COLUMN_ATTRIBUTE, "");
1121
+ const column2 = outlet === null ? null : outlet.parentElement;
1122
+ if (column2 !== null) column2.setAttribute(SIDEBAR_COLUMN_ATTRIBUTE, "");
873
1123
  }
874
1124
  function markMainColumn() {
875
1125
  markColumn(MAIN_COLUMN_ATTRIBUTE, "main");
@@ -936,7 +1186,7 @@
936
1186
  observedPanel = null;
937
1187
  }
938
1188
  const outlet = document.querySelector('[data-slot="rightbar"]');
939
- const column = outlet === null ? null : outlet.parentElement;
1189
+ const column2 = outlet === null ? null : outlet.parentElement;
940
1190
  const panel = outlet === null ? null : outlet.querySelector('[class*="panel"]');
941
1191
  const root = document.documentElement;
942
1192
  const labelFull = hasExitFullscreenControl();
@@ -966,12 +1216,12 @@
966
1216
  element.removeAttribute(RIGHT_PANEL_FULL_ATTRIBUTE);
967
1217
  };
968
1218
  setFull(panel);
969
- if (column !== null) setFull(column);
1219
+ if (column2 !== null) setFull(column2);
970
1220
  if (full) {
971
1221
  const leftOutlet = document.querySelector('[data-slot="sidebar"]');
972
1222
  const leftColumn = leftOutlet === null ? null : leftOutlet.parentElement;
973
1223
  const leftWidth = leftColumn === null ? 0 : leftColumn.getBoundingClientRect().width;
974
- const columnLeft = column === null ? 0 : column.getBoundingClientRect().left;
1224
+ const columnLeft = column2 === null ? 0 : column2.getBoundingClientRect().left;
975
1225
  const offset = Math.round(leftWidth - columnLeft);
976
1226
  const width = Math.round(window.innerWidth - leftWidth);
977
1227
  root.style.setProperty("--dsh-chat-background-full-left", offset + "px");
@@ -984,15 +1234,15 @@
984
1234
  }
985
1235
  function animateLayoutFrame() {
986
1236
  const outlet = document.querySelector('[data-slot="main"]');
987
- const column = outlet === null ? null : outlet.parentElement;
988
- const frame = column === null ? null : column.parentElement;
989
- if (frame === null) return;
1237
+ const column2 = outlet === null ? null : outlet.parentElement;
1238
+ const frame2 = column2 === null ? null : column2.parentElement;
1239
+ if (frame2 === null) return;
990
1240
  const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
991
- if (!reduced) frame.setAttribute(FRAME_ANIMATING_ATTRIBUTE, "");
1241
+ if (!reduced) frame2.setAttribute(FRAME_ANIMATING_ATTRIBUTE, "");
992
1242
  if (frameAnimTimer !== 0) window.clearTimeout(frameAnimTimer);
993
1243
  frameAnimTimer = window.setTimeout(() => {
994
1244
  frameAnimTimer = 0;
995
- frame.removeAttribute(FRAME_ANIMATING_ATTRIBUTE);
1245
+ frame2.removeAttribute(FRAME_ANIMATING_ATTRIBUTE);
996
1246
  }, 340);
997
1247
  }
998
1248
  function installRightbarGestures() {
@@ -1052,9 +1302,9 @@
1052
1302
  }
1053
1303
  function markLayoutFrame() {
1054
1304
  const outlet = document.querySelector('[data-slot="main"]');
1055
- const column = outlet === null ? null : outlet.parentElement;
1056
- const frame = column === null ? null : column.parentElement;
1057
- if (frame !== null && !frame.hasAttribute(LAYOUT_FRAME_ATTRIBUTE)) frame.setAttribute(LAYOUT_FRAME_ATTRIBUTE, "");
1305
+ const column2 = outlet === null ? null : outlet.parentElement;
1306
+ const frame2 = column2 === null ? null : column2.parentElement;
1307
+ if (frame2 !== null && !frame2.hasAttribute(LAYOUT_FRAME_ATTRIBUTE)) frame2.setAttribute(LAYOUT_FRAME_ATTRIBUTE, "");
1058
1308
  }
1059
1309
  function markRegionColumns() {
1060
1310
  markSidebarColumn();
@@ -1112,7 +1362,7 @@
1112
1362
  if (candidates.length > 0) syncDynamicRules([candidates[0]]);
1113
1363
  return candidates;
1114
1364
  }
1115
- var applied = { selection: void 0, dark: null, scrimLeft: null, scrimMain: null, scrimRight: null };
1365
+ var applied2 = { selection: void 0, dark: null, scrimLeft: null, scrimMain: null, scrimRight: null };
1116
1366
  var markSignature = -1;
1117
1367
  var markContainer = null;
1118
1368
  var flushHandle = 0;
@@ -1128,7 +1378,7 @@
1128
1378
  const container = conversationContainer();
1129
1379
  const painters = markedPainters();
1130
1380
  const needsPaint = signature !== markSignature || container !== markContainer || painters.length === 0;
1131
- const sameVars = applied.selection === next && applied.dark === dark && applied.scrimLeft === scrimLeft && applied.scrimMain === scrimMain && applied.scrimRight === scrimRight;
1381
+ const sameVars = applied2.selection === next && applied2.dark === dark && applied2.scrimLeft === scrimLeft && applied2.scrimMain === scrimMain && applied2.scrimRight === scrimRight;
1132
1382
  if (next === null) {
1133
1383
  if (sameVars && !root.hasAttribute(ACTIVE_ATTRIBUTE)) return;
1134
1384
  for (const property of INLINE_PROPERTIES) root.style.removeProperty(property);
@@ -1140,8 +1390,9 @@
1140
1390
  clearPainters();
1141
1391
  clearRegionColumns();
1142
1392
  syncDynamicRules([]);
1393
+ teardownTurnMasks();
1143
1394
  nudgeNativeTitlebar(false);
1144
- applied = { selection: null, dark, scrimLeft, scrimMain, scrimRight };
1395
+ applied2 = { selection: null, dark, scrimLeft, scrimMain, scrimRight };
1145
1396
  markSignature = -1;
1146
1397
  markContainer = null;
1147
1398
  recordDiagnostics({ background: { active: false } });
@@ -1171,13 +1422,14 @@
1171
1422
  });
1172
1423
  if (!sameVars) {
1173
1424
  root.style.setProperty("--dsh-chat-background-image", 'url("' + FILE_URL + encodeURIComponent(next) + '")');
1174
- root.style.setProperty("--dsh-chat-background-scrim-left", layer(scrimLeft));
1175
- root.style.setProperty("--dsh-chat-background-scrim-main", layer(scrimMain));
1176
- root.style.setProperty("--dsh-chat-background-scrim-right", layer(scrimRight));
1425
+ root.style.setProperty("--dsh-chat-background-scrim-left", layer2(scrimLeft));
1426
+ root.style.setProperty("--dsh-chat-background-scrim-main", layer2(scrimMain));
1427
+ root.style.setProperty("--dsh-chat-background-scrim-right", layer2(scrimRight));
1177
1428
  root.setAttribute(ACTIVE_ATTRIBUTE, next);
1178
- applied = { selection: next, dark, scrimLeft, scrimMain, scrimRight };
1429
+ applied2 = { selection: next, dark, scrimLeft, scrimMain, scrimRight };
1179
1430
  nudgeNativeTitlebar(true);
1180
1431
  }
1432
+ updateTurnMasks();
1181
1433
  }
1182
1434
  function flushBackground() {
1183
1435
  if (flushHandle !== 0) return;
@@ -1186,6 +1438,7 @@
1186
1438
  applyBackground(readSelection());
1187
1439
  renderComposerStrip();
1188
1440
  renderTokenTotal();
1441
+ updateTurnMasks();
1189
1442
  };
1190
1443
  if (typeof window.requestAnimationFrame === "function") {
1191
1444
  flushIsTimeout = false;
@@ -1195,6 +1448,11 @@
1195
1448
  flushHandle = window.setTimeout(run, 16);
1196
1449
  }
1197
1450
  }
1451
+ function invalidatePainters() {
1452
+ markSignature = -1;
1453
+ markContainer = null;
1454
+ flushBackground();
1455
+ }
1198
1456
  function teardownVisuals() {
1199
1457
  if (flushHandle !== 0) {
1200
1458
  if (flushIsTimeout) window.clearTimeout(flushHandle);
@@ -1224,6 +1482,7 @@
1224
1482
  clearPainters();
1225
1483
  clearRegionColumns();
1226
1484
  syncDynamicRules([]);
1485
+ teardownTurnMasks();
1227
1486
  document.querySelector(`style[data-plugin-css=${cssString(DYNAMIC_TAG_ID)}]`)?.remove();
1228
1487
  document.querySelector(`[${STRIP_ATTRIBUTE}]`)?.remove();
1229
1488
  document.querySelector(`[${TOKEN_NODE_ATTRIBUTE}]`)?.remove();
@@ -1231,7 +1490,7 @@
1231
1490
  cachedContainer = null;
1232
1491
  markSignature = -1;
1233
1492
  markContainer = null;
1234
- applied = { selection: void 0, dark: null, scrimLeft: null, scrimMain: null, scrimRight: null };
1493
+ applied2 = { selection: void 0, dark: null, scrimLeft: null, scrimMain: null, scrimRight: null };
1235
1494
  }
1236
1495
 
1237
1496
  // src/lib/zoom.js
@@ -1463,7 +1722,7 @@
1463
1722
  }
1464
1723
 
1465
1724
  // src/rotation.js
1466
- var LAYER_CLASS = "dshcb-rotate-layer";
1725
+ var LAYER_CLASS2 = "dshcb-rotate-layer";
1467
1726
  var timer = 0;
1468
1727
  var layerTimers = [];
1469
1728
  var request = null;
@@ -1522,45 +1781,45 @@
1522
1781
  return document.body?.hasAttribute("data-ds-dark-theme") === true ? "rgb(9, 11, 15)" : "rgb(250, 250, 250)";
1523
1782
  }
1524
1783
  function createImageLayer(name) {
1525
- const layer2 = document.createElement("img");
1526
- layer2.className = LAYER_CLASS;
1527
- layer2.setAttribute(ROTATION_LAYER_ATTRIBUTE, "");
1528
- layer2.setAttribute("aria-hidden", "true");
1529
- layer2.setAttribute("alt", "");
1530
- layer2.setAttribute("draggable", "false");
1531
- layer2.src = FILE_URL + encodeURIComponent(name);
1532
- document.body.appendChild(layer2);
1533
- return layer2;
1784
+ const layer3 = document.createElement("img");
1785
+ layer3.className = LAYER_CLASS2;
1786
+ layer3.setAttribute(ROTATION_LAYER_ATTRIBUTE, "");
1787
+ layer3.setAttribute("aria-hidden", "true");
1788
+ layer3.setAttribute("alt", "");
1789
+ layer3.setAttribute("draggable", "false");
1790
+ layer3.src = FILE_URL + encodeURIComponent(name);
1791
+ document.body.appendChild(layer3);
1792
+ return layer3;
1534
1793
  }
1535
1794
  function createColorLayer(color) {
1536
- const layer2 = document.createElement("div");
1537
- layer2.className = LAYER_CLASS;
1538
- layer2.setAttribute(ROTATION_LAYER_ATTRIBUTE, "");
1539
- layer2.setAttribute("aria-hidden", "true");
1540
- layer2.style.background = color;
1541
- document.body.appendChild(layer2);
1542
- return layer2;
1795
+ const layer3 = document.createElement("div");
1796
+ layer3.className = LAYER_CLASS2;
1797
+ layer3.setAttribute(ROTATION_LAYER_ATTRIBUTE, "");
1798
+ layer3.setAttribute("aria-hidden", "true");
1799
+ layer3.style.background = color;
1800
+ document.body.appendChild(layer3);
1801
+ return layer3;
1543
1802
  }
1544
1803
  function fadeTransition(previous, next, duration) {
1545
- const layer2 = createImageLayer(previous);
1546
- layer2.style.opacity = "1";
1804
+ const layer3 = createImageLayer(previous);
1805
+ layer3.style.opacity = "1";
1547
1806
  commit(next);
1548
- void layer2.offsetWidth;
1549
- layer2.style.transition = `opacity ${String(duration)}s ease`;
1550
- layer2.style.opacity = "0";
1807
+ void layer3.offsetWidth;
1808
+ layer3.style.transition = `opacity ${String(duration)}s ease`;
1809
+ layer3.style.opacity = "0";
1551
1810
  scheduleLayer(removeLayer, duration * 1e3 + 80);
1552
1811
  }
1553
1812
  function fadeThroughTransition(previous, next, duration) {
1554
1813
  const half = Math.max(40, duration * 1e3 / 2);
1555
- const layer2 = createColorLayer(themeBaseColour());
1556
- layer2.style.opacity = "0";
1557
- void layer2.offsetWidth;
1558
- layer2.style.transition = `opacity ${String(half / 1e3)}s ease`;
1559
- layer2.style.opacity = "1";
1814
+ const layer3 = createColorLayer(themeBaseColour());
1815
+ layer3.style.opacity = "0";
1816
+ void layer3.offsetWidth;
1817
+ layer3.style.transition = `opacity ${String(half / 1e3)}s ease`;
1818
+ layer3.style.opacity = "1";
1560
1819
  scheduleLayer(() => {
1561
1820
  commit(next);
1562
- layer2.style.transition = `opacity ${String(half / 1e3)}s ease`;
1563
- layer2.style.opacity = "0";
1821
+ layer3.style.transition = `opacity ${String(half / 1e3)}s ease`;
1822
+ layer3.style.opacity = "0";
1564
1823
  scheduleLayer(removeLayer, half + 80);
1565
1824
  }, half);
1566
1825
  void previous;
@@ -1814,24 +2073,15 @@
1814
2073
  Row,
1815
2074
  { title: copy.modeTitle },
1816
2075
  h(
1817
- "div",
1818
- { className: "dshcb-rotation-controls" },
1819
- h(
1820
- "label",
1821
- { className: "dshcb-control-field" },
1822
- h("span", { className: "dshcb-control-caption" }, copy.modeTitle),
1823
- h(
1824
- "select",
1825
- {
1826
- className: "dshcb-select",
1827
- value: mode,
1828
- "aria-label": copy.modeTitle,
1829
- onChange: (event) => change(event.target.value)
1830
- },
1831
- h("option", { value: "fixed" }, copy.modeFixed),
1832
- h("option", { value: "rotation" }, copy.modeRotation)
1833
- )
1834
- )
2076
+ "select",
2077
+ {
2078
+ className: "dshcb-select",
2079
+ value: mode,
2080
+ "aria-label": copy.modeTitle,
2081
+ onChange: (event) => change(event.target.value)
2082
+ },
2083
+ h("option", { value: "fixed" }, copy.modeFixed),
2084
+ h("option", { value: "rotation" }, copy.modeRotation)
1835
2085
  )
1836
2086
  );
1837
2087
  }
@@ -2359,36 +2609,42 @@ ${active} {
2359
2609
  object-position: center center;
2360
2610
  }
2361
2611
 
2362
- /* Rotation settings block. The mode/effect/group controls stack inside
2363
- * the normal row control column so they line up with every other row. */
2612
+ /* Rotation settings block. Each field is its own two-column module so the
2613
+ * captions, inputs and value slots keep the same width on every display,
2614
+ * font scale and window size; the block only decides the module width. */
2364
2615
  .dshcb-rotation-controls {
2365
- display: flex;
2366
- flex-direction: column;
2616
+ display: grid;
2367
2617
  gap: 8px;
2368
- min-width: 260px;
2369
- max-width: 340px;
2618
+ width: min(340px, 100%);
2619
+ min-width: 0;
2370
2620
  }
2371
2621
  .dshcb-control-field {
2372
- display: flex;
2622
+ display: grid;
2623
+ grid-template-columns: 84px minmax(0, 1fr) auto;
2373
2624
  align-items: center;
2374
- gap: 8px;
2625
+ column-gap: 8px;
2626
+ min-width: 0;
2375
2627
  font-size: 12px;
2376
2628
  }
2377
2629
  .dshcb-control-field > .dshcb-control-caption {
2378
- flex: 0 0 auto;
2630
+ min-width: 0;
2379
2631
  color: var(--dsw-alias-label-secondary);
2632
+ overflow: hidden;
2633
+ text-overflow: ellipsis;
2634
+ white-space: nowrap;
2380
2635
  }
2381
2636
  .dshcb-control-field > .dshcb-select {
2382
- flex: 1 1 auto;
2637
+ width: 100%;
2638
+ max-width: none;
2383
2639
  min-width: 0;
2384
2640
  }
2385
2641
  .dshcb-control-field > .dshcb-range {
2386
- flex: 1 1 auto;
2387
- min-width: 80px;
2642
+ width: 100%;
2643
+ min-width: 0;
2388
2644
  }
2389
2645
  .dshcb-control-field > .dshcb-value {
2390
- flex: 0 0 auto;
2391
- min-width: 42px;
2646
+ min-width: 40px;
2647
+ justify-self: end;
2392
2648
  text-align: end;
2393
2649
  }
2394
2650
  .dshcb-group-list {
@@ -2481,6 +2737,21 @@ ${active} [class*="sidebarCol"] {
2481
2737
  isolation: isolate !important;
2482
2738
  }
2483
2739
 
2740
+ /* The app paints the base surface on the frame and on the center/right
2741
+ * columns: the Windows titlebar frame gives the center column an opaque fill
2742
+ * plus a rounded corner, and macOS fills the right column. The measured
2743
+ * painter marks only describe the DOM that was measured, so a rebuilt or
2744
+ * restyled column can come back white while the slots stay the same (the
2745
+ * marker is refreshed on every apply, the measured mark is not). Clearing
2746
+ * through the stable slot-derived markers keeps the image visible across
2747
+ * rebuilds, window restores and titlebar re-themes. */
2748
+ ${active} [${MAIN_COLUMN_ATTRIBUTE}],
2749
+ ${active} [${RIGHT_COLUMN_ATTRIBUTE}],
2750
+ ${active} [${LAYOUT_FRAME_ATTRIBUTE}] {
2751
+ background-color: transparent !important;
2752
+ background-image: none !important;
2753
+ }
2754
+
2484
2755
  /* Left sidebar scrim. */
2485
2756
  ${active} [${SIDEBAR_COLUMN_ATTRIBUTE}]::before,
2486
2757
  ${active} [class*="sidebarCol"]::before {
@@ -2492,13 +2763,33 @@ ${active} [class*="sidebarCol"]::before {
2492
2763
  background: var(--dsh-chat-background-scrim-left);
2493
2764
  }
2494
2765
 
2495
- /* Main conversation column scrim. */
2496
- ${active} [${MAIN_COLUMN_ATTRIBUTE}]::after {
2497
- content: "";
2766
+ /* The reading-area veil is painted per conversation turn by the turn-mask
2767
+ * layer (src/turn-masks.js), not as one rectangle over the whole centre
2768
+ * column: the wallpaper stays visible between two turns while every turn —
2769
+ * prompt and reply — still reads as one card. The layer lives inside the
2770
+ * reading column so its width follows that column's drag handle. */
2771
+ ${active} [${READ_COLUMN_ATTRIBUTE}] {
2772
+ position: relative !important;
2773
+ isolation: isolate !important;
2774
+ }
2775
+ .dshcb-turn-scrims {
2498
2776
  position: absolute;
2499
- inset: 0;
2500
- z-index: -1;
2777
+ top: 0;
2778
+ height: 0;
2779
+ overflow: visible;
2501
2780
  pointer-events: none;
2781
+ z-index: -1;
2782
+ /* The host's flow-gap rule targets every direct child of the flow, including
2783
+ * this layer; without the reset the whole layer (and every card in it) sits
2784
+ * one flow gap below the reading column top, leaving prompt bubbles outside
2785
+ * the veil. */
2786
+ margin: 0 !important;
2787
+ }
2788
+ .dshcb-turn-scrim {
2789
+ position: absolute;
2790
+ left: 0;
2791
+ width: 100%;
2792
+ border-radius: var(--dsw-radius-lg, 12px);
2502
2793
  background: var(--dsh-chat-background-scrim-main);
2503
2794
  }
2504
2795
 
@@ -2594,8 +2885,8 @@ ${active} body > span[style*="dsw-specific-sidebar-fill"] {
2594
2885
  background-image: none !important;
2595
2886
  }
2596
2887
  ${active} [${SIDEBAR_COLUMN_ATTRIBUTE}]::before,
2597
- ${active} [${MAIN_COLUMN_ATTRIBUTE}]::after,
2598
- ${active} [${RIGHT_COLUMN_ATTRIBUTE}]::after {
2888
+ ${active} [${RIGHT_COLUMN_ATTRIBUTE}]::after,
2889
+ ${active} .dshcb-turn-scrims {
2599
2890
  display: none;
2600
2891
  }
2601
2892
  }
@@ -2609,8 +2900,8 @@ ${active} body > span[style*="dsw-specific-sidebar-fill"] {
2609
2900
  background-image: none !important;
2610
2901
  }
2611
2902
  ${active} [${SIDEBAR_COLUMN_ATTRIBUTE}]::before,
2612
- ${active} [${MAIN_COLUMN_ATTRIBUTE}]::after,
2613
- ${active} [${RIGHT_COLUMN_ATTRIBUTE}]::after {
2903
+ ${active} [${RIGHT_COLUMN_ATTRIBUTE}]::after,
2904
+ ${active} .dshcb-turn-scrims {
2614
2905
  display: none;
2615
2906
  }
2616
2907
  }
@@ -2766,31 +3057,41 @@ ${active} :is([data-slot="settings.section"], [data-slot="settings.header"], [da
2766
3057
  }
2767
3058
  .dshcb-bg-file {
2768
3059
  display: flex;
3060
+ flex-wrap: wrap;
2769
3061
  align-items: center;
2770
3062
  gap: 8px;
2771
3063
  }
2772
3064
  .dshcb-bg-select {
2773
- flex: 1;
3065
+ flex: 1 1 200px;
2774
3066
  max-width: none;
2775
3067
  min-width: 0;
2776
3068
  }
2777
3069
  .dshcb-bg-file .dshcb-select {
2778
- flex: 1;
3070
+ flex: 1 1 200px;
2779
3071
  max-width: none;
3072
+ min-width: 0;
3073
+ }
3074
+ .dshcb-bg-file .dshcb-button {
3075
+ flex: none;
2780
3076
  }
2781
3077
 
3078
+ /* Every setting row is a two-column module: the copy takes the free space,
3079
+ * the control block keeps its own intrinsic width and is pinned to the right.
3080
+ * Grid (not flex + space-between) is what stops a wide control from squeezing
3081
+ * the label or shifting it between window sizes, zoom levels and displays. */
2782
3082
  .dshcb-row {
2783
- display: flex;
3083
+ display: grid;
3084
+ grid-template-columns: minmax(0, 1fr) auto;
2784
3085
  align-items: center;
2785
- justify-content: space-between;
2786
- gap: 24px;
3086
+ column-gap: 24px;
3087
+ row-gap: 8px;
2787
3088
  padding: 16px 0;
2788
3089
  border-bottom: 0.5px solid var(--dsw-alias-border-l2);
2789
3090
  }
2790
3091
  .dshcb-text {
2791
3092
  display: flex;
2792
3093
  flex-direction: column;
2793
- flex: 1;
3094
+ align-items: stretch;
2794
3095
  gap: 4px;
2795
3096
  min-width: 0;
2796
3097
  }
@@ -2816,14 +3117,20 @@ ${active} :is([data-slot="settings.section"], [data-slot="settings.header"], [da
2816
3117
  font-size: 12px;
2817
3118
  line-height: 18px;
2818
3119
  }
3120
+ /* One self-contained module per control group: it keeps its size and stays
3121
+ * right-aligned whatever the label wraps to. */
2819
3122
  .dshcb-control {
2820
- display: inline-flex;
3123
+ display: flex;
3124
+ flex: none;
2821
3125
  align-items: center;
3126
+ justify-content: flex-end;
2822
3127
  gap: 8px;
3128
+ min-width: 0;
2823
3129
  }
2824
3130
  /* ---- controls ------------------------------------------------------------ */
2825
3131
 
2826
3132
  .dshcb-button {
3133
+ flex: none;
2827
3134
  height: 32px;
2828
3135
  padding: 0 12px;
2829
3136
  border: 0.5px solid var(--dsw-alias-border-l1);
@@ -2833,6 +3140,7 @@ ${active} :is([data-slot="settings.section"], [data-slot="settings.header"], [da
2833
3140
  font: inherit;
2834
3141
  font-size: 13px;
2835
3142
  line-height: 20px;
3143
+ white-space: nowrap;
2836
3144
  cursor: pointer;
2837
3145
  }
2838
3146
  .dshcb-button:hover:not(:disabled) {
@@ -2843,8 +3151,10 @@ ${active} :is([data-slot="settings.section"], [data-slot="settings.header"], [da
2843
3151
  cursor: default;
2844
3152
  }
2845
3153
  .dshcb-select {
2846
- height: 32px;
3154
+ flex: none;
3155
+ width: 180px;
2847
3156
  max-width: 180px;
3157
+ height: 32px;
2848
3158
  padding: 0 6px;
2849
3159
  border: 0.5px solid var(--dsw-alias-border-l1);
2850
3160
  border-radius: var(--dsw-radius-sm);
@@ -2921,8 +3231,13 @@ ${active} :is([data-slot="settings.section"], [data-slot="settings.header"], [da
2921
3231
  outline-offset: 2px;
2922
3232
  }
2923
3233
 
2924
- /* Range slider. */
3234
+ /* Range slider. The track and thumb sizes are variables and the thumb offset
3235
+ * is derived from them; a hand-tuned pixel offset drifts once the app's font
3236
+ * metrics, page zoom or device pixel ratio change (the track's vertical
3237
+ * position depends on the native control's line box, not on this stylesheet). */
2925
3238
  .dshcb-range {
3239
+ --dshcb-range-track-height: 4px;
3240
+ --dshcb-range-thumb-size: 14px;
2926
3241
  appearance: none;
2927
3242
  -webkit-appearance: none;
2928
3243
  flex: none;
@@ -2931,12 +3246,16 @@ ${active} :is([data-slot="settings.section"], [data-slot="settings.header"], [da
2931
3246
  margin: 0;
2932
3247
  background: transparent;
2933
3248
  cursor: pointer;
3249
+ /* Collapse the native line box so the track cannot be pushed off-centre by
3250
+ * the inherited font size. */
3251
+ line-height: 0;
2934
3252
  }
2935
3253
  .dshcb-range:disabled {
2936
3254
  cursor: default;
2937
3255
  }
2938
3256
  .dshcb-range::-webkit-slider-runnable-track {
2939
- height: 4px;
3257
+ box-sizing: border-box;
3258
+ height: var(--dshcb-range-track-height);
2940
3259
  border: 0.5px solid var(--dsw-alias-border-l1);
2941
3260
  border-radius: 999px;
2942
3261
  background: var(--dsw-alias-bg-module-platform);
@@ -2944,9 +3263,9 @@ ${active} :is([data-slot="settings.section"], [data-slot="settings.header"], [da
2944
3263
  .dshcb-range::-webkit-slider-thumb {
2945
3264
  appearance: none;
2946
3265
  -webkit-appearance: none;
2947
- width: 14px;
2948
- height: 14px;
2949
- margin-top: -50.5px;
3266
+ width: var(--dshcb-range-thumb-size);
3267
+ height: var(--dshcb-range-thumb-size);
3268
+ margin-top: calc((var(--dshcb-range-track-height) - var(--dshcb-range-thumb-size)) / 2);
2950
3269
  border: 0;
2951
3270
  border-radius: 50%;
2952
3271
  background: var(--dsw-alias-brand-primary);
@@ -2955,14 +3274,15 @@ ${active} :is([data-slot="settings.section"], [data-slot="settings.header"], [da
2955
3274
  background: var(--dsw-alias-state-idle-primary, #a2a4a6);
2956
3275
  }
2957
3276
  .dshcb-range::-moz-range-track {
2958
- height: 4px;
3277
+ box-sizing: border-box;
3278
+ height: var(--dshcb-range-track-height);
2959
3279
  border: 0.5px solid var(--dsw-alias-border-l1);
2960
3280
  border-radius: 999px;
2961
3281
  background: var(--dsw-alias-bg-module-platform);
2962
3282
  }
2963
3283
  .dshcb-range::-moz-range-thumb {
2964
- width: 14px;
2965
- height: 14px;
3284
+ width: var(--dshcb-range-thumb-size);
3285
+ height: var(--dshcb-range-thumb-size);
2966
3286
  border: 0;
2967
3287
  border-radius: 50%;
2968
3288
  background: var(--dsw-alias-brand-primary);
@@ -2975,6 +3295,21 @@ ${active} :is([data-slot="settings.section"], [data-slot="settings.header"], [da
2975
3295
  text-align: right;
2976
3296
  }
2977
3297
 
3298
+ /* Narrow settings panels: stack the control module under the copy instead of
3299
+ * letting the two columns fight for room. The module keeps its own layout, so
3300
+ * nothing moves between devices — only its position changes. */
3301
+ @media (max-width: 640px) {
3302
+ .dshcb-row {
3303
+ grid-template-columns: minmax(0, 1fr);
3304
+ }
3305
+ .dshcb-control {
3306
+ justify-content: flex-start;
3307
+ }
3308
+ .dshcb-rotation-controls {
3309
+ width: 100%;
3310
+ }
3311
+ }
3312
+
2978
3313
  /* ---- task progress bar --------------------------------------------------- */
2979
3314
 
2980
3315
  /* Mounted into the composer's input dock (a full-width entry above the composer
@@ -3170,8 +3505,26 @@ ${active} :is([data-slot="settings.section"], [data-slot="settings.header"], [da
3170
3505
  };
3171
3506
  if (document.body === null) document.addEventListener("DOMContentLoaded", observe, { once: true });
3172
3507
  else observe();
3508
+ const rootObserver = new MutationObserver(() => {
3509
+ invalidatePainters();
3510
+ });
3511
+ rootObserver.observe(document.documentElement, {
3512
+ attributes: true,
3513
+ attributeFilter: ["data-windows-titlebar", "data-platform"]
3514
+ });
3515
+ const onFocus = () => {
3516
+ invalidatePainters();
3517
+ };
3518
+ const onVisibility = () => {
3519
+ if (!document.hidden) invalidatePainters();
3520
+ };
3521
+ window.addEventListener("focus", onFocus);
3522
+ document.addEventListener("visibilitychange", onVisibility);
3173
3523
  return () => {
3174
3524
  observer.disconnect();
3525
+ rootObserver.disconnect();
3526
+ window.removeEventListener("focus", onFocus);
3527
+ document.removeEventListener("visibilitychange", onVisibility);
3175
3528
  };
3176
3529
  }, "chat-background: DOM, scheme and switch follow-up");
3177
3530
  ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), "chat-background: settings dictionaries");
@@ -117,14 +117,22 @@ composer 包装层 ResizeObserver(纯样式尺寸) ──┘ ├─ a
117
117
  ### 5.0 背景区块、三区遮罩与原生标题栏
118
118
 
119
119
  - 设置页内部有「背景 / UI」两个 pill 标签页;切换是组件内 `useState`,不产生额外路由。
120
+ - 所有设置行都经 `Row` 组件渲染:两列 grid(说明 / 控件模块)。控件组保持自身宽度并右对齐,所以语言切换、字体缩放、不同 DPI 或窗口变窄时只有位置变化,不会互相挤压或错位;滑块尺寸由变量推导,不写死像素偏移。
120
121
  - **背景**标签:大图预览、当前文件名下拉框 + 「打开文件夹」「重新扫描」按钮,下方三个遮罩层滑杆;区块与行之间用默认分隔线。
121
122
  - **UI**标签:背景条、进度条、Token 三组设置各用一张 `1px` 细边框 + 大圆角卡片包住,组内两行之间用默认分隔线。
122
123
  - 右栏 panel 通过 `markRightPanel()` 打标:普通状态由右栏列的 `::after` 画遮罩(随列宽收缩);全屏时**左侧栏保持可见**,只隐藏主窗口列,panel 用 offset/width 变量从左侧栏右缘开始覆盖「主窗口 + 原右栏」并绘制统一遮罩;布局 frame 通过 `markLayoutFrame()` 打标,仅在展开/收起点击的 340ms 窗口内启用 300ms `grid-template-columns` 过渡(与 app 内容动画同步),拖动宽度时不启用过渡。
123
124
  - 三个遮罩层没有开关,只有 0–100 的不透明度:0 全透明、100 完全不透明;侧栏/右栏的表面始终让位于图片,由遮罩决定遮挡程度。
125
+ - **主窗口遮罩按对话轮次绘制**:不再给整个主窗口列铺一层,而是由 `src/turn-masks.js` 读取 `[data-chat-turn]`,把同一轮的用户消息与回复合成一张绝对定位的卡片,
126
+ 宽度 = 阅读列宽 + 两侧各 24px 留白(正好到阅读列宽度手柄的净空,跟随手柄自动变化),轮次之间保留约 20px 壁纸缝;同时把每轮第一个条目的宿主变量 `--dsh-chat-flow-gap` 写成 28px(原生 12px),把轮次之间的**真实布局间距**调成舒适档,关闭背景图或卸载时还原。卡片本身只写 `top/height`,不影响会话布局;卡片层会把宿主的 flow-gap margin 清零,否则整个层会随 flow 间距下移、用户气泡顶边露在遮罩外。几何计算在
127
+ `src/lib/turn-masks.js`,有单元测试覆盖;DOM 线程只在布局变化时重算。
124
128
 
125
129
  - 左侧栏/主窗口/右侧栏由稳定槽位(`[data-slot="sidebar"]`、`[data-slot="main"]`、`[data-slot="rightbar"]`)的父节点打标,
126
130
  各自建立 `position: relative` + `isolation: isolate` 的层叠上下文,再用 `::before`/`::after` 在 `z-index: -1` 画独立遮罩;
127
131
  因此调暗侧栏不会影响正文,反之亦然。
132
+ - app 会把 base surface 画在布局 frame、主窗口列和右侧栏上:Windows 标题栏模式还会给主窗口列铺不透明底色和 16px 圆角。
133
+ 这些表面通过**槽位标记**(`data-dsh-chat-background-main/right/frame`)清成透明,而不是只依赖一次性测量出的 `lift` 标记,
134
+ 所以列被重建、标题栏重新主题化或窗口从遮挡恢复后都不会重新变白。测量标记丢失、`data-windows-titlebar`/`data-platform`
135
+ 变化、窗口 `focus` 或 `visibilitychange` 变可见时会主动重新测量,见 `src/background.js` 的 `invalidatePainters()`。
128
136
  - 窗口控制条:桌面预加载用隐藏 probe 的 `background-color: var(--dsw-specific-sidebar-fill)` 采样原生 titleBarOverlay 颜色;
129
137
  插件只在该 probe 上覆盖为透明(不改 token),并通过改写 body 自定义属性触发预加载的 observer 重新发送,见 `src/background.js`。
130
138
 
@@ -109,6 +109,8 @@ export function createSwitch({ key, onChange }) { ... }
109
109
  - 选择器按「背景 → 侧栏 → composer → 设置 → 控件 → 进度条 → token 行」分组,组间用 `/* ---- 组名 ---- */` 分隔。
110
110
  - 不给宿主元素打标时优先用稳定的 `data-slot` / `data-*` 钩子;确实没有稳定钩子时,才允许用 CSS 模块名后缀选择器(如 `[class*="sidebarCol"]`),并在注释里写明这是升级敏感点。
111
111
  - 规则若依赖某个层叠/包含块不变量,必须写注释解释原因。
112
+ - 设置行统一是两列 grid 模块:左侧说明、右侧控件整体成块(`flex: none`、固定槽位、右对齐),控件尺寸不随语言、字体缩放、DPI 或窗口宽度漂移;只有窄屏(≤640px)才把控件堆到说明下方。
113
+ - 原生控件的尺寸不要用手调像素偏移去对齐(如滑块的 `margin-top: -50.5px`),用尺寸变量推导(`calc((track - thumb) / 2)`),否则在别的缩放/DPI 下会漂移。
112
114
 
113
115
  ## 5. JSON / YAML
114
116
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  ```bash
8
8
  npm run verify # 格式 + 构建一致性 + 静态检查 + 单元测试
9
- npm run test:e2e # 9 个端到端套件(需要本机 DSH)
9
+ npm run test:e2e # 11 个端到端套件(需要本机 DSH)
10
10
  npm pack --dry-run # 确认 tarball 内容
11
11
  ```
12
12
 
@@ -25,12 +25,12 @@ npm pack --dry-run # 确认 tarball 内容
25
25
  - 删除旧 tag(如 `git tag -d v1.2.0`),只保留要发布的版本;
26
26
  - 复查 `git log --oneline` 与 `git ls-files`,确认没有个人文件后再推送。
27
27
 
28
- 当前仓库已按此方式重建为单提交的 1.0.0。
28
+ 当前仓库已按此方式重建为单提交的 1.0.0 起点,当前发布版本 1.1.0。
29
29
 
30
30
  ### 2.2 首次创建仓库
31
31
 
32
32
  1. 在 GitHub 新建公开仓库 `dsh-chat-background`(默认分支 `main`);
33
- 2. 推送代码与 `v1.0.0` 标签;
33
+ 2. 推送代码与 `v1.1.0` 标签;
34
34
  3. 仓库 **About** 填写简介:
35
35
  - 描述:`给 DeepSeek Harness 桌面端换背景:背景图/轮换分组/三栏遮罩/输入栏背景条/任务进度条/Token 用量`;
36
36
  - 网站:留空或填仓库地址;
@@ -49,26 +49,30 @@ npm pack --dry-run # 确认 tarball 内容
49
49
  生成 npm tarball 并创建 GitHub Release。发布后在 Release 中附上主要更新内容即可。
50
50
 
51
51
  ```bash
52
- git tag v1.0.0
52
+ git tag v1.1.0
53
53
  git push origin main --tags
54
54
  ```
55
55
 
56
- ## 3. npm 发布(可选)
56
+ ## 3. npm 发布
57
57
 
58
- 插件可以只通过 GitHub 安装。若希望 `dsh plugin add dsh-chat-background` 直接可用,再发布 npm:
58
+ `1.1.0` 已发布到 npm,`dsh plugin --profile desktop add dsh-chat-background` 可直接安装。
59
+ 发新版本时:
59
60
 
60
61
  ```bash
61
- npm login
62
- npm publish --access public
62
+ npm whoami # 确认是维护者账号 lemonjam
63
+ npm publish --access public # 需要带 bypass 2FA 的 granular token
63
64
  ```
64
65
 
65
66
  验证:
66
67
 
67
68
  ```bash
68
- npm view dsh-chat-background version
69
- dsh plugin --profile desktop add dsh-chat-background@1.0.0
69
+ npm view dsh-chat-background version # 应等于当前版本号
70
+ dsh plugin --profile desktop add dsh-chat-background@1.1.0
70
71
  ```
71
72
 
73
+ > npm 页面上的 README 是发布时 tarball 的快照,发布后再改 README 不会回写 npm 页面,
74
+ > 下次发版时才会同步;以 GitHub 仓库的 README 为准。
75
+
72
76
  ## 4. 提交 DSH 插件市场
73
77
 
74
78
  DSH 0.2.x 的插件市场会扫描 GitHub 上的公开 DSH 插件;收录要求:
package/docs/TESTING.md CHANGED
@@ -59,17 +59,19 @@ npm run test:e2e -- --keep # 保留临时 DSH_HOME 与产物,便于
59
59
 
60
60
  ### 2.4 套件一览
61
61
 
62
- | 套件 | 覆盖 |
63
- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
64
- | `01-smoke` | 背景应用、样式表/测量规则安装、背景条覆盖用量行且与输入卡片等宽、进度条空闲隐藏、Token 行位置、设置页标签页、背景区块、UI 分组卡片与控件类型、行 `aria-labelledby/describedby` |
65
- | `02-controls` | 3 个开关、3 个滑杆、2 个调色盘、深色主题默认色、语言切换(经设置页语言控件) |
66
- | `03-robustness` | 稳定钩子优先(英文 pill + `display: contents` 包装层)、零布局影响与 fixed 探针、纯样式尺寸变化跟随、移除/重建用量行、连续开关不重复、窗口 resize |
67
- | `04-host-routes` | `/list`、图片 200/ETag/304、路径穿越 404、`/folder` 的 405/403、token 汇总自洽 |
68
- | `05-rightbar` | 右栏收起/展开的临时网格过渡(中间帧宽度)、拖动宽度无过渡、全屏 panel 覆盖窗口/统一遮罩/主窗口隐藏、退出全屏恢复 |
69
- | `08-security` | `/health`、目录脱敏、响应头、SVG CSP、真实导航到 SVG 后脚本不执行 |
70
- | `06-image-zoom` | 固定层图片滚轮缩放/双击还原;设置弹窗内图片不缩放;右栏图片预览的普通滚轮转换为 Ctrl+滚轮 |
71
- | `07-rotation` | 轮换分组内切换、过渡层出现、三行设置顺序/控件值/无小字、分组 5 行滚动、仅勾选框选择、缩略图、选择不重排、拖动排序动画、切换固定后停止轮换 |
72
- | `07b-animated-background` | 动图文件被列为背景并由已加载的固定 IMG 层绘制,根元素不再绘制背景图 |
62
+ | 套件 | 覆盖 |
63
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
64
+ | `01-smoke` | 背景应用、样式表/测量规则安装、背景条覆盖用量行且与输入卡片等宽、进度条空闲隐藏、Token 行位置、设置页标签页、背景区块、UI 分组卡片与控件类型、行 `aria-labelledby/describedby` |
65
+ | `02-controls` | 3 个开关、3 个滑杆、2 个调色盘、深色主题默认色、语言切换(经设置页语言控件) |
66
+ | `03-robustness` | 稳定钩子优先(英文 pill + `display: contents` 包装层)、零布局影响与 fixed 探针、纯样式尺寸变化跟随、移除/重建用量行、连续开关不重复、窗口 resize |
67
+ | `04-host-routes` | `/list`、图片 200/ETag/304、路径穿越 404、`/folder` 的 405/403、token 汇总自洽 |
68
+ | `05-rightbar` | 右栏收起/展开的临时网格过渡(中间帧宽度)、拖动宽度无过渡、全屏 panel 覆盖窗口/统一遮罩/主窗口隐藏、退出全屏恢复 |
69
+ | `08-security` | `/health`、目录脱敏、响应头、SVG CSP、真实导航到 SVG 后脚本不执行 |
70
+ | `06-image-zoom` | 固定层图片滚轮缩放/双击还原;设置弹窗内图片不缩放;右栏图片预览的普通滚轮转换为 Ctrl+滚轮 |
71
+ | `07-rotation` | 轮换分组内切换、过渡层出现、三行设置顺序/控件值/无小字、分组 5 行滚动、仅勾选框选择、缩略图、选择不重排、拖动排序动画、切换固定后停止轮换 |
72
+ | `07b-animated-background` | 动图文件被列为背景并由已加载的固定 IMG 层绘制,根元素不再绘制背景图 |
73
+ | `10-turn-masks` | 每个对话一轮一张遮罩卡(宽度=阅读列宽+两侧留白)、舒适档 28px 轮次布局间距(写回宿主 `--dsh-chat-flow-gap`)、卡片间约 14px 壁纸缝(上边额外 6px 出血,保证用户气泡顶边被盖住;卡片层 margin 清零不受宿主 flow gap 影响)、用户消息与回复同卡、主窗口不再整列铺遮罩、移除会话后卡片/列标记/间距一起还原 |
74
+ | `09-windows-titlebar` | Windows 标题栏标记给主窗口列铺不透明底色后,靠槽位标记而不是一次性测量结果保持透明;丢失测量标记后仍透明;focus/visibility 重新测量;移除标记后图片仍在 |
73
75
 
74
76
  ### 2.5 新增断言
75
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-chat-background",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Chat background image, sidebar veil, composer strip, task progress bar and all-session token total for the DeepSeek Harness (DSH) Web GUI.",
5
5
  "meta": {
6
6
  "title": "聊天背景",