dsh-width-tiers 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,11 +10,11 @@ ultra / full / custom).
10
10
  ## Why
11
11
 
12
12
  The DSH Web app caps the message column — and every table inside it — at
13
- `--dsh-chat-content-width` (0.1.7 default `clamp(680px, column × 0.64,
13
+ `--dsh-chat-content-width` (0.1.7–0.2.0 default `clamp(680px, column × 0.64,
14
14
  920px)`), no matter how wide your window is, so wide tables always need
15
15
  horizontal scrolling.
16
16
 
17
- This plugin sets the width per tier through 0.1.7's **native width axis**
17
+ This plugin sets the width per tier through the app's **native width axis**
18
18
  (`--dsh-chat-user-width` + the app's `dsh.conversation.contentWidth`
19
19
  preference), letting the app clamp every value to "column − 176px handle
20
20
  budget" — so the built-in drag handles keep working:
@@ -22,21 +22,35 @@ budget" — so the built-in drag handles keep working:
22
22
  | Tier | Content width | Sidebar |
23
23
  |---|---|---|
24
24
  | standard 标准 | app default | untouched |
25
- | medium 中等 | 960px | untouched |
25
+ | medium 中等 | 1120px | untouched |
26
26
  | wide 宽 | 1280px | untouched |
27
27
  | ultra 超宽 | 1400px | untouched |
28
28
  | full 全宽 | column minus the handle budget (fills the available width) | collapsed to the icon rail |
29
29
  | custom 自定义 | your dragged width, re-selectable from the menu | untouched |
30
30
 
31
+ The menu and the button title show nominal px for the fixed tiers (medium
32
+ 1120px, wide 1280px, ultra 1400px). Standard shows the app default px it
33
+ resolves right now (`clamp(680px, column × 0.64, 920px)`, e.g. 710px); full
34
+ shows the live maximum (`column − 176px`, e.g. 934px); custom shows its
35
+ recorded width or "Not set". The app still clamps the applied width to
36
+ "column − 176px"; on a narrow window a higher tier therefore behaves like
37
+ "full".
38
+
31
39
  On narrow windows wider tiers are clamped to the available column, so nothing
32
40
  overflows under the sidebar.
33
41
 
34
42
  The choice persists in `localStorage` and is restored on the next visit; the
35
43
  menu highlights the current tier, and the button's five level-bars show it at
36
- a glance (all lit for custom). Dragging one of 0.1.7's native edge handles
44
+ a glance (all lit for custom). Dragging one of the app's native edge handles
37
45
  switches the picker to the custom tier — the plugin stands aside for native
38
46
  drags.
39
47
 
48
+ Before the first drag, picking custom does not silently keep a width: a guide
49
+ appears at the conversation's right edge and highlights the width handle.
50
+ While dragging, a bubble shows the live transcript width; releasing records it
51
+ as the custom value. Every later handle drag opens the same highlight + live
52
+ readout, no matter which tier was active.
53
+
40
54
  ## Install
41
55
 
42
56
  ```bash
@@ -62,8 +76,9 @@ Restart `dsh web` (the plugin loads at boot) and hard-refresh the page
62
76
  - `full` collapses the sidebar to the icon rail through the app's own toggle
63
77
  — it is never force-hidden or locked: click a rail icon any time to expand
64
78
  it, and it stays expanded.
65
- - Non-standard tiers also close the details panel (re-closed automatically if
66
- reopened); `standard` leaves everything to the app.
79
+ - The right panel (rightbar) is never touched: its track and panel are fully
80
+ owned by the app, and the conversation yields space to it on its own.
81
+ `standard` leaves the layout entirely to the app.
67
82
 
68
83
  ## Language
69
84
 
@@ -90,10 +105,12 @@ language, and Chinese is the last resort. No plugin-side setting needed.
90
105
  ## Limitations
91
106
 
92
107
  - Content width cannot exceed the window: on a narrow window, wider tiers are
93
- capped by the available column. Use `full` (which also collapses the
94
- sidebar) for the maximum.
108
+ capped by the available column (the picker keeps showing the nominal value;
109
+ the live bubble during a drag shows the applied width). Use `full` (which
110
+ also collapses the sidebar) for the maximum.
95
111
  - Requires the DSH Web client plugin system (dsh ≥ 0.1.0-rc.x with a `web`
96
- profile).
112
+ profile). Verified on dsh 0.1.7-alpha.1 and 0.2.0-rc.2; the 0.2.1-rc.1
113
+ width axis matches by bundle diff.
97
114
 
98
115
  ## Uninstall
99
116
 
package/README.zh.md CHANGED
@@ -9,28 +9,38 @@ DeepSeek Harness Web GUI 的客户端插件:给对话区加**内容宽度档
9
9
  ## 为什么需要它
