dsh-width-tiers 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,29 +4,42 @@
4
4
 
5
5
  A client plugin for the **DeepSeek Harness Web GUI** that adds **chat content
6
6
  width tiers** with a floating picker button (bottom-right, above the
7
- composer): 标准 / 中等 / 宽 / 超宽 / 全宽 (standard / medium / wide / ultra /
8
- full).
7
+ composer): 标准 / 中等 / 宽 / 超宽 / 全宽 / 自定义 (standard / medium / wide /
8
+ ultra / full / custom).
9
9
 
10
10
  ## Why
11
11
 
12
- The DSH Web app hard-codes a readable content width on the conversation root
13
- (`--dsh-chat-content-width: 748px`). That caps the message column — and every
14
- table inside it — at **748px** no matter how wide your window or panels are,
15
- so wide tables always need horizontal scrolling.
12
+ The DSH Web app caps the message column — and every table inside it — at
13
+ `--dsh-chat-content-width` (0.1.7–0.2.0 default `clamp(680px, column × 0.64,
14
+ 920px)`), no matter how wide your window is, so wide tables always need
15
+ horizontal scrolling.
16
16
 
17
- This plugin overrides that variable per tier:
17
+ This plugin sets the width per tier through the app's **native width axis**
18
+ (`--dsh-chat-user-width` + the app's `dsh.conversation.contentWidth`
19
+ preference), letting the app clamp every value to "column − 176px handle
20
+ budget" — so the built-in drag handles keep working:
18
21
 
19
22
  | Tier | Content width | Sidebar |
20
23
  |---|---|---|
21
- | standard 标准 | 748px (app default) | untouched |
22
- | medium 中等 | 960px | untouched |
24
+ | standard 标准 | app default | untouched |
25
+ | medium 中等 | 1120px | untouched |
23
26
  | wide 宽 | 1280px | untouched |
24
27
  | ultra 超宽 | 1400px | untouched |
25
- | full 全宽 | 100% (fills the window) | collapsed to the icon rail |
28
+ | full 全宽 | column minus the handle budget (fills the available width) | collapsed to the icon rail |
29
+ | custom 自定义 | your dragged width, re-selectable from the menu | untouched |
30
+
31
+ The menu and the button title always show the width a tier resolves to **at
32
+ the current column**, so on narrow windows a clamped tier reads its real
33
+ value (e.g. `宽 668px`) instead of its nominal one.
34
+
35
+ On narrow windows wider tiers are clamped to the available column, so nothing
36
+ overflows under the sidebar.
26
37
 
27
38
  The choice persists in `localStorage` and is restored on the next visit; the
28
39
  menu highlights the current tier, and the button's five level-bars show it at
29
- a glance.
40
+ a glance (all lit for custom). Dragging one of the app's native edge handles
41
+ switches the picker to the custom tier — the plugin stands aside for native
42
+ drags.
30
43
 
31
44
  ## Install
32
45
 
@@ -53,8 +66,9 @@ Restart `dsh web` (the plugin loads at boot) and hard-refresh the page
53
66
  - `full` collapses the sidebar to the icon rail through the app's own toggle
54
67
  — it is never force-hidden or locked: click a rail icon any time to expand
55
68
  it, and it stays expanded.
56
- - Non-standard tiers also close the details panel (re-closed automatically if
57
- reopened); `standard` leaves everything to the app.
69
+ - The right panel (rightbar) is never touched: its track and panel are fully
70
+ owned by the app, and the conversation yields space to it on its own.
71
+ `standard` leaves the layout entirely to the app.
58
72
 
59
73
  ## Language
60
74
 
@@ -81,10 +95,10 @@ language, and Chinese is the last resort. No plugin-side setting needed.
81
95
  ## Limitations
82
96
 
83
97
  - Content width cannot exceed the window: on a narrow window, wider tiers are
84
- capped by the available column. Use `full` (which also collapses the
85
- sidebar) for the maximum.
98
+ capped by the available column (the menu shows the capped value). Use
99
+ `full` (which also collapses the sidebar) for the maximum.
86
100
  - Requires the DSH Web client plugin system (dsh ≥ 0.1.0-rc.x with a `web`
87
- profile).
101
+ profile). Verified on dsh 0.1.7-alpha.1 and 0.2.0-rc.2.
88
102
 
89
103
  ## Uninstall
90
104
 
package/README.zh.md CHANGED
@@ -3,27 +3,36 @@
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
4
4
 
5
5
  DeepSeek Harness Web GUI 的客户端插件:给对话区加**内容宽度档位选择器**
6
- (右下角圆形按钮,输入框上方),共 5 档:
7
- 标准 / 中等 / 宽 / 超宽 / 全宽。
6
+ (右下角圆形按钮,输入框上方),共 6 档:
7
+ 标准 / 中等 / 宽 / 超宽 / 全宽 / 自定义。
8
8
 
9
9
  ## 为什么需要它
10
10
 
11
- DSH Web 在会话根元素上硬编码了一个阅读宽度
12
- (`--dsh-chat-content-width: 748px`),消息列——包括里面的所有表格——最大
13
- 只有 **748px**,不管窗口和面板多宽都不变,宽表格只能横向滚动。
11
+ DSH Web 把消息列——包括里面的所有表格——的宽度限制在
12
+ `--dsh-chat-content-width`(0.1.7–0.2.0 默认
13
+ `clamp(680px, 列宽 × 0.64, 920px)`),不管窗口多宽都不变,宽表格只能横向滚动。
14
14
 
15
- 本插件按档位覆盖这个变量:
15
+ 本插件通过应用自身的**原生宽度通道**(`--dsh-chat-user-width` +
16
+ `dsh.conversation.contentWidth` 偏好)按档位设定宽度,由应用自身钳制到
17
+ 「列宽 − 176px 手柄预留」,因此自带的原生拖拽手柄始终可用:
16
18
 
17
19
  | 档位 | 内容宽度 | 侧边栏 |
18
20
  |---|---|---|
19
- | 标准 | 748px(应用默认) | 不动 |
20
- | 中等 | 960px | 不动 |
21
+ | 标准 | 应用默认 | 不动 |
22
+ | 中等 | 1120px | 不动 |
21
23
  | 宽 | 1280px | 不动 |
22
24
  | 超宽 | 1400px | 不动 |
23
- | 全宽 | 100%(占满窗口) | 收起为图标轨道 |
25
+ | 全宽 | 列宽减手柄预留(占满可用宽度) | 收起为图标轨道 |
26
+ | 自定义 | 拖拽原生手柄得出,可在菜单回切 | 不动 |
27
+
28
+ 菜单与按钮标题显示的是**当前列宽下实际生效的 px**:窄窗口下被钳制的档位会
29
+ 显示真实值(如「宽 668px」),而不是标称值。
30
+
31
+ 窄窗口下超宽档位会自动被钳到可用宽度,不会溢出被侧栏覆盖。
24
32
 
25
33
  选择会记住(localStorage),下次打开自动恢复;菜单里当前档位高亮,按钮上
26
- 的五条横条图标也直观显示当前档位。
34
+ 的五条横条图标也直观显示当前档位(自定义档全亮)。拖动应用自带的
35
+ 对话区边缘手柄会自动切到「自定义」档,插件让位于原生拖拽。
27
36
 
28
37
  ## 安装
29
38
 
@@ -49,7 +58,8 @@ bundle patch 会自动插入 `dsh-width-tiers` 加载项——无需手动改
49
58
  - 点圆形按钮 → 选择档位,当前档位高亮。
50
59
  - 「全宽」通过应用自己的收起按钮把侧边栏收成图标轨道——**不会被锁死**:
51
60
  随时点轨道图标展开,展开后保持展开。
52
- - 非标准档会关闭详情面板(被重新打开会自动再关);标准档完全交给应用。
61
+ - 插件**不碰右侧面板**:右栏的轨道与面板完全由应用管理,展开时对话内容
62
+ 自适应让位;「标准」档完全交给应用。
53
63
 
54
64
  ## 多语言
55
65
 
@@ -70,9 +80,10 @@ bundle patch 会自动插入 `dsh-width-tiers` 加载项——无需手动改
70
80
 
71
81
  ## 已知限制
72
82
 
73
- - 内容宽度不可能超过窗口:窗口窄时,高档位会被可用列宽截断;想要最大
74
- 空间请用「全宽」(同时收起侧边栏)。
83
+ - 内容宽度不可能超过窗口:窗口窄时,高档位会被可用列宽截断(菜单会显示
84
+ 截断后的实际值);想要最大空间请用「全宽」(同时收起侧边栏)。
75
85
  - 依赖 DSH Web 客户端插件体系(带 web profile 的 dsh ≥ 0.1.0-rc.x)。
86
+ 已在 dsh 0.1.7-alpha.1 与 0.2.0-rc.2 上验证。
76
87
 
77
88
  ## 卸载