10
10
 
11
11
  DSH Web 把消息列——包括里面的所有表格——的宽度限制在
12
- `--dsh-chat-content-width`(0.1.7 默认 `clamp(680px, 列宽 × 0.64, 920px)`),
13
- 不管窗口多宽都不变,宽表格只能横向滚动。
12
+ `--dsh-chat-content-width`(0.1.7–0.2.0 默认
13
+ `clamp(680px, 列宽 × 0.64, 920px)`),不管窗口多宽都不变,宽表格只能横向滚动。
14
14
 
15
- 本插件通过 0.1.7 的**原生宽度通道**(`--dsh-chat-user-width` +
15
+ 本插件通过应用自身的**原生宽度通道**(`--dsh-chat-user-width` +
16
16
  `dsh.conversation.contentWidth` 偏好)按档位设定宽度,由应用自身钳制到
17
17
  「列宽 − 176px 手柄预留」,因此自带的原生拖拽手柄始终可用:
18
18
 
19
19
  | 档位 | 内容宽度 | 侧边栏 |
20
20
  |---|---|---|
21
21
  | 标准 | 应用默认 | 不动 |
22
- | 中等 | 960px | 不动 |
22
+ | 中等 | 1120px | 不动 |
23
23
  | 宽 | 1280px | 不动 |
24
24
  | 超宽 | 1400px | 不动 |
25
25
  | 全宽 | 列宽减手柄预留(占满可用宽度) | 收起为图标轨道 |
26
26
  | 自定义 | 拖拽原生手柄得出,可在菜单回切 | 不动 |
27
27
 
28
+ 菜单与按钮标题中:中等 / 宽 / 超宽显示**固定标称值**(1120 / 1280 / 1400px),
29
+ 标准显示应用当前默认值(`clamp(680px, 列宽 × 0.64, 920px)`,如 710px),全宽
30
+ 显示当前列宽下的最大实际值(列宽 − 176px,如 934px),自定义显示已记录的
31
+ 宽度或「未设置」。实际生效宽度仍由应用按「列宽 − 176px」钳制;窄窗口下
32
+ 选定较高档位时,效果等同于「全宽」。
33
+
28
34
  窄窗口下超宽档位会自动被钳到可用宽度,不会溢出被侧栏覆盖。
29
35
 
30
36
  选择会记住(localStorage),下次打开自动恢复;菜单里当前档位高亮,按钮上
31
- 的五条横条图标也直观显示当前档位(自定义档全亮)。拖动 0.1.7 自带的
37
+ 的五条横条图标也直观显示当前档位(自定义档全亮)。拖动应用自带的
32
38
  对话区边缘手柄会自动切到「自定义」档,插件让位于原生拖拽。
33
39
 
40
+ 若从未拖拽过,点击「自定义」不会静默套用某个宽度:右侧对话边缘会出现
41
+ 拖拽引导,手柄高亮;拖拽时气泡实时显示对话列宽度,松开后记为该档的值。
42
+ 之后每次拖拽手柄(无论从哪个档位开始)都会重新高亮手柄并显示实时宽度。
43
+
34
44
  ## 安装
35
45
 
36
46
  ```bash
@@ -55,7 +65,8 @@ bundle patch 会自动插入 `dsh-width-tiers` 加载项——无需手动改
55
65
  - 点圆形按钮 → 选择档位,当前档位高亮。
56
66
  - 「全宽」通过应用自己的收起按钮把侧边栏收成图标轨道——**不会被锁死**:
57
67
  随时点轨道图标展开,展开后保持展开。
58
- - 非标准档会关闭详情面板(被重新打开会自动再关);标准档完全交给应用。
68
+ - 插件**不碰右侧面板**:右栏的轨道与面板完全由应用管理,展开时对话内容
69
+ 自适应让位;「标准」档完全交给应用。
59
70
 
60
71
  ## 多语言
61
72
 
@@ -76,9 +87,12 @@ bundle patch 会自动插入 `dsh-width-tiers` 加载项——无需手动改
76
87
 
77
88
  ## 已知限制
78
89
 
79
- - 内容宽度不可能超过窗口:窗口窄时,高档位会被可用列宽截断;想要最大
80
- 空间请用「全宽」(同时收起侧边栏)。
90
+ - 内容宽度不可能超过窗口:窗口窄时,高档位会被可用列宽截断(选择器仍显示
91
+ 标称值,实际宽度以拖拽气泡/窗口效果为准);想要最大空间请用「全宽」
92
+ (同时收起侧边栏)。
81
93
  - 依赖 DSH Web 客户端插件体系(带 web profile 的 dsh ≥ 0.1.0-rc.x)。
94
+ 已在 dsh 0.1.7-alpha.1、0.2.0-rc.2 上验证;0.2.1-rc.1 的宽度轴经 bundle
95
+ 对比确认一致。
82
96
 
83
97
  ## 卸载
84
98
 
package/lib/client.js CHANGED
@@ -10,7 +10,7 @@ window.__ModuleLoader__.load({
10
10
  * dsh-width-tiers — chat content width tiers for the DSH Web GUI.
11
11
  *
12
12
  * The app derives the transcript width from
13
- * `--dsh-chat-content-width`, which 0.1.7-alpha.1 resolves as
13
+ * `--dsh-chat-content-width`, which 0.1.7 and 0.2.0 resolve as
14
14
  * `var(--dsh-chat-user-width, clamp(680px, column * .64, 920px))` on
15
15
  * the conversation body. 0.1.7 also added native drag handles
16
16
  * (ConversationWidthControls) that publish
@@ -23,21 +23,32 @@ window.__ModuleLoader__.load({
23
23
  * `applyContentWidth`. Other invariants:
24
24
  *
25
25
  * 标准 standard app default (no override)
26
- * 中等 medium 960px (sidebar untouched)
26
+ * 中等 medium 1120px (sidebar untouched)
27
27
  * 宽 wide 1280px (sidebar untouched)
28
28
  * 超宽 ultra 1400px (sidebar untouched)
29
29
  * 全宽 full column max (sidebar collapsed to the icon rail)
30
30
  * 自定义 custom user-dragged width (plugin hands width over to
31
31
  * the app's native handles; the dragged value stays
32
- * re-selectable from the menu)
32
+ * re-selectable from the menu). Before the first
33
+ * drag, picking it opens a guide at the right edge
34
+ * and the live column width is shown while dragging.
35
+ *
36
+ * The menu and title show fixed px for medium/wide/ultra
37
+ * (1120/1280/1400), the live app default px for standard, the live
38
+ * maximum (column − 176) for full, and the recorded drag width or
39
+ * "未设置" for custom. The app still clamps every applied px to
40
+ * column − 176. Every handle press (not just the first custom one)
41
+ * opens the live width readout, so repeat drags keep the highlight
42
+ * and the px bubble.
33
43
  *
34
44
  * - Panels are driven through the layout service (ctx.layout), never
35
45
  * by simulating clicks.
36
46
  * - The sidebar is only ever collapsed by the `full` tier, through the
37
47
  * app's own toggle — never force-hidden, never locked. Going back to
38
48
  * `standard` restores it only if this plugin collapsed it.
39
- * - Non-standard tiers close the right panel (rightbar, self-heal
40
- * while active); `standard` leaves everything to the app.
49
+ * - The rightbar is never touched: on 0.1.7+ its panel lives in its
50
+ * own seat, so closing the track would leave it overlaying the
51
+ * conversation; the app's width axis yields space on its own.
41
52
  * - The chosen tier persists in localStorage and is restored on the
42
53
  * next visit.
43
54
  * - All copy is localized through the app's locale service: tier
@@ -54,6 +65,13 @@ window.__ModuleLoader__.load({
54
65
  * the horizontal budget both width handles + safe edges reserve. */
55
66
  const CONTENT_MIN_PX = 640;
56
67
  const CONTENT_EDGE_BUDGET_PX = 176;
68
+ /* Nominal content widths of the fixed tiers (px). */
69
+ const NOMINAL_WIDTH_PX = { medium: 1120, wide: 1280, ultra: 1400 };
70
+ /* The app's no-preference formula: clamp(680px, column × 0.64, 920px).
71
+ * Used as the last-resort live readout before the first drag. */
72
+ const DEFAULT_MIN_PX = 680;
73
+ const DEFAULT_MAX_PX = 920;
74
+ const DEFAULT_COLUMN_RATIO = 0.64;
57
75
  const READY_TIMEOUT_MS = 30000;
58
76
  /* Self-heal cadence: DOM-driven (MutationObserver scheduling on the
59
77
  * next animation frame — before paint, so the width never flashes
@@ -76,6 +94,8 @@ window.__ModuleLoader__.load({
76
94
  "tier.ultra": "超宽",
77
95
  "tier.full": "全宽",
78
96
  "tier.custom": "自定义",
97
+ "value.custom.unset": "未设置",
98
+ "guide.hint": "拖拽右侧边缘调整对话宽度",
79
99
  "button.aria": "对话区宽度档位:{label}({value}),点击选择档位",
80
100
  "button.title.summary": "对话区宽度档位:{label}({value})",
81
101
  "button.title.hint": "点击选择:{list}"
@@ -88,21 +108,26 @@ window.__ModuleLoader__.load({
88
108
  "tier.ultra": "Ultra",
89
109
  "tier.full": "Full",
90
110
  "tier.custom": "Custom",
111
+ "value.custom.unset": "Not set",
112
+ "guide.hint": "Drag the right edge to resize",
91
113
  "button.aria": "Chat width tier: {label} ({value}). Click to choose a tier.",
92
114
  "button.title.summary": "Chat width tier: {label} ({value})",
93
115
  "button.title.hint": "Click to choose: {list}"
94
116
  };
95
117
  //#endregion
96
118
 
119
+ /* Tier ids and label keys only — the picker resolves displayed values
120
+ * in tierValueText (fixed px for medium/wide/ultra, live px for
121
+ * standard/full, recorded px for custom). */
97
122
  const TIERS = [
98
- { id: "standard", key: "tier.standard", value: "748px" },
99
- { id: "medium", key: "tier.medium", value: "960px" },
100
- { id: "wide", key: "tier.wide", value: "1280px" },
101
- { id: "ultra", key: "tier.ultra", value: "1400px" },
102
- { id: "full", key: "tier.full", value: "100%" },
103
- /* Reached only by dragging a native width handle; the displayed
104
- * value is the dragged width, resolved at render time. */
105
- { id: "custom", key: "tier.custom", value: null }
123
+ { id: "standard", key: "tier.standard" },
124
+ { id: "medium", key: "tier.medium" },
125
+ { id: "wide", key: "tier.wide" },
126
+ { id: "ultra", key: "tier.ultra" },
127
+ { id: "full", key: "tier.full" },
128
+ /* Reached by dragging a native width handle, or by picking it in
129
+ * the menu and following the right-edge drag guide. */
130
+ { id: "custom", key: "tier.custom" }
106
131
  ];
107
132
 
108
133
  function tierOf(id) {
@@ -128,10 +153,15 @@ window.__ModuleLoader__.load({
128
153
  "#dsh-width-menu[hidden]{display:none}",
129
154
  "#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}",
130
155
  "#dsh-width-menu button:hover{background:var(--dsw-alias-interactive-bg-hover)}",
131
- "#dsh-width-menu button.disabled{opacity:.45;cursor:default;pointer-events:none}",
132
156
  "#dsh-width-menu button.selected{background:var(--dsw-alias-interactive-bg-hover-solid);color:var(--dsw-alias-state-business-primary)}",
133
157
  "#dsh-width-menu .dshwm-value{color:var(--dsw-alias-label-tertiary);font-size:12px}",
134
- "#dsh-width-menu button.selected .dshwm-value{color:var(--dsw-alias-state-business-primary)}"
158
+ "#dsh-width-menu button.selected .dshwm-value{color:var(--dsw-alias-state-business-primary)}",
159
+ "#dsh-width-guide{position:fixed;z-index:1001;display:flex;align-items:center;gap:8px;padding:8px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-specific-menu);color:var(--dsw-alias-label-primary);font-size:12px;line-height:16px;box-shadow:0 8px 28px rgba(0,0,0,.24);pointer-events:none;white-space:nowrap;transform:translateY(-50%)}",
160
+ "#dsh-width-guide[hidden]{display:none}",
161
+ "#dsh-width-guide svg{flex:none;color:var(--dsw-alias-state-business-primary)}",
162
+ "#dsh-width-guide[data-mode=live]{font-size:13px;font-weight:600;font-variant-numeric:tabular-nums}",
163
+ "body[data-dsh-width-guide] [data-width-handle=right]{background:linear-gradient(to bottom,transparent calc(50% - 64px),color-mix(in srgb,var(--dsw-alias-state-business-primary) 18%,transparent) calc(50% - 64px),color-mix(in srgb,var(--dsw-alias-state-business-primary) 18%,transparent) calc(50% + 64px),transparent calc(50% + 64px));border-radius:3px}",
164
+ "body[data-dsh-width-guide] [data-width-handle=right]::after{opacity:1}"
135
165
  ].join("");
136
166
  //#endregion
137
167
 
@@ -286,9 +316,10 @@ window.__ModuleLoader__.load({
286
316
  function tierPx(tier, root) {
287
317
  const cap = Math.max(CONTENT_MIN_PX, root.offsetWidth - CONTENT_EDGE_BUDGET_PX);
288
318
  switch (tier) {
289
- case "medium": return Math.min(960, cap);
290
- case "wide": return Math.min(1280, cap);
291
- case "ultra": return Math.min(1400, cap);
319
+ case "medium":
320
+ case "wide":
321
+ case "ultra":
322
+ return Math.min(NOMINAL_WIDTH_PX[tier], cap);
292
323
  case "full": return cap;
293
324
  case "custom": {
294
325
  const dragged = readStoredNumber(CUSTOM_KEY);
@@ -298,6 +329,25 @@ window.__ModuleLoader__.load({
298
329
  }
299
330
  }
300
331
 
332
+ /* The transcript width the app currently resolves for the live
333
+ * column (after its own clamp), used by the custom-drag guide.
334
+ * During a drag the app writes the clamped px to the target's
335
+ * inline `--dsh-chat-user-width`, so reading that is exact and
336
+ * avoids relying on how a browser serializes a computed custom
337
+ * property (which may stay as a var()/clamp() token stream). */
338
+ function liveWidthPx(root) {
339
+ const target = widthTarget(root);
340
+ const inline = Number.parseFloat(target.style.getPropertyValue("--dsh-chat-user-width"));
341
+ if (Number.isFinite(inline) && inline > 0) return Math.round(inline);
342
+ const raw = getComputedStyle(root).getPropertyValue("--dsh-chat-content-width");
343
+ const computed = Number.parseFloat(raw);
344
+ if (Number.isFinite(computed) && computed > 0) return Math.round(computed);
345
+ return Math.round(Math.max(
346
+ DEFAULT_MIN_PX,
347
+ Math.min(root.offsetWidth * DEFAULT_COLUMN_RATIO, DEFAULT_MAX_PX)
348
+ ));
349
+ }
350
+
301
351
  function applyContentWidth(tier) {
302
352
  const root = findWidthRoot();
303
353
  if (root === null) return;
@@ -383,12 +433,38 @@ window.__ModuleLoader__.load({
383
433
  return `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">${bars}</svg>`;
384
434
  }
385
435
 
386
- /* Display value for a tier; `custom` shows the dragged width. */
436
+ /* Value shown in the picker. Fixed tiers advertise their
437
+ * nominal px; standard and full are inherently relative to the
438
+ * current column, so they show the px the app resolves right
439
+ * now (full is the column's maximum, not a word). */
387
440
  function tierValueText(tierId) {
388
- const def = tierOf(tierId);
389
- if (def.value !== null) return def.value;
390
- const px = readStoredNumber(CUSTOM_KEY);
391
- return px === null ? "—" : `${Math.round(px)}px`;
441
+ switch (tierId) {
442
+ case "standard": {
443
+ const root = findWidthRoot();
444
+ if (root === null) return "—";
445
+ return `${Math.round(Math.max(
446
+ DEFAULT_MIN_PX,
447
+ Math.min(root.offsetWidth * DEFAULT_COLUMN_RATIO, DEFAULT_MAX_PX)
448
+ ))}px`;
449
+ }
450
+ case "medium":
451
+ case "wide":
452
+ case "ultra":
453
+ return `${NOMINAL_WIDTH_PX[tierId]}px`;
454
+ case "full": {
455
+ const root = findWidthRoot();
456
+ if (root === null) return "—";
457
+ return `${Math.round(Math.max(
458
+ CONTENT_MIN_PX,
459
+ root.offsetWidth - CONTENT_EDGE_BUDGET_PX
460
+ ))}px`;
461
+ }
462
+ case "custom": {
463
+ const dragged = readStoredNumber(CUSTOM_KEY);
464
+ return dragged === null ? t("value.custom.unset") : `${Math.round(dragged)}px`;
465
+ }
466
+ default: return "—";
467
+ }
392
468
  }