78
89
 
package/lib/client.js CHANGED
@@ -9,30 +9,40 @@ window.__ModuleLoader__.load({
9
9
  /**
10
10
  * dsh-width-tiers — chat content width tiers for the DSH Web GUI.
11
11
  *
12
- * The app hard-codes a readable content width
13
- * (`--dsh-chat-content-width: 748px` on the conversation root), which
14
- * caps the message column — and every table inside it — at 748px no
15
- * matter how wide the panels are. This plugin overrides that variable
16
- * per tier and adds a floating picker button:
12
+ * The app derives the transcript width from
13
+ * `--dsh-chat-content-width`, which 0.1.7 and 0.2.0 resolve as
14
+ * `var(--dsh-chat-user-width, clamp(680px, column * .64, 920px))` on
15
+ * the conversation body. 0.1.7 also added native drag handles
16
+ * (ConversationWidthControls) that publish
17
+ * `--dsh-conversation-column-width` / `--dsh-chat-user-width` on the
18
+ * body's PARENT and clamp every value to the column minus a 176px
19
+ * handle budget. Forcing `--dsh-chat-content-width` with `!important`
20
+ * (the 1.0.5 approach) muted those handles and let over-wide
21
+ * transcripts overflow under the sidebar/rightbar columns, so this
22
+ * version drives the app's own width axis instead — see
23
+ * `applyContentWidth`. Other invariants:
17
24
  *
18
- * 标准 standard 748px (app default, no override)
19
- * 中等 medium 960px (sidebar untouched)
25
+ * 标准 standard app default (no override)
26
+ * 中等 medium 1120px (sidebar untouched)
20
27
  * 宽 wide 1280px (sidebar untouched)
21
28
  * 超宽 ultra 1400px (sidebar untouched)
22
- * 全宽 full 100% (sidebar collapsed to the icon rail)
29
+ * 全宽 full column max (sidebar collapsed to the icon rail)
30
+ * 自定义 custom user-dragged width (plugin hands width over to
31
+ * the app's native handles; the dragged value stays
32
+ * re-selectable from the menu)
33
+ *
34
+ * The menu and title always show the px a tier resolves to at the
35
+ * CURRENT column (see `tierValueText`), so a clamped tier never
36
+ * claims its nominal width on a narrow window.
23
37
  *
24
- * Design notes:
25
- * - The element that defines `--dsh-chat-content-width` is located at
26
- * runtime (first element whose computed value is non-empty) and the
27
- * tier is applied as an INLINE override on it — no hashed class
28
- * names, survives bundle upgrades. Re-detected if it remounts.
29
38
  * - Panels are driven through the layout service (ctx.layout), never
30
39
  * by simulating clicks.
31
40
  * - The sidebar is only ever collapsed by the `full` tier, through the
32
41
  * app's own toggle — never force-hidden, never locked. Going back to
33
42
  * `standard` restores it only if this plugin collapsed it.
34
- * - Non-standard tiers close the right panel (rightbar, self-heal
35
- * while active); `standard` leaves everything to the app.
43
+ * - The rightbar is never touched: on 0.1.7+ its panel lives in its
44
+ * own seat, so closing the track would leave it overlaying the
45
+ * conversation; the app's width axis yields space on its own.
36
46
  * - The chosen tier persists in localStorage and is restored on the
37
47
  * next visit.
38
48
  * - All copy is localized through the app's locale service: tier
@@ -41,6 +51,20 @@ window.__ModuleLoader__.load({
41
51
  */
42
52
  const KEY = "dsh.widthTier";
43
53
  const OLD_KEY = "dsh.wide";
54
+ /** Last user-dragged width (px), backing the `custom` tier. */
55
+ const CUSTOM_KEY = "dsh.widthTierCustom";
56
+ /** The app's own transcript width preference (ConversationWidthControls). */
57
+ const APP_PREF_KEY = "dsh.conversation.contentWidth";
58
+ /* Mirrors ConversationWidthControls in 0.1.7-alpha.1: clamp floor and
59
+ * the horizontal budget both width handles + safe edges reserve. */
60
+ const CONTENT_MIN_PX = 640;
61
+ const CONTENT_EDGE_BUDGET_PX = 176;
62
+ /* The app's default transcript width (resolveContentWidth's
63
+ * no-preference branch): clamp(680px, column × 0.64, 920px). Used to
64
+ * show what `standard` actually resolves to at the current column. */
65
+ const DEFAULT_MIN_PX = 680;
66
+ const DEFAULT_MAX_PX = 920;
67
+ const DEFAULT_COLUMN_RATIO = 0.64;
44
68
  const READY_TIMEOUT_MS = 30000;
45
69
  /* Self-heal cadence: DOM-driven (MutationObserver scheduling on the
46
70
  * next animation frame — before paint, so the width never flashes
@@ -62,6 +86,7 @@ window.__ModuleLoader__.load({
62
86
  "tier.wide": "宽",
63
87
  "tier.ultra": "超宽",
64
88
  "tier.full": "全宽",
89
+ "tier.custom": "自定义",
65
90
  "button.aria": "对话区宽度档位:{label}({value}),点击选择档位",
66
91
  "button.title.summary": "对话区宽度档位:{label}({value})",
67
92
  "button.title.hint": "点击选择:{list}"
@@ -73,18 +98,24 @@ window.__ModuleLoader__.load({
73
98
  "tier.wide": "Wide",
74
99
  "tier.ultra": "Ultra",
75
100
  "tier.full": "Full",
101
+ "tier.custom": "Custom",
76
102
  "button.aria": "Chat width tier: {label} ({value}). Click to choose a tier.",
77
103
  "button.title.summary": "Chat width tier: {label} ({value})",
78
104
  "button.title.hint": "Click to choose: {list}"
79
105
  };
80
106
  //#endregion
81
107
 
108
+ /* Tier ids and label keys only — every displayed width is resolved at
109
+ * render time from the live column (see tierValueText), so the menu
110
+ * never claims a width the app would not actually apply. */
82
111
  const TIERS = [
83
- { id: "standard", key: "tier.standard", value: "748px" },
84
- { id: "medium", key: "tier.medium", value: "960px" },
85
- { id: "wide", key: "tier.wide", value: "1280px" },
86
- { id: "ultra", key: "tier.ultra", value: "1400px" },
87
- { id: "full", key: "tier.full", value: "100%" }
112
+ { id: "standard", key: "tier.standard" },
113
+ { id: "medium", key: "tier.medium" },
114
+ { id: "wide", key: "tier.wide" },
115
+ { id: "ultra", key: "tier.ultra" },
116
+ { id: "full", key: "tier.full" },
117
+ /* Reached only by dragging a native width handle. */
118
+ { id: "custom", key: "tier.custom" }
88
119
  ];
89
120
 
90
121
  function tierOf(id) {
@@ -110,6 +141,7 @@ window.__ModuleLoader__.load({
110
141
  "#dsh-width-menu[hidden]{display:none}",
111
142
  "#dsh-width-menu button{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:18px;text-align:left;cursor:pointer}",
112
143
  "#dsh-width-menu button:hover{background:var(--dsw-alias-interactive-bg-hover)}",
144
+ "#dsh-width-menu button.disabled{opacity:.45;cursor:default;pointer-events:none}",
113
145
  "#dsh-width-menu button.selected{background:var(--dsw-alias-interactive-bg-hover-solid);color:var(--dsw-alias-state-business-primary)}",
114
146
  "#dsh-width-menu .dshwm-value{color:var(--dsw-alias-label-tertiary);font-size:12px}",
115
147
  "#dsh-width-menu button.selected .dshwm-value{color:var(--dsw-alias-state-business-primary)}"
@@ -165,12 +197,15 @@ window.__ModuleLoader__.load({
165
197
  return document.querySelector("[data-sidebar-collapsed]") !== null;
166
198
  }
167
199
 
168
- /* 0.1.5-rc.2 replaced the details panel with the rightbar and
169
- * dropped `data-details-collapsed` entirely; closeRightbar is
170
- * idempotent, so it is called unconditionally. */
171
- function closeRightbar() {
172
- layout.closeRightbar?.();
173
- }
200
+ /* NOTE: the 1.0.x "close the right panel on non-standard tiers"
201
+ * self-heal is gone on purpose. On 0.1.7 the rightbar panel
202
+ * lives in its own seat: closeRightbar() only retracts the
203
+ * grid TRACK (rightbarTrack=false) while the seat stays
204
+ * expanded, so the panel keeps rendering as an OVERLAY pinned
205
+ * to the frame's right edge — hanging over and covering the
206
+ * conversation (对话窗口被边侧栏覆盖). The plugin must never
207
+ * touch the rightbar; the app's own width axis already yields
208
+ * space to the panel. */
174
209
 
175
210
  function collapseSidebar() {
176
211
  if (!isSidebarCollapsed()) layout.toggleSidebar();
@@ -180,10 +215,6 @@ window.__ModuleLoader__.load({
180
215
  if (isSidebarCollapsed()) layout.toggleSidebar();
181
216
  }
182
217
 
183
- function closeDetails() {
184
- closeRightbar();
185
- }
186
-
187
218
  /* Locate the element that defines --dsh-chat-content-width at
188
219
  * runtime (first match wins; only one element declares it).
189
220
  * Cached; the app remounts the chat tree on conversation switch,
@@ -242,22 +273,88 @@ window.__ModuleLoader__.load({
242
273
  return scanWidthRoot();
243
274
  }
244
275
 
245
- function tierWidth(tier) {
276
+ /* ---- 0.1.7 native width axis ----
277
+ * The conversation body's parent is the element the app's
278
+ * ConversationWidthControls publishes
279
+ * `--dsh-conversation-column-width` / `--dsh-chat-user-width`
280
+ * on. Writing there (never with `!important`) keeps native
281
+ * drag handles in charge: during a drag the app rewrites the
282
+ * same inline property every frame and wins the cascade. */
283
+ function widthTarget(root) {
284
+ return (root && root.parentElement) || root;
285
+ }
286
+
287
+ function readStoredNumber(key) {
288
+ try {
289
+ const v = Number(window.localStorage.getItem(key));
290
+ return Number.isFinite(v) && v > 0 ? v : null;
291
+ } catch (e) {
292
+ return null;
293
+ }
294
+ }
295
+
296
+ /* Resolve a tier to the px the app would clamp it to
297
+ * (resolveContentWidth: max = column - handle budget), or
298
+ * null when the tier leaves the width to the app. */
299
+ function tierPx(tier, root) {
300
+ const cap = Math.max(CONTENT_MIN_PX, root.offsetWidth - CONTENT_EDGE_BUDGET_PX);
246
301
  switch (tier) {
247
- case "medium": return "960px";
248
- case "wide": return "1280px";
249
- case "ultra": return "1400px";
250
- case "full": return "100%";
251
- default: return null; /* standard: remove the override */
302
+ case "medium": return Math.min(1120, cap);
303
+ case "wide": return Math.min(1280, cap);
304
+ case "ultra": return Math.min(1400, cap);
305
+ case "full": return cap;
306
+ case "custom": {
307
+ const dragged = readStoredNumber(CUSTOM_KEY);
308
+ return dragged === null ? null : Math.min(dragged, cap);
309
+ }
310
+ default: return null; /* standard: app default */
252
311
  }
253
312
  }
254
313
 
314
+ /* The px a tier resolves to right now, including the app's own
315
+ * default formula for `standard`; null when the tier has no
316
+ * width of its own yet (custom before the first drag). */
317
+ function resolvedWidthPx(tier, root) {
318
+ if (tier === "standard") {
319
+ const column = root.offsetWidth;
320
+ return Math.round(Math.max(DEFAULT_MIN_PX, Math.min(column * DEFAULT_COLUMN_RATIO, DEFAULT_MAX_PX)));
321
+ }
322
+ return tierPx(tier, root);
323
+ }
324
+
255
325
  function applyContentWidth(tier) {
256
326
  const root = findWidthRoot();
257
327
  if (root === null) return;
258
- const w = tierWidth(tier);
259
- if (w === null) root.style.removeProperty("--dsh-chat-content-width");
260
- else root.style.setProperty("--dsh-chat-content-width", w, "important");
328
+ const target = widthTarget(root);
329
+ /* custom before the first drag has no width of its own —
330
+ * leave whatever the app last published in place. */
331
+ if (tier === "custom" && readStoredNumber(CUSTOM_KEY) === null) return;
332
+ const px = tierPx(tier, root);
333
+ if (px === null) {
334
+ if (target.style.getPropertyValue("--dsh-chat-user-width") !== "") {
335
+ target.style.removeProperty("--dsh-chat-user-width");
336
+ try {
337
+ window.localStorage.removeItem(APP_PREF_KEY);
338
+ } catch (e) {
339
+ /* ignore */
340
+ }
341
+ }
342
+ return;
343
+ }
344
+ /* Mirror into the app's preference so window resizes and
345
+ * conversation remounts re-clamp through the app's own
346
+ * publish cycle instead of relying on plugin healing. */
347
+ const next = String(Math.round(px));
348
+ if (target.style.getPropertyValue("--dsh-chat-user-width") !== next + "px") {
349
+ target.style.setProperty("--dsh-chat-user-width", next + "px");
350
+ }
351
+ try {
352
+ if (window.localStorage.getItem(APP_PREF_KEY) !== next) {
353
+ window.localStorage.setItem(APP_PREF_KEY, next);
354
+ }
355
+ } catch (e) {
356
+ /* ignore */
357
+ }
261
358
  }
262
359
 
263
360
  /* true while the CURRENT tier is the one that collapsed the sidebar */
@@ -284,7 +381,6 @@ window.__ModuleLoader__.load({
284
381
  } else {
285
382
  collapsedByTier = false;
286
383
  }
287
- if (tier !== "standard") closeDetails();
288
384
  syncButton();
289
385
  syncMenu();
290
386
  pulse();
@@ -311,17 +407,32 @@ window.__ModuleLoader__.load({
311
407
  return `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">${bars}</svg>`;
312
408
  }
313
409
 
410
+ /* Display value for a tier: the px it resolves to at the live
411
+ * column (what the app would actually apply), so narrow windows
412
+ * show the clamped value instead of the nominal one. Falls back
413
+ * to the recorded drag when the width root is not mounted. */
414
+ function tierValueText(tierId) {
415
+ const root = findWidthRoot();
416
+ if (root !== null) {
417
+ const px = resolvedWidthPx(tierId, root);
418
+ if (px !== null) return `${px}px`;
419
+ }
420
+ const dragged = readStoredNumber(CUSTOM_KEY);
421
+ return dragged === null ? "—" : `${Math.round(dragged)}px`;
422
+ }
423
+
314
424
  function syncButton() {
315
425
  const tier = currentTier();
316
426
  const def = tierOf(tier);
317
427
  const label = t(def.key);
318
- const summary = t("button.title.summary", { label, value: def.value });
428
+ const value = tierValueText(tier);
429
+ const summary = t("button.title.summary", { label, value });
319
430
  const hint = t("button.title.hint", {
320
- list: TIERS.map((tierDef) => `${t(tierDef.key)} ${tierDef.value}`).join(" / ")
431
+ list: TIERS.map((tierDef) => `${t(tierDef.key)} ${tierValueText(tierDef.id)}`).join(" / ")
321
432
  });
322
433
  btn.innerHTML = barIcon(tier);
323
434
  btn.classList.toggle("active", tier !== "standard");
324
- btn.setAttribute("aria-label", t("button.aria", { label, value: def.value }));
435
+ btn.setAttribute("aria-label", t("button.aria", { label, value }));
325
436
  btn.title = `${summary}\n${hint}`;
326
437
  }
327
438
 
@@ -336,24 +447,39 @@ window.__ModuleLoader__.load({
336
447
  item.type = "button";
337
448
  item.setAttribute("role", "menuitemradio");
338
449
  item.setAttribute("data-tier", def.id);
339
- item.innerHTML = `<span class="dshwm-label">${t(def.key)}</span><span class="dshwm-value">${def.value}</span>`;
450
+ item.innerHTML = `<span class="dshwm-label">${t(def.key)}</span><span class="dshwm-value">${tierValueText(def.id)}</span>`;
340
451
  item.addEventListener("click", (e) => {
341
452
  if (e && e.stopPropagation) e.stopPropagation();
453
+ if (item.classList.contains("disabled")) return;
342
454
  applyTier(def.id);
343
455
  hideMenu();
344
456
  });
345
457
  menu.appendChild(item);
346
458
  }
347
459
 
460
+ /* Write only on change: syncMenu also runs on window resizes,
461
+ * and untouched text should not churn the DOM (the plugin's own
462
+ * MutationObserver watches childList). */
463
+ function setText(el, text) {
464
+ if (el.textContent !== text) el.textContent = text;
465
+ }
466
+
348
467
  function syncMenu() {
349
468
  const tier = currentTier();
350
469
  const items = menu.querySelectorAll("button[data-tier]");
351
470
  for (let i = 0; i < items.length; i++) {
352
- const on = items[i].getAttribute("data-tier") === tier;
471
+ const id = items[i].getAttribute("data-tier");
472
+ const on = id === tier;
353
473
  items[i].classList.toggle("selected", on);
354
474
  items[i].setAttribute("aria-checked", String(on));
355
475
  const labelEl = items[i].querySelector(".dshwm-label");
356
- if (labelEl) labelEl.textContent = t(tierOf(items[i].getAttribute("data-tier")).key);
476
+ if (labelEl) setText(labelEl, t(tierOf(id).key));
477
+ const valueEl = items[i].querySelector(".dshwm-value");
478
+ if (valueEl) setText(valueEl, tierValueText(id));
479
+ /* custom is inert until the user has dragged once */
480
+ const disabled = id === "custom" && readStoredNumber(CUSTOM_KEY) === null;
481
+ items[i].classList.toggle("disabled", disabled);
482
+ items[i].setAttribute("aria-disabled", String(disabled));
357
483
  }
358
484
  }
359
485
 
@@ -384,6 +510,48 @@ window.__ModuleLoader__.load({
384
510
  document.addEventListener("click", onDocClick);
385
511
  document.addEventListener("keydown", onDocKey);
386
512
 
513
+ /* ---- hand the width over to the app's native drag handles ----
514
+ * Pressing a `[data-width-handle]` means the user is taking the
515
+ * width over: switch to the `custom` tier so healing stops
516
+ * enforcing a preset, and let the app's drag win the cascade
517
+ * (it rewrites the same inline property every frame). The
518
+ * settled width is recorded on release so it stays
519
+ * re-selectable from the menu. */
520
+ const isHandleEvent = (e) =>
521
+ e.target instanceof Element && e.target.closest("[data-width-handle]") !== null;
522
+
523
+ const onHandlePointerDown = (e) => {
524
+ if (!isHandleEvent(e) || currentTier() === "custom") return;
525
+ body().setAttribute("data-dsh-width", "custom");
526
+ try {
527
+ window.localStorage.setItem(KEY, "custom");
528
+ } catch (err) {
529
+ /* ignore */
530
+ }
531
+ collapsedByTier = false;
532
+ syncButton();
533
+ syncMenu();
534
+ };
535
+
536
+ const onHandlePointerUp = (e) => {
537
+ if (!isHandleEvent(e)) return;
538
+ const root = findWidthRoot();
539
+ if (root === null) return;
540
+ const px = Number.parseFloat(
541
+ widthTarget(root).style.getPropertyValue("--dsh-chat-user-width")
542
+ );
543
+ if (!Number.isFinite(px) || px <= 0) return;
544
+ try {
545
+ window.localStorage.setItem(CUSTOM_KEY, String(Math.round(px)));
546
+ } catch (err) {
547
+ /* ignore */
548
+ }
549
+ syncButton();
550
+ syncMenu();
551
+ };
552
+ document.addEventListener("pointerdown", onHandlePointerDown, true);
553
+ document.addEventListener("pointerup", onHandlePointerUp, true);
554
+
387
555
  /* ---- feedback ring so every selection is visibly answered ---- */
388
556
  function pulse() {
389
557
  btn.classList.remove("pulse");
@@ -433,16 +601,19 @@ window.__ModuleLoader__.load({
433
601
  attach();
434
602
  }, READY_TIMEOUT_MS);
435
603
 
436
- /* ---- self-heal: keep the details panel closed for non-standard
437
- * tiers. The sidebar is never managed here — the user may expand
438
- * it any time. Driven by DOM mutations, scheduled on the next
439
- * animation frame (before the browser paints) instead of a fixed
440
- * hot loop: conversation switches remount the chat tree, and the
441
- * width override must land on the fresh root before its first
442
- * paint — otherwise the default width flashes for a frame.
443
- * While the new root has not appeared yet (mid-remount), full
444
- * scans are owned by a backoff retry; mutation-driven heals only
445
- * try the cheap path walk so DOM churn cannot storm. ---- */
604
+ /* ---- self-heal: re-apply the width when the chat tree
605
+ * remounts (conversation switch). Panels are never managed
606
+ * here — the user may expand/collapse either side any time;
607
+ * forcing the rightbar closed only retracts the grid track
608
+ * while the seat keeps rendering as an overlay that covers
609
+ * the conversation. Driven by DOM mutations, scheduled on the
610
+ * next animation frame (before the browser paints) instead of
611
+ * a fixed hot loop: the width must land on the fresh root
612
+ * before its first paint — otherwise the default width
613
+ * flashes for a frame. While the new root has not appeared
614
+ * yet (mid-remount), full scans are owned by a backoff retry;
615
+ * mutation-driven heals only try the cheap path walk so DOM
616
+ * churn cannot storm. ---- */
446
617
  let rootMissing = false;
447
618
  let rootRetryDelay = ROOT_RETRY_MIN_MS;
448
619
  let rootRetryTimer = null;
@@ -479,7 +650,6 @@ window.__ModuleLoader__.load({
479
650
  function heal(fromObserver) {
480
651
  const tier = currentTier();
481
652
  if (tier === "standard") return;
482
- closeDetails();
483
653
  const prev = widthRoot;
484
654
  let root;
485
655
  if (rootMissing) {
@@ -503,6 +673,11 @@ window.__ModuleLoader__.load({
503
673
  rootRetryDelay = ROOT_RETRY_MIN_MS;
504
674
  }
505
675
  if (root !== prev) applyContentWidth(tier);
676
+ /* full tracks the live column: after the sidebar collapse
677
+ * animation settles the column widens, and the width must
678
+ * follow (applyContentWidth is diff-checked, so steady
679
+ * state writes nothing). */
680
+ if (tier === "full") applyContentWidth("full");
506
681
  }
507
682
 
508
683
  function startHealing() {
@@ -518,7 +693,7 @@ window.__ModuleLoader__.load({
518
693
  subtree: true,
519
694
  childList: true,
520
695
  attributes: true,
521
- attributeFilter: ["data-sidebar-collapsed", "data-rightbar-collapsed"]
696
+ attributeFilter: ["data-sidebar-collapsed"]
522
697
  });
523
698
  }
524
699
  /* Low-frequency fallback so self-heal still runs if the
@@ -527,6 +702,22 @@ window.__ModuleLoader__.load({
527
702
  fallbackTimer = setInterval(() => heal(false), FALLBACK_HEAL_MS);
528
703
  }
529
704
 
705
+ /* ---- keep the displayed px honest when the column changes ----
706
+ * Window resizes (and layout handle drags) change what every
707
+ * tier resolves to, so re-render the copy on the next frame.
708
+ * The menu is also re-synced whenever it opens. */
709
+ let resizeFrame = null;
710
+
711
+ const onWindowResize = () => {
712
+ if (resizeFrame !== null) return;
713
+ scheduleFrame(() => {
714
+ resizeFrame = null;
715
+ syncButton();
716
+ syncMenu();
717
+ });
718
+ };
719
+ window.addEventListener("resize", onWindowResize);
720
+
530
721
  /* ---- disposer ---- */
531
722
  return () => {
532
723
  unsubscribeLocale();
@@ -535,13 +726,23 @@ window.__ModuleLoader__.load({
535
726
  clearTimeout(timeout);
536
727
  clearTimeout(rootRetryTimer);
537
728
  if (healFrame !== null) cancelFrame(healFrame);
729
+ if (resizeFrame !== null) cancelFrame(resizeFrame);
730
+ window.removeEventListener("resize", onWindowResize);
538
731
  if (observer !== null) observer.disconnect();
539
732
  document.removeEventListener("click", onDocClick);
540
733
  document.removeEventListener("keydown", onDocKey);
734
+ document.removeEventListener("pointerdown", onHandlePointerDown, true);
735
+ document.removeEventListener("pointerup", onHandlePointerUp, true);
541
736
  if (btn.parentNode) btn.parentNode.removeChild(btn);
542
737
  if (menu.parentNode) menu.parentNode.removeChild(menu);
543
738
  if (styleTag.parentNode) styleTag.parentNode.removeChild(styleTag);
544
- if (widthRoot !== null) widthRoot.style.removeProperty("--dsh-chat-content-width");
739
+ /* Hand the width back to the app default; the app's own
740
+ * preference (which this plugin mirrors) stays so the last
741
+ * width survives a plugin reload — matching native-drag
742
+ * semantics. */
743
+ if (widthRoot !== null) {
744
+ widthTarget(widthRoot).style.removeProperty("--dsh-chat-user-width");
745
+ }
545
746
  body().removeAttribute("data-dsh-width");
546
747
  };
547
748
  }, "dsh-width-tiers: ui");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-width-tiers",
3
- "version": "1.0.5",
4
- "description": "DSH Web client plugin: chat content width tiers (standard / medium / wide / ultra / full) with a floating picker button. Frees wide tables from the app's hard-coded 748px content cap. UI copy follows the app language (zh / en).",
3
+ "version": "1.0.7",
4
+ "description": "DSH Web client plugin: chat content width tiers (standard / medium / wide / ultra / full / custom) with a floating picker button. Drives the app's native width axis on DSH 0.1.7+ (verified on 0.2.0-rc.2), so the built-in drag handles keep working. UI copy follows the app language (zh / en).",
5
5
  "license": "MIT",
6
6
  "author": "aaronlei",
7
7
  "publishConfig": {