393
469
 
394
470
  function syncButton() {
@@ -420,13 +496,27 @@ window.__ModuleLoader__.load({
420
496
  item.innerHTML = `<span class="dshwm-label">${t(def.key)}</span><span class="dshwm-value">${tierValueText(def.id)}</span>`;
421
497
  item.addEventListener("click", (e) => {
422
498
  if (e && e.stopPropagation) e.stopPropagation();
423
- if (item.classList.contains("disabled")) return;
499
+ /* Custom before the first drag has no width of its own:
500
+ * open the right-edge guide instead of applying a value. */
501
+ if (def.id === "custom" && readStoredNumber(CUSTOM_KEY) === null) {
502
+ beginCustomGuide();
503
+ hideMenu();
504
+ return;
505
+ }
506
+ endGuide(false);
424
507
  applyTier(def.id);
425
508
  hideMenu();
426
509
  });
427
510
  menu.appendChild(item);
428
511
  }
429
512
 
513
+ /* Write only on change: syncMenu also runs on window resizes,
514
+ * and untouched text should not churn the DOM (the plugin's own
515
+ * MutationObserver watches childList). */
516
+ function setText(el, text) {
517
+ if (el.textContent !== text) el.textContent = text;
518
+ }
519
+
430
520
  function syncMenu() {
431
521
  const tier = currentTier();
432
522
  const items = menu.querySelectorAll("button[data-tier]");
@@ -436,13 +526,9 @@ window.__ModuleLoader__.load({
436
526
  items[i].classList.toggle("selected", on);
437
527
  items[i].setAttribute("aria-checked", String(on));
438
528
  const labelEl = items[i].querySelector(".dshwm-label");
439
- if (labelEl) labelEl.textContent = t(tierOf(id).key);
529
+ if (labelEl) setText(labelEl, t(tierOf(id).key));
440
530
  const valueEl = items[i].querySelector(".dshwm-value");
441
- if (valueEl) valueEl.textContent = tierValueText(id);
442
- /* custom is inert until the user has dragged once */
443
- const disabled = id === "custom" && readStoredNumber(CUSTOM_KEY) === null;
444
- items[i].classList.toggle("disabled", disabled);
445
- items[i].setAttribute("aria-disabled", String(disabled));
531
+ if (valueEl) setText(valueEl, tierValueText(id));
446
532
  }
447
533
  }
448
534
 
@@ -464,15 +550,151 @@ window.__ModuleLoader__.load({
464
550
  });
465
551
 
466
552
  const onDocClick = (e) => {
467
- if (menu.hidden) return;
468
- if (e.target !== btn && !menu.contains(e.target)) hideMenu();
553
+ if (!menu.hidden && e.target !== btn && !menu.contains(e.target)) hideMenu();
554
+ /* Clicking anywhere but the guide's handle cancels a
555
+ * pending custom guide and restores the previous tier. */
556
+ if (guideActive && !guideDragging && e.target !== btn && !menu.contains(e.target) && !isHandleEvent(e)) {
557
+ endGuide(true);
558
+ }
469
559
  };
470
560
  const onDocKey = (e) => {
471
- if (e.key === "Escape" && !menu.hidden) hideMenu();
561
+ if (e.key !== "Escape") return;
562
+ if (!menu.hidden) hideMenu();
563
+ if (guideActive) endGuide(true);
472
564
  };
473
565
  document.addEventListener("click", onDocClick);
474
566
  document.addEventListener("keydown", onDocKey);
475
567
 
568
+ /* ---- custom-drag guide ----
569
+ * A first-time `custom` pick has no recorded width. Instead of
570
+ * silently keeping the current width, point at the app's
571
+ * right-edge width handle and show the live transcript width
572
+ * while the user drags. The guide never writes the width
573
+ * itself: the app's native drag does (and wins the cascade),
574
+ * and the plugin only records the settled value on release. */
575
+ const guide = document.createElement("div");
576
+ guide.id = "dsh-width-guide";
577
+ guide.setAttribute("aria-hidden", "true");
578
+ guide.hidden = true;
579
+ guide.innerHTML =
580
+ '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">' +
581
+ '<path d="M6 3 2 8l4 5M10 3l4 5-4 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>' +
582
+ '<span class="dshwg-text"></span>';
583
+ const guideTextEl = guide.querySelector(".dshwg-text");
584
+
585
+ let guideActive = false;
586
+ let guideDragging = false;
587
+ /* true while the bubble came from picking custom in the menu (its
588
+ * cancel path restores the previous tier). Direct handle drags
589
+ * open the same bubble but never "cancel" a tier. */
590
+ let guideFromMenu = false;
591
+ let guideHideTimer = null;
592
+ let tierBeforeGuide = "standard";
593
+
594
+ /* The px the app currently resolves for the transcript, after
595
+ * its own clamp — what the guide's live readout shows. */
596
+ function guideValueText() {
597
+ const root = findWidthRoot();
598
+ const px = root === null ? null : liveWidthPx(root);
599
+ return px === null ? "—" : `${px}px`;
600
+ }
601
+
602
+ /* Keep the bubble beside the right handle (it moves with the
603
+ * dragged edge); fall back to the frame's right edge when the
604
+ * handle is not mounted yet (hero / settling phase). */
605
+ function positionGuide() {
606
+ if (!guideActive) return;
607
+ const handle = document.querySelector('[data-width-handle="right"]');
608
+ const viewport = window.innerWidth || 0;
609
+ if (handle === null || viewport === 0) {
610
+ guide.style.left = "auto";
611
+ guide.style.right = "24px";
612
+ guide.style.top = "50%";
613
+ return;
614
+ }
615
+ const rect = handle.getBoundingClientRect();
616
+ const gap = 10;
617
+ const width = guide.offsetWidth || 180;
618
+ guide.style.top = `${rect.top + rect.height / 2}px`;
619
+ guide.style.right = "auto";
620
+ if (viewport - rect.right >= width + gap) {
621
+ guide.style.left = `${rect.right + gap}px`;
622
+ } else {
623
+ guide.style.left = `${Math.max(8, rect.left - gap - width)}px`;
624
+ }
625
+ }
626
+
627
+ function setGuideText(mode, text) {
628
+ guide.dataset.mode = mode;
629
+ setText(guideTextEl, text);
630
+ }
631
+
632
+ function startGuide() {
633
+ guideFromMenu = true;
634
+ guideActive = true;
635
+ guideDragging = false;
636
+ guide.hidden = false;
637
+ body().setAttribute("data-dsh-width-guide", "custom");
638
+ setGuideText("hint", t("guide.hint"));
639
+ positionGuide();
640
+ }
641
+
642
+ /* Every handle press shows the live readout, not just the first
643
+ * guided drag — a repeat custom drag must keep the handle
644
+ * highlight and the width bubble. */
645
+ function startLiveGuide() {
646
+ window.clearTimeout(guideHideTimer);
647
+ guideActive = true;
648
+ guideDragging = true;
649
+ guide.hidden = false;
650
+ body().setAttribute("data-dsh-width-guide", "custom");
651
+ setGuideText("live", guideValueText());
652
+ positionGuide();
653
+ }
654
+
655
+ function beginCustomGuide() {
656
+ /* Remember the tier to restore on cancel only on the first
657
+ * entry; re-picking custom while the guide is open must not
658
+ * make "custom" the restore target. */
659
+ if (!guideActive) {
660
+ tierBeforeGuide = currentTier();
661
+ applyTier("custom");
662
+ }
663
+ startGuide();
664
+ }
665
+
666
+ /* Show the settled width briefly, then remove the guide. */
667
+ function finishGuide() {
668
+ if (!guideActive) return;
669
+ guideDragging = false;
670
+ setGuideText("live", guideValueText());
671
+ window.clearTimeout(guideHideTimer);
672
+ guideHideTimer = window.setTimeout(() => endGuide(false), 800);
673
+ }
674
+
675
+ /* `restoreTier` is for cancelling a menu-opened guide (click-away /
676
+ * Escape): if it never recorded a width, go back to the tier the
677
+ * user had before opening it. Drag-opened bubbles keep custom. */
678
+ function endGuide(restoreTier) {
679
+ if (!guideActive) return;
680
+ guideActive = false;
681
+ guideDragging = false;
682
+ window.clearTimeout(guideHideTimer);
683
+ guide.hidden = true;
684
+ guide.dataset.mode = "hint";
685
+ body().removeAttribute("data-dsh-width-guide");
686
+ const restore = restoreTier && guideFromMenu && readStoredNumber(CUSTOM_KEY) === null;
687
+ guideFromMenu = false;
688
+ if (restore) applyTier(tierBeforeGuide);
689
+ }
690
+
691
+ const onGuidePointerMove = () => {
692
+ if (!guideActive || !guideDragging) return;
693
+ setGuideText("live", guideValueText());
694
+ positionGuide();
695
+ };
696
+ document.addEventListener("pointermove", onGuidePointerMove, true);
697
+
476
698
  /* ---- hand the width over to the app's native drag handles ----
477
699
  * Pressing a `[data-width-handle]` means the user is taking the
478
700
  * width over: switch to the `custom` tier so healing stops
@@ -484,7 +706,9 @@ window.__ModuleLoader__.load({
484
706
  e.target instanceof Element && e.target.closest("[data-width-handle]") !== null;
485
707
 
486
708
  const onHandlePointerDown = (e) => {
487
- if (!isHandleEvent(e) || currentTier() === "custom") return;
709
+ if (!isHandleEvent(e)) return;
710
+ startLiveGuide();
711
+ if (currentTier() === "custom") return;
488
712
  body().setAttribute("data-dsh-width", "custom");
489
713
  try {
490
714
  window.localStorage.setItem(KEY, "custom");
@@ -499,16 +723,19 @@ window.__ModuleLoader__.load({
499
723
  const onHandlePointerUp = (e) => {
500
724
  if (!isHandleEvent(e)) return;
501
725
  const root = findWidthRoot();
502
- if (root === null) return;
503
- const px = Number.parseFloat(
504
- widthTarget(root).style.getPropertyValue("--dsh-chat-user-width")
505
- );
506
- if (!Number.isFinite(px) || px <= 0) return;
507
- try {
508
- window.localStorage.setItem(CUSTOM_KEY, String(Math.round(px)));
509
- } catch (err) {
510
- /* ignore */
726
+ if (root !== null) {
727
+ const px = Number.parseFloat(
728
+ widthTarget(root).style.getPropertyValue("--dsh-chat-user-width")
729
+ );
730
+ if (Number.isFinite(px) && px > 0) {
731
+ try {
732
+ window.localStorage.setItem(CUSTOM_KEY, String(Math.round(px)));
733
+ } catch (err) {
734
+ /* ignore */
735
+ }
736
+ }
511
737
  }
738
+ if (guideActive) finishGuide();
512
739
  syncButton();
513
740
  syncMenu();
514
741
  };
@@ -527,6 +754,9 @@ window.__ModuleLoader__.load({
527
754
  const renderTexts = () => {
528
755
  syncButton();
529
756
  syncMenu();
757
+ if (guideActive && !guideDragging && guide.dataset.mode === "hint") {
758
+ setText(guideTextEl, t("guide.hint"));
759
+ }
530
760
  };
531
761
  const unsubscribeLocale = ctx.locale.subscribe(renderTexts);
532
762
 
@@ -551,6 +781,7 @@ window.__ModuleLoader__.load({
551
781
  }
552
782
  body().appendChild(btn);
553
783
  body().appendChild(menu);
784
+ body().appendChild(guide);
554
785
  syncButton();
555
786
  const saved = getSavedTier();
556
787
  if (saved !== "standard") applyTier(saved);
@@ -611,6 +842,9 @@ window.__ModuleLoader__.load({
611
842
  }
612
843
 
613
844
  function heal(fromObserver) {
845
+ /* The right handle only mounts in the active phase; keep the
846
+ * guide pinned to it once the chat tree settles. */
847
+ if (guideActive) positionGuide();
614
848
  const tier = currentTier();
615
849
  if (tier === "standard") return;
616
850
  const prev = widthRoot;
@@ -665,6 +899,22 @@ window.__ModuleLoader__.load({
665
899
  fallbackTimer = setInterval(() => heal(false), FALLBACK_HEAL_MS);
666
900
  }
667
901
 
902
+ /* ---- keep the picker and the guide aligned with the live
903
+ * column. Standard/full re-resolve on resize, and the guide
904
+ * must stay beside the handle when the frame resizes. ---- */
905
+ let resizeFrame = null;
906
+
907
+ const onWindowResize = () => {
908
+ if (resizeFrame !== null) return;
909
+ scheduleFrame(() => {
910
+ resizeFrame = null;
911
+ syncButton();
912
+ syncMenu();
913
+ positionGuide();
914
+ });
915
+ };
916
+ window.addEventListener("resize", onWindowResize);
917
+
668
918
  /* ---- disposer ---- */
669
919
  return () => {
670
920
  unsubscribeLocale();
@@ -672,14 +922,19 @@ window.__ModuleLoader__.load({
672
922
  clearInterval(fallbackTimer);
673
923
  clearTimeout(timeout);
674
924
  clearTimeout(rootRetryTimer);
925
+ clearTimeout(guideHideTimer);
675
926
  if (healFrame !== null) cancelFrame(healFrame);
927
+ if (resizeFrame !== null) cancelFrame(resizeFrame);
928
+ window.removeEventListener("resize", onWindowResize);
676
929
  if (observer !== null) observer.disconnect();
677
930
  document.removeEventListener("click", onDocClick);
678
931
  document.removeEventListener("keydown", onDocKey);
679
932
  document.removeEventListener("pointerdown", onHandlePointerDown, true);
680
933
  document.removeEventListener("pointerup", onHandlePointerUp, true);
934
+ document.removeEventListener("pointermove", onGuidePointerMove, true);
681
935
  if (btn.parentNode) btn.parentNode.removeChild(btn);
682
936
  if (menu.parentNode) menu.parentNode.removeChild(menu);
937
+ if (guide.parentNode) guide.parentNode.removeChild(guide);
683
938
  if (styleTag.parentNode) styleTag.parentNode.removeChild(styleTag);
684
939
  /* Hand the width back to the app default; the app's own
685
940
  * preference (which this plugin mirrors) stays so the last
@@ -689,6 +944,7 @@ window.__ModuleLoader__.load({
689
944
  widthTarget(widthRoot).style.removeProperty("--dsh-chat-user-width");
690
945
  }
691
946
  body().removeAttribute("data-dsh-width");
947
+ body().removeAttribute("data-dsh-width-guide");
692
948
  };
693
949
  }, "dsh-width-tiers: ui");
694
950
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-width-tiers",
3
- "version": "1.0.6",
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+, so the built-in drag handles keep working. UI copy follows the app language (zh / en).",
3
+ "version": "1.0.8",
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+ (0.2.1-rc.1 width axis checked), 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": {