dsh-width-tiers 1.0.8 → 1.0.9

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
@@ -1,55 +1,41 @@
1
- # dsh-width-tiers
1
+ <h1 align="center">dsh-width-tiers</h1>
2
2
 
3
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
3
+ <p align="center">Chat content-width tiers for the DeepSeek Harness Web GUI.</p>
4
4
 
5
- A client plugin for the **DeepSeek Harness Web GUI** that adds **chat content
6
- width tiers** with a floating picker button (bottom-right, above the
7
- composer): 标准 / 中等 / 宽 / 超宽 / 全宽 / 自定义 (standard / medium / wide /
8
- ultra / full / custom).
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/dsh-width-tiers"><img alt="npm" src="https://img.shields.io/npm/v/dsh-width-tiers?style=flat-square"></a>
7
+ <a href="https://github.com/aaronlei/dsh-width-tiers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/aaronlei/dsh-width-tiers?style=flat-square"></a>
8
+ <a href="LICENSE"><img alt="MIT" src="https://img.shields.io/badge/license-MIT-yellow?style=flat-square"></a>
9
+ <a href="#compatibility"><img alt="DSH 0.1.7+" src="https://img.shields.io/badge/DSH-0.1.7%2B-blue?style=flat-square"></a>
10
+ </p>
9
11
 
10
- ## Why
11
-
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
-
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:
12
+ ## Tiers
21
13
 
22
14
  | Tier | Content width | Sidebar |
23
- |---|---|---|
24
- | standard 标准 | app default | untouched |
25
- | medium 中等 | 1120px | untouched |
26
- | wide 宽 | 1280px | untouched |
27
- | ultra 超宽 | 1400px | untouched |
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 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
-
39
- On narrow windows wider tiers are clamped to the available column, so nothing
40
- overflows under the sidebar.
41
-
42
- The choice persists in `localStorage` and is restored on the next visit; the
43
- menu highlights the current tier, and the button's five level-bars show it at
44
- a glance (all lit for custom). Dragging one of the app's native edge handles
45
- switches the picker to the custom tier — the plugin stands aside for native
46
- drags.
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.
15
+ | --- | --- | --- |
16
+ | Standard 标准 | App default: `max(680px, min(column × 0.64, 920px))` | unchanged |
17
+ | Medium 中等 | 1120px | unchanged |
18
+ | Wide 宽 | 1280px | unchanged |
19
+ | Ultra 超宽 | 1400px | unchanged |
20
+ | Full 全宽 | `max(640px, column − 176px)` | collapsed to icon rail |
21
+ | Custom 自定义 | width recorded from a native drag | unchanged |
22
+
23
+ Fixed and custom widths are clamped by the app to
24
+ `max(640px, column − 176px)`; the standard tier follows the app default
25
+ (680–920px). On a narrow window a wider fixed tier therefore behaves like
26
+ `full`.
27
+
28
+ ## Features
29
+
30
+ - Uses the app's native width axis (`--dsh-chat-user-width` and
31
+ `dsh.conversation.contentWidth`), so the built-in drag handles keep working.
32
+ - Persists the selected tier and restores it on the next visit.
33
+ - Follows **Settings → Language** (中文 / English).
34
+ - Menu supports ArrowUp/Down, Home/End, Escape and Tab, with focus restoration.
35
+ - Tier changes sync across tabs.
36
+ - Respects `prefers-reduced-motion`.
37
+ - Never touches the right panel. Only `full` collapses the left sidebar, and
38
+ you can expand it again at any time.
53
39
 
54
40
  ## Install
55
41
 
@@ -57,68 +43,57 @@ readout, no matter which tier was active.
57
43
  dsh plugin --profile web add dsh-width-tiers
58
44
  ```
59
45
 
60
- The bundle patch then inserts the `dsh-width-tiers` loader entry itself — no
61
- manual `cordis.patch.yml` edits needed.
46
+ Restart `dsh web`, then hard-refresh the page. A six-bar picker appears at the
47
+ bottom-right.
62
48
 
63
- Restart `dsh web` (the plugin loads at boot) and hard-refresh the page
64
- (Ctrl+Shift+R). A round button with five bars appears at the bottom-right.
49
+ For a local checkout:
65
50
 
66
- > Installing from a local checkout: `dsh plugin --profile web add file:/path/to/dsh-width-tiers`.
67
- > Local-development gotcha: `file:` dependencies are **copied** into the
68
- > profile's `node_modules` (pnpm does not symlink them). After editing the
69
- > plugin source, re-sync the copy (e.g. `rsync -a --delete --exclude .git
70
- > /path/to/dsh-width-tiers/ ~/.dsh/profiles/web/node_modules/dsh-width-tiers/`)
71
- > or re-install after removing it, then restart `dsh web` and hard-refresh.
51
+ ```bash
52
+ dsh plugin --profile web add link:/path/to/dsh-width-tiers
53
+ ```
72
54
 
73
55
  ## Usage
74
56
 
75
- - Click the round button → pick a tier; the current one is highlighted.
76
- - `full` collapses the sidebar to the icon rail through the app's own toggle
77
- — it is never force-hidden or locked: click a rail icon any time to expand
78
- it, and it stays expanded.
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.
57
+ - Click the picker and choose a tier.
58
+ - Picking `Custom` before the first drag opens a guide at the right edge; drag
59
+ a native handle to record a width.
60
+ - Dragging any native handle switches to `Custom`; the plugin steps aside while
61
+ you drag.
62
+ - `Standard` clears the width preference and returns to the app default.
63
+ - `Full` collapses the left sidebar; the icon rail stays available.
82
64
 
83
- ## Language
65
+ ## How it works
84
66
 
85
- All picker copy (tier labels, button `aria-label` and title) follows the
86
- app's **Settings → Language** (中文 / English), through the app's own locale
87
- service. Switching the language re-renders the button and menu immediately;
88
- when no explicit language is chosen the app falls back to the browser's
89
- language, and Chinese is the last resort. No plugin-side setting needed.
67
+ - Locates the conversation body that declares `--dsh-chat-content-width`
68
+ (prefers `[data-conversation-content]`, skips the right sidebar's embedded
69
+ conversation), then writes `--dsh-chat-user-width` on its parent.
70
+ - Stores the configured target in the app preference; only the inline width is
71
+ clamped to `max(640px, column − 176px)`.
72
+ - Watches the column with `ResizeObserver` so `full` follows sidebar, rightbar
73
+ and window changes.
74
+ - Drives panels through `ctx.layout` and registers copy through `ctx.locale`.
90
75
 
91
- ## How it works
76
+ ## Compatibility
77
+
78
+ - DSH Web 0.1.7+ with a `web` profile.
79
+ - Width axis checked against DSH 0.2.1-rc.2: 640px floor, 176px handle budget,
80
+ `--dsh-chat-*` variables, `data-width-handle`, layout/locale services.
92
81
 
93
- - The element that defines `--dsh-chat-content-width` is **located at
94
- runtime** (the first element whose computed value is non-empty) and the
95
- tier is applied as an inline override — no hashed class names, so it
96
- survives bundle upgrades.
97
- - Panels are driven through the **layout service** (`ctx.layout`), never by
98
- simulating clicks.
99
- - The sidebar is only ever collapsed by the `full` tier; returning to
100
- `standard` restores it only if this plugin collapsed it.
101
- - Dictionaries are registered with the app's **locale service**
102
- (`ctx.locale.register` / `bind`) and re-rendered through its change
103
- subscription, so the copy always matches the app's active language.
104
-
105
- ## Limitations
106
-
107
- - Content width cannot exceed the window: on a narrow window, wider tiers are
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.
111
- - Requires the DSH Web client plugin system (dsh ≥ 0.1.0-rc.x with a `web`
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.
114
-
115
- ## Uninstall
82
+ ## Development
116
83
 
117
84
  ```bash
118
- dsh plugin --profile web remove dsh-width-tiers
85
+ npm test
119
86
  ```
120
87
 
121
- then restart `dsh web`.
88
+ The test loads the shipped `lib/client.js` bundle through a DOM stub; no live
89
+ DSH instance is required. The package ships prebuilt `lib/` and does not
90
+ include a `src/` tree.
91
+
92
+ ## Notes
93
+
94
+ - Wider fixed tiers are clamped on narrow windows.
95
+ - A custom width dragged on a narrow window is clamped by the app before it is
96
+ recorded.
122
97
 
123
98
  ## License
124
99
 
package/README.zh.md CHANGED
@@ -1,45 +1,38 @@
1
- # dsh-width-tiers(对话区宽度档位)
1
+ <h1 align="center">dsh-width-tiers</h1>
2
2
 
3
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
3
+ <p align="center">DeepSeek Harness Web GUI 的对话区内容宽度档位插件。</p>
4
4
 
5
- DeepSeek Harness Web GUI 的客户端插件:给对话区加**内容宽度档位选择器**
6
- (右下角圆形按钮,输入框上方),共 6 档:
7
- 标准 / 中等 / 宽 / 超宽 / 全宽 / 自定义。
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/dsh-width-tiers"><img alt="npm" src="https://img.shields.io/npm/v/dsh-width-tiers?style=flat-square"></a>
7
+ <a href="https://github.com/aaronlei/dsh-width-tiers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/aaronlei/dsh-width-tiers?style=flat-square"></a>
8
+ <a href="LICENSE"><img alt="MIT" src="https://img.shields.io/badge/license-MIT-yellow?style=flat-square"></a>
9
+ <a href="#兼容性"><img alt="DSH 0.1.7+" src="https://img.shields.io/badge/DSH-0.1.7%2B-blue?style=flat-square"></a>
10
+ </p>
8
11
 
9
- ## 为什么需要它
10
-
11
- DSH Web 把消息列——包括里面的所有表格——的宽度限制在
12
- `--dsh-chat-content-width`(0.1.7–0.2.0 默认
13
- `clamp(680px, 列宽 × 0.64, 920px)`),不管窗口多宽都不变,宽表格只能横向滚动。
14
-
15
- 本插件通过应用自身的**原生宽度通道**(`--dsh-chat-user-width` +
16
- `dsh.conversation.contentWidth` 偏好)按档位设定宽度,由应用自身钳制到
17
- 「列宽 − 176px 手柄预留」,因此自带的原生拖拽手柄始终可用:
12
+ ## 档位
18
13
 
19
14
  | 档位 | 内容宽度 | 侧边栏 |
20
- |---|---|---|
21
- | 标准 | 应用默认 | 不动 |
15
+ | --- | --- | --- |
16
+ | 标准 | 应用默认:`max(680px, min(列宽 × 0.64, 920px))` | 不动 |
22
17
  | 中等 | 1120px | 不动 |
23
18
  | 宽 | 1280px | 不动 |
24
19
  | 超宽 | 1400px | 不动 |
25
- | 全宽 | 列宽减手柄预留(占满可用宽度) | 收起为图标轨道 |
26
- | 自定义 | 拖拽原生手柄得出,可在菜单回切 | 不动 |
27
-
28
- 菜单与按钮标题中:中等 / 宽 / 超宽显示**固定标称值**(1120 / 1280 / 1400px),
29
- 标准显示应用当前默认值(`clamp(680px, 列宽 × 0.64, 920px)`,如 710px),全宽
30
- 显示当前列宽下的最大实际值(列宽 − 176px,如 934px),自定义显示已记录的
31
- 宽度或「未设置」。实际生效宽度仍由应用按「列宽 − 176px」钳制;窄窗口下
32
- 选定较高档位时,效果等同于「全宽」。
20
+ | 全宽 | `max(640px, 列宽 − 176px)` | 收起为图标轨道 |
21
+ | 自定义 | 原生拖拽记录的宽度 | 不动 |
33
22
 
34
- 窄窗口下超宽档位会自动被钳到可用宽度,不会溢出被侧栏覆盖。
23
+ 固定档和自定义宽度会被应用钳制到 `max(640px, 列宽 − 176px)`;标准档
24
+ 使用应用默认的 680–920px。窄窗口下较宽的固定档等同「全宽」。
35
25
 
36
- 选择会记住(localStorage),下次打开自动恢复;菜单里当前档位高亮,按钮上
37
- 的五条横条图标也直观显示当前档位(自定义档全亮)。拖动应用自带的
38
- 对话区边缘手柄会自动切到「自定义」档,插件让位于原生拖拽。
26
+ ## 特性
39
27
 
40
- 若从未拖拽过,点击「自定义」不会静默套用某个宽度:右侧对话边缘会出现
41
- 拖拽引导,手柄高亮;拖拽时气泡实时显示对话列宽度,松开后记为该档的值。
42
- 之后每次拖拽手柄(无论从哪个档位开始)都会重新高亮手柄并显示实时宽度。
28
+ - 走应用原生宽度通道(`--dsh-chat-user-width` 与
29
+ `dsh.conversation.contentWidth`),自带拖拽手柄始终可用。
30
+ - 记住当前档位,下次打开自动恢复。
31
+ - 跟随 **设置 → 语言**(中文 / English)。
32
+ - 菜单支持方向键、Home/End、Esc、Tab,并恢复焦点。
33
+ - 档位选择跨标签页同步。
34
+ - 支持 `prefers-reduced-motion`。
35
+ - 不碰右侧面板;只有「全宽」会收起左侧栏,随时可以再展开。
43
36
 
44
37
  ## 安装
45
38
 
@@ -47,61 +40,51 @@ DSH Web 把消息列——包括里面的所有表格——的宽度限制在
47
40
  dsh plugin --profile web add dsh-width-tiers
48
41
  ```
49
42
 
50
- bundle patch 会自动插入 `dsh-width-tiers` 加载项——无需手动改
51
- `cordis.patch.yml`。
43
+ 重启 `dsh web` 并硬刷新页面。右下角会出现六条横条的圆形按钮。
52
44
 
53
- **重启 `dsh web`**(插件在启动时加载),再硬刷新页面(Ctrl+Shift+R)。
54
- 右下角会出现五条横条的圆形按钮。
45
+ 本地目录:
55
46
 
56
- > 本地目录安装:`dsh plugin --profile web add file:/path/to/dsh-width-tiers`。
57
- > 本地开发注意:`file:` 依赖会被 **拷贝** 进 profile 的 node_modules(pnpm
58
- > 不会建软链)。改完插件源码后,需要重新同步拷贝(例如 `rsync -a --delete
59
- > --exclude .git /path/to/dsh-width-tiers/
60
- > ~/.dsh/profiles/web/node_modules/dsh-width-tiers/`)或先 remove 再 add,
61
- > 然后重启 `dsh web` 并硬刷新。
47
+ ```bash
48
+ dsh plugin --profile web add link:/path/to/dsh-width-tiers
49
+ ```
62
50
 
63
51
  ## 使用
64
52
 
65
- - 点圆形按钮 → 选择档位,当前档位高亮。
66
- - 「全宽」通过应用自己的收起按钮把侧边栏收成图标轨道——**不会被锁死**:
67
- 随时点轨道图标展开,展开后保持展开。
68
- - 插件**不碰右侧面板**:右栏的轨道与面板完全由应用管理,展开时对话内容
69
- 自适应让位;「标准」档完全交给应用。
70
-
71
- ## 多语言
72
-
73
- 选择器里的所有文案(档位名、按钮的 `aria-label` 和标题)跟随应用的
74
- **设置 → 语言**(中文 / English),通过应用自身的 locale 服务实现。切换
75
- 语言后按钮和菜单会立即重渲染;未显式选择语言时,应用会回退到浏览器语言,
76
- 最后兜底中文。无需插件侧单独设置。
53
+ - 点击圆形按钮选择档位。
54
+ - 首次选择「自定义」会在右侧边缘显示引导;拖动原生手柄记录宽度。
55
+ - 拖动任意原生手柄都会切换到「自定义」;拖拽期间插件让位于应用。
56
+ - 「标准」清除宽度偏好,回到应用默认宽度。
57
+ - 「全宽」收起左侧栏,图标轨道仍可随时展开。
77
58
 
78
59
  ## 原理
79
60
 
80
- - 运行时**自动定位**定义 `--dsh-chat-content-width` 的元素(第一个计算值
81
- 非空的元素),以行内样式覆盖——**不依赖哈希类名**,dsh 升级后依旧有效。
82
- - 面板操作走 **layout 服务**(`ctx.layout`),从不模拟点击。
83
- - 侧边栏只被「全宽」档收起;回到「标准」时,只有本插件自己收起的侧边栏
84
- 才会被恢复展开(你手动收的不动)。
85
- - 字典注册到应用的 **locale 服务**(`ctx.locale.register` / `bind`),并
86
- 订阅其变更事件重渲染,文案始终与应用的当前语言一致。
61
+ - 定位声明 `--dsh-chat-content-width` 的对话 body(优先
62
+ `[data-conversation-content]`,跳过右侧栏的 embedded 对话),在它的
63
+ 父级写入 `--dsh-chat-user-width`。
64
+ - 应用偏好保存设定值,只有行内宽度按 `max(640px, 列宽 − 176px)` 钳制。
65
+ - 用 `ResizeObserver` 观察列宽,让「全宽」跟随左右栏与窗口变化。
66
+ - 面板操作走 `ctx.layout`,文案注册走 `ctx.locale`。
87
67
 
88
- ## 已知限制
68
+ ## 兼容性
89
69
 
90
- - 内容宽度不可能超过窗口:窗口窄时,高档位会被可用列宽截断(选择器仍显示
91
- 标称值,实际宽度以拖拽气泡/窗口效果为准);想要最大空间请用「全宽」
92
- (同时收起侧边栏)。
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
- 对比确认一致。
70
+ - DSH Web 0.1.7+,带 `web` profile。
71
+ - 宽度轴已按 DSH 0.2.1-rc.2 对照:640px 下限、176px 手柄预留、
72
+ `--dsh-chat-*` 变量、`data-width-handle`、layout/locale 服务。
96
73
 
97
- ## 卸载
74
+ ## 开发
98
75
 
99
76
  ```bash
100
- dsh plugin --profile web remove dsh-width-tiers
77
+ npm test
101
78
  ```
102
79
 
103
- 然后重启 `dsh web`。
80
+ 测试通过 DOM stub 加载已发布的 `lib/client.js`,不需要启动真实 DSH。包内
81
+ 直接发布预构建的 `lib/`,不包含 `src/`。
82
+
83
+ ## 注意
84
+
85
+ - 窄窗口下较宽的固定档位会被应用钳制。
86
+ - 窄窗口下拖出的自定义宽度会先被应用钳制,再被记录。
104
87
 
105
88
  ## 许可证
106
89
 
107
- MIT —— 见 [LICENSE](LICENSE)。Copyright (c) 2026 aaronlei。
90
+ MIT — 见 [LICENSE](LICENSE)。Copyright (c) 2026 aaronlei。
package/lib/client.js CHANGED
@@ -41,6 +41,15 @@ window.__ModuleLoader__.load({
41
41
  * opens the live width readout, so repeat drags keep the highlight
42
42
  * and the px bubble.
43
43
  *
44
+ * The app preference stores the tier's configured target (nominal px,
45
+ * the recorded custom width, or the live cap for full); only the
46
+ * inline value is clamped. This lets the app restore the target when
47
+ * the window widens instead of staying pinned to a narrow clamp.
48
+ * The picker icon has one bar per tier (full lights five, custom all
49
+ * six), tier changes sync across tabs, the menu supports
50
+ * ArrowUp/Down/Home/End/Escape, and the sidebar's embedded
51
+ * Conversation is excluded from the width-root lookup.
52
+ *
44
53
  * - Panels are driven through the layout service (ctx.layout), never
45
54
  * by simulating clicks.
46
55
  * - The sidebar is only ever collapsed by the `full` tier, through the
@@ -148,11 +157,13 @@ window.__ModuleLoader__.load({
148
157
  "#dsh-wide-toggle:not(:hover):not(:focus-visible) svg rect{width:6px;x:5px}",
149
158
  "#dsh-wide-toggle.active{background:var(--dsw-alias-state-business-primary);border-color:transparent;color:var(--dsw-static-neutral-00);opacity:1}",
150
159
  "#dsh-wide-toggle.pulse{animation:dsh-wide-pulse .65s ease-out}",
160
+ "@media (prefers-reduced-motion:reduce){#dsh-wide-toggle{transition:none}#dsh-wide-toggle.pulse{animation:none}}",
151
161
  "@keyframes dsh-wide-pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--dsw-alias-state-business-primary) 60%,transparent)}100%{box-shadow:0 0 0 14px transparent}}",
152
162
  "#dsh-width-menu{position:fixed;right:20px;bottom:264px;z-index:1000;min-width:196px;padding:6px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-specific-menu);box-shadow:0 8px 28px rgba(0,0,0,.24);display:flex;flex-direction:column;gap:2px}",
153
163
  "#dsh-width-menu[hidden]{display:none}",
154
164
  "#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}",
155
165
  "#dsh-width-menu button:hover{background:var(--dsw-alias-interactive-bg-hover)}",
166
+ "#dsh-width-menu button:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}",
156
167
  "#dsh-width-menu button.selected{background:var(--dsw-alias-interactive-bg-hover-solid);color:var(--dsw-alias-state-business-primary)}",
157
168
  "#dsh-width-menu .dshwm-value{color:var(--dsw-alias-label-tertiary);font-size:12px}",
158
169
  "#dsh-width-menu button.selected .dshwm-value{color:var(--dsw-alias-state-business-primary)}",
@@ -160,8 +171,8 @@ window.__ModuleLoader__.load({
160
171
  "#dsh-width-guide[hidden]{display:none}",
161
172
  "#dsh-width-guide svg{flex:none;color:var(--dsw-alias-state-business-primary)}",
162
173
  "#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}"
174
+ "body[data-dsh-width-guide] [data-width-handle]{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}",
175
+ "body[data-dsh-width-guide] [data-width-handle]::after{opacity:1}"
165
176
  ].join("");
166
177
  //#endregion
167
178
 
@@ -241,6 +252,13 @@ window.__ModuleLoader__.load({
241
252
  let widthRoot = null;
242
253
  let widthPath = null;
243
254
 
255
+ /* Whether this plugin wrote the app's width preference. Native
256
+ * drags also write it, so unload only removes a value this
257
+ * plugin owns. */
258
+ let preferenceOwned = false;
259
+ let preferenceWritten = null;
260
+ let pulseTimer = null;
261
+
244
262
  function hasWidthVar(el) {
245
263
  return getComputedStyle(el)
246
264
  .getPropertyValue("--dsh-chat-content-width")
@@ -259,14 +277,35 @@ window.__ModuleLoader__.load({
259
277
  widthPath = path.length > 0 ? path : null;
260
278
  }
261
279
 
280
+ /* Sidebar chat tabs render an embedded Conversation whose
281
+ * `--dsh-chat-content-width` is a local min() and never reads
282
+ * `--dsh-chat-user-width`. Writing there would be a no-op on
283
+ * the visible column and could starve the real transcript. */
284
+ function isSidebarConversation(el) {
285
+ return typeof el.closest === "function"
286
+ && el.closest("[data-sidebar-chat]") !== null;
287
+ }
288
+
289
+ function firstWidthRoot(list) {
290
+ for (let i = 0; i < list.length; i++) {
291
+ const el = list[i];
292
+ if (isSidebarConversation(el)) continue;
293
+ if (hasWidthVar(el)) return el;
294
+ }
295
+ return null;
296
+ }
297
+
262
298
  function scanWidthRoot() {
263
- const all = document.querySelectorAll("[class]");
264
- for (let i = 0; i < all.length; i++) {
265
- if (hasWidthVar(all[i])) {
266
- widthRoot = all[i];
267
- recordWidthPath(widthRoot);
268
- return widthRoot;
269
- }
299
+ /* Prefer the stable Conversation data attribute (one or two
300
+ * elements) over every classed node in the document. The
301
+ * [class] walk remains the compatibility fallback for older
302
+ * clients that do not publish data-conversation-content. */
303
+ let found = firstWidthRoot(document.querySelectorAll("[data-conversation-content]"));
304
+ if (found === null) found = firstWidthRoot(document.querySelectorAll("[class]"));
305
+ if (found !== null) {
306
+ widthRoot = found;
307
+ recordWidthPath(widthRoot);
308
+ return widthRoot;
270
309
  }
271
310
  widthRoot = null;
272
311
  widthPath = null;
@@ -274,14 +313,16 @@ window.__ModuleLoader__.load({
274
313
  }
275
314
 
276
315
  function findWidthRoot(allowScan) {
277
- if (widthRoot !== null && document.contains(widthRoot)) return widthRoot;
316
+ if (widthRoot !== null && document.contains(widthRoot)
317
+ && !isSidebarConversation(widthRoot)) return widthRoot;
278
318
  if (widthPath !== null) {
279
319
  let el = document.body;
280
320
  for (let i = 0; i < widthPath.length; i++) {
281
321
  el = el.children[widthPath[i]];
282
322
  if (!el) break;
283
323
  }
284
- if (el && el !== widthRoot && document.contains(el) && hasWidthVar(el)) {
324
+ if (el && el !== widthRoot && document.contains(el)
325
+ && !isSidebarConversation(el) && hasWidthVar(el)) {
285
326
  widthRoot = el;
286
327
  return widthRoot;
287
328
  }
@@ -310,25 +351,34 @@ window.__ModuleLoader__.load({
310
351
  }
311
352
  }
312
353
 
313
- /* Resolve a tier to the px the app would clamp it to
314
- * (resolveContentWidth: max = column - handle budget), or
315
- * null when the tier leaves the width to the app. */
316
- function tierPx(tier, root) {
317
- const cap = Math.max(CONTENT_MIN_PX, root.offsetWidth - CONTENT_EDGE_BUDGET_PX);
354
+ /* The tier's configured target, before the app's column clamp.
355
+ * This is the value persisted to the app preference: on a
356
+ * narrow column the app clamps it for display, and when the
357
+ * window widens it restores the configured target instead of
358
+ * staying pinned to the old clamp. `full` is inherently the
359
+ * live maximum. */
360
+ function tierTargetPx(tier, root) {
318
361
  switch (tier) {
319
362
  case "medium":
320
363
  case "wide":
321
364
  case "ultra":
322
- return Math.min(NOMINAL_WIDTH_PX[tier], cap);
323
- case "full": return cap;
324
- case "custom": {
325
- const dragged = readStoredNumber(CUSTOM_KEY);
326
- return dragged === null ? null : Math.min(dragged, cap);
327
- }
365
+ return NOMINAL_WIDTH_PX[tier];
366
+ case "full":
367
+ return Math.max(CONTENT_MIN_PX, root.offsetWidth - CONTENT_EDGE_BUDGET_PX);
368
+ case "custom": return readStoredNumber(CUSTOM_KEY);
328
369
  default: return null; /* standard: app default */
329
370
  }
330
371
  }
331
372
 
373
+ /* The px actually applied to the column (same floor/max as the
374
+ * app's resolveContentWidth). */
375
+ function appliedTierPx(tier, root) {
376
+ const target = tierTargetPx(tier, root);
377
+ if (target === null) return null;
378
+ const cap = Math.max(CONTENT_MIN_PX, root.offsetWidth - CONTENT_EDGE_BUDGET_PX);
379
+ return Math.min(Math.max(target, CONTENT_MIN_PX), cap);
380
+ }
381
+
332
382
  /* The transcript width the app currently resolves for the live
333
383
  * column (after its own clamp), used by the custom-drag guide.
334
384
  * During a drag the app writes the clamped px to the target's
@@ -355,28 +405,47 @@ window.__ModuleLoader__.load({
355
405
  /* custom before the first drag has no width of its own —
356
406
  * leave whatever the app last published in place. */
357
407
  if (tier === "custom" && readStoredNumber(CUSTOM_KEY) === null) return;
358
- const px = tierPx(tier, root);
359
- if (px === null) {
360
- if (target.style.getPropertyValue("--dsh-chat-user-width") !== "") {
408
+ const targetPx = tierTargetPx(tier, root);
409
+ if (targetPx === null) {
410
+ /* standard: hand the width back to the app default. The
411
+ * app's width preference is cleared as well; standard is
412
+ * deliberately the reset to the app's default width. */
413
+ if (target.style.getPropertyValue("--dsh-chat-user-width") !== "" || preferenceOwned) {
361
414
  target.style.removeProperty("--dsh-chat-user-width");
362
415
  try {
363
416
  window.localStorage.removeItem(APP_PREF_KEY);
364
417
  } catch (e) {
365
418
  /* ignore */
366
419
  }
420
+ preferenceOwned = false;
421
+ preferenceWritten = null;
367
422
  }
368
423
  return;
369
424
  }
370
- /* Mirror into the app's preference so window resizes and
371
- * conversation remounts re-clamp through the app's own
372
- * publish cycle instead of relying on plugin healing. */
373
- const next = String(Math.round(px));
374
- if (target.style.getPropertyValue("--dsh-chat-user-width") !== next + "px") {
375
- target.style.setProperty("--dsh-chat-user-width", next + "px");
425
+ const laidOut = root.offsetWidth > 0;
426
+ /* `full` derives its target from the live column; without a
427
+ * layout box it would collapse to the 640px floor and pin a
428
+ * narrow width. Wait for the fallback/resize pass. */
429
+ if (!laidOut && tier === "full") return;
430
+ if (laidOut) {
431
+ const px = appliedTierPx(tier, root);
432
+ if (px !== null) {
433
+ const next = String(Math.round(px));
434
+ if (target.style.getPropertyValue("--dsh-chat-user-width") !== next + "px") {
435
+ target.style.setProperty("--dsh-chat-user-width", next + "px");
436
+ }
437
+ }
376
438
  }
439
+ /* Persist the unclamped target even while the root is not
440
+ * laid out (fixed tiers / recorded custom width do not
441
+ * depend on the column). The app clamps it for display and
442
+ * restores it after a resize / remount. */
443
+ const preference = String(Math.round(targetPx));
377
444
  try {
378
- if (window.localStorage.getItem(APP_PREF_KEY) !== next) {
379
- window.localStorage.setItem(APP_PREF_KEY, next);
445
+ if (window.localStorage.getItem(APP_PREF_KEY) !== preference) {
446
+ window.localStorage.setItem(APP_PREF_KEY, preference);
447
+ preferenceOwned = true;
448
+ preferenceWritten = preference;
380
449
  }
381
450
  } catch (e) {
382
451
  /* ignore */
@@ -386,25 +455,30 @@ window.__ModuleLoader__.load({
386
455
  /* true while the CURRENT tier is the one that collapsed the sidebar */
387
456
  let collapsedByTier = false;
388
457
 
389
- function applyTier(tier) {
458
+ /* `persist` is false when another tab already wrote the tier:
459
+ * adopting it must not write localStorage again. */
460
+ function applyTier(tier, persist = true) {
390
461
  body().setAttribute("data-dsh-width", tier);
391
- try {
392
- window.localStorage.setItem(KEY, tier);
393
- } catch (e) {
394
- /* storage unavailable — state still applies this session */
462
+ if (persist) {
463
+ try {
464
+ window.localStorage.setItem(KEY, tier);
465
+ } catch (e) {
466
+ /* storage unavailable — state still applies this session */
467
+ }
395
468
  }
396
469
  applyContentWidth(tier);
397
470
  if (tier === "full") {
398
471
  if (!isSidebarCollapsed()) {
399
472
  collapseSidebar();
400
473
  collapsedByTier = true;
401
- } else {
402
- collapsedByTier = false;
403
474
  }
475
+ /* already collapsed: don't claim ownership we may not have */
404
476
  } else if (tier === "standard") {
405
477
  if (collapsedByTier && isSidebarCollapsed()) expandSidebar();
406
478
  collapsedByTier = false;
407
- } else {
479
+ } else if (!isSidebarCollapsed()) {
480
+ /* the user (or another panel) expanded it — the plugin no
481
+ * longer owns the collapsed state */
408
482
  collapsedByTier = false;
409
483
  }
410
484
  syncButton();
@@ -424,11 +498,13 @@ window.__ModuleLoader__.load({
424
498
  for (let i = 0; i < TIERS.length; i++) {
425
499
  if (TIERS[i].id === tier) idx = i;
426
500
  }
427
- const widths = [3.5, 5.5, 7.5, 10, 13];
501
+ /* One bar per tier so full (5 bars) and custom (6 bars,
502
+ * all lit) remain distinguishable. */
503
+ const widths = [3.5, 5.3, 7.1, 8.9, 10.7, 12.5];
428
504
  let bars = "";
429
- for (let b = 0; b < 5; b++) {
430
- const y = 0.5 + b * 3;
431
- bars += `<rect x="1.5" y="${y}" width="${widths[b]}" height="2" rx="1" fill="currentColor" opacity="${b <= idx ? 1 : 0.3}"/>`;
505
+ for (let b = 0; b < TIERS.length; b++) {
506
+ const y = 0.75 + b * 2.55;
507
+ bars += `<rect x="1.5" y="${y}" width="${widths[b]}" height="1.6" rx="0.8" fill="currentColor" opacity="${b <= idx ? 1 : 0.3}"/>`;
432
508
  }
433
509
  return `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">${bars}</svg>`;
434
510
  }
@@ -493,6 +569,9 @@ window.__ModuleLoader__.load({
493
569
  item.type = "button";
494
570
  item.setAttribute("role", "menuitemradio");
495
571
  item.setAttribute("data-tier", def.id);
572
+ /* Roving focus: the menu is opened by focusing one item, and
573
+ * ArrowUp/Down/Home/End move between items. */
574
+ item.tabIndex = -1;
496
575
  item.innerHTML = `<span class="dshwm-label">${t(def.key)}</span><span class="dshwm-value">${tierValueText(def.id)}</span>`;
497
576
  item.addEventListener("click", (e) => {
498
577
  if (e && e.stopPropagation) e.stopPropagation();
@@ -500,12 +579,12 @@ window.__ModuleLoader__.load({
500
579
  * open the right-edge guide instead of applying a value. */
501
580
  if (def.id === "custom" && readStoredNumber(CUSTOM_KEY) === null) {
502
581
  beginCustomGuide();
503
- hideMenu();
582
+ hideMenu(true);
504
583
  return;
505
584
  }
506
585
  endGuide(false);
507
586
  applyTier(def.id);
508
- hideMenu();
587
+ hideMenu(true);
509
588
  });
510
589
  menu.appendChild(item);
511
590
  }
@@ -532,25 +611,36 @@ window.__ModuleLoader__.load({
532
611
  }
533
612
  }
534
613
 
614
+ function menuItems() {
615
+ return Array.from(menu.querySelectorAll("button[data-tier]"));
616
+ }
617
+
535
618
  function showMenu() {
536
619
  menu.hidden = false;
537
620
  btn.setAttribute("aria-expanded", "true");
538
621
  syncMenu();
622
+ const items = menuItems();
623
+ const selected = items.find((item) => item.getAttribute("data-tier") === currentTier()) || items[0];
624
+ if (selected && typeof selected.focus === "function") selected.focus();
539
625
  }
540
626
 
541
- function hideMenu() {
627
+ /* restoreFocus is true for Escape / item selection, false for a
628
+ * click-away so the click target keeps focus. */
629
+ function hideMenu(restoreFocus) {
630
+ const hadFocus = menu.contains(document.activeElement);
542
631
  menu.hidden = true;
543
632
  btn.setAttribute("aria-expanded", "false");
633
+ if (restoreFocus && hadFocus && typeof btn.focus === "function") btn.focus();
544
634
  }
545
635
 
546
636
  btn.addEventListener("click", (e) => {
547
637
  if (e && e.stopPropagation) e.stopPropagation();
548
638
  if (menu.hidden) showMenu();
549
- else hideMenu();
639
+ else hideMenu(true);
550
640
  });
551
641
 
552
642
  const onDocClick = (e) => {
553
- if (!menu.hidden && e.target !== btn && !menu.contains(e.target)) hideMenu();
643
+ if (!menu.hidden && e.target !== btn && !menu.contains(e.target)) hideMenu(false);
554
644
  /* Clicking anywhere but the guide's handle cancels a
555
645
  * pending custom guide and restores the previous tier. */
556
646
  if (guideActive && !guideDragging && e.target !== btn && !menu.contains(e.target) && !isHandleEvent(e)) {
@@ -558,9 +648,29 @@ window.__ModuleLoader__.load({
558
648
  }
559
649
  };
560
650
  const onDocKey = (e) => {
561
- if (e.key !== "Escape") return;
562
- if (!menu.hidden) hideMenu();
563
- if (guideActive) endGuide(true);
651
+ if (e.key === "Escape") {
652
+ if (!menu.hidden) hideMenu(true);
653
+ if (guideActive) endGuide(true);
654
+ return;
655
+ }
656
+ if (menu.hidden) return;
657
+ if (e.key === "Tab") {
658
+ /* Let Tab move on, but do not leave an orphaned menu. */
659
+ hideMenu(false);
660
+ return;
661
+ }
662
+ const items = menuItems();
663
+ if (items.length === 0) return;
664
+ const current = items.indexOf(document.activeElement);
665
+ let next = -1;
666
+ if (e.key === "ArrowDown") next = current < 0 ? 0 : (current + 1) % items.length;
667
+ else if (e.key === "ArrowUp") next = current < 0 ? items.length - 1 : (current - 1 + items.length) % items.length;
668
+ else if (e.key === "Home") next = 0;
669
+ else if (e.key === "End") next = items.length - 1;
670
+ if (next < 0) return;
671
+ e.preventDefault();
672
+ const item = items[next];
673
+ if (typeof item.focus === "function") item.focus();
564
674
  };
565
675
  document.addEventListener("click", onDocClick);
566
676
  document.addEventListener("keydown", onDocKey);
@@ -589,6 +699,7 @@ window.__ModuleLoader__.load({
589
699
  * open the same bubble but never "cancel" a tier. */
590
700
  let guideFromMenu = false;
591
701
  let guideHideTimer = null;
702
+ let guideHandle = null;
592
703
  let tierBeforeGuide = "standard";
593
704
 
594
705
  /* The px the app currently resolves for the transcript, after
@@ -599,14 +710,17 @@ window.__ModuleLoader__.load({
599
710
  return px === null ? "—" : `${px}px`;
600
711
  }
601
712
 
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). */
713
+ /* Keep the bubble beside the handle the user actually grabbed
714
+ * (it moves with the dragged edge); fall back to the right
715
+ * handle / frame edge when the handle is not mounted yet
716
+ * (menu-opened guide, hero or settling phase). */
605
717
  function positionGuide() {
606
718
  if (!guideActive) return;
607
- const handle = document.querySelector('[data-width-handle="right"]');
719
+ const handle = guideHandle !== null && document.contains(guideHandle)
720
+ ? guideHandle
721
+ : document.querySelector('[data-width-handle="right"]');
608
722
  const viewport = window.innerWidth || 0;
609
- if (handle === null || viewport === 0) {
723
+ if (handle === null || typeof handle.getBoundingClientRect !== "function" || viewport === 0) {
610
724
  guide.style.left = "auto";
611
725
  guide.style.right = "24px";
612
726
  guide.style.top = "50%";
@@ -633,6 +747,7 @@ window.__ModuleLoader__.load({
633
747
  guideFromMenu = true;
634
748
  guideActive = true;
635
749
  guideDragging = false;
750
+ guideHandle = null;
636
751
  guide.hidden = false;
637
752
  body().setAttribute("data-dsh-width-guide", "custom");
638
753
  setGuideText("hint", t("guide.hint"));
@@ -642,10 +757,11 @@ window.__ModuleLoader__.load({
642
757
  /* Every handle press shows the live readout, not just the first
643
758
  * guided drag — a repeat custom drag must keep the handle
644
759
  * highlight and the width bubble. */
645
- function startLiveGuide() {
760
+ function startLiveGuide(handle) {
646
761
  window.clearTimeout(guideHideTimer);
647
762
  guideActive = true;
648
763
  guideDragging = true;
764
+ guideHandle = handle || null;
649
765
  guide.hidden = false;
650
766
  body().setAttribute("data-dsh-width-guide", "custom");
651
767
  setGuideText("live", guideValueText());
@@ -682,6 +798,7 @@ window.__ModuleLoader__.load({
682
798
  window.clearTimeout(guideHideTimer);
683
799
  guide.hidden = true;
684
800
  guide.dataset.mode = "hint";
801
+ guideHandle = null;
685
802
  body().removeAttribute("data-dsh-width-guide");
686
803
  const restore = restoreTier && guideFromMenu && readStoredNumber(CUSTOM_KEY) === null;
687
804
  guideFromMenu = false;
@@ -707,7 +824,7 @@ window.__ModuleLoader__.load({
707
824
 
708
825
  const onHandlePointerDown = (e) => {
709
826
  if (!isHandleEvent(e)) return;
710
- startLiveGuide();
827
+ startLiveGuide(e.target.closest("[data-width-handle]"));
711
828
  if (currentTier() === "custom") return;
712
829
  body().setAttribute("data-dsh-width", "custom");
713
830
  try {
@@ -715,7 +832,6 @@ window.__ModuleLoader__.load({
715
832
  } catch (err) {
716
833
  /* ignore */
717
834
  }
718
- collapsedByTier = false;
719
835
  syncButton();
720
836
  syncMenu();
721
837
  };
@@ -739,15 +855,32 @@ window.__ModuleLoader__.load({
739
855
  syncButton();
740
856
  syncMenu();
741
857
  };
858
+
859
+ /* A cancelled drag (touch gesture, window switch) ends without
860
+ * pointerup; without this handler the bubble would stay stuck
861
+ * in dragging mode and click-away would no longer dismiss it. */
862
+ const onHandlePointerCancel = () => {
863
+ /* The cancel target can be the capture element or the
864
+ * document; while a drag is active, end it regardless. */
865
+ if (!guideActive || !guideDragging) return;
866
+ endGuide(false);
867
+ syncButton();
868
+ syncMenu();
869
+ };
742
870
  document.addEventListener("pointerdown", onHandlePointerDown, true);
743
871
  document.addEventListener("pointerup", onHandlePointerUp, true);
872
+ document.addEventListener("pointercancel", onHandlePointerCancel, true);
744
873
 
745
874
  /* ---- feedback ring so every selection is visibly answered ---- */
746
875
  function pulse() {
747
876
  btn.classList.remove("pulse");
748
877
  void btn.offsetWidth;
749
878
  btn.classList.add("pulse");
750
- window.setTimeout(() => btn.classList.remove("pulse"), 700);
879
+ window.clearTimeout(pulseTimer);
880
+ pulseTimer = window.setTimeout(() => {
881
+ btn.classList.remove("pulse");
882
+ pulseTimer = null;
883
+ }, 700);
751
884
  }
752
885
 
753
886
  /* ---- re-render localized copy when the app language changes ---- */
@@ -762,6 +895,24 @@ window.__ModuleLoader__.load({
762
895
 
763
896
  /* ---- attach once the app shell has mounted ---- */
764
897
  let attached = false;
898
+ let readyTimer = null;
899
+ let slowTimer = null;
900
+ let readyTimeout = null;
901
+
902
+ function stopWaiting() {
903
+ if (readyTimer !== null) {
904
+ clearInterval(readyTimer);
905
+ readyTimer = null;
906
+ }
907
+ if (slowTimer !== null) {
908
+ clearInterval(slowTimer);
909
+ slowTimer = null;
910
+ }
911
+ if (readyTimeout !== null) {
912
+ window.clearTimeout(readyTimeout);
913
+ readyTimeout = null;
914
+ }
915
+ }
765
916
 
766
917
  /* The old gate matched [data-sidebar-collapsed] /
767
918
  * [data-details-collapsed]. 0.1.5-rc.2 renders collapsed
@@ -774,6 +925,7 @@ window.__ModuleLoader__.load({
774
925
  if (attached || !body()) return;
775
926
  if (document.querySelector("[data-shell-overlay]") === null) return;
776
927
  attached = true;
928
+ stopWaiting();
777
929
  try {
778
930
  window.localStorage.removeItem(OLD_KEY);
779
931
  } catch (e) {
@@ -786,14 +938,34 @@ window.__ModuleLoader__.load({
786
938
  const saved = getSavedTier();
787
939
  if (saved !== "standard") applyTier(saved);
788
940
  startHealing();
941
+ if (widthRoot !== null) observeWidthTarget(widthRoot);
789
942
  }
790
943
 
791
- const readyTimer = setInterval(attach, 300);
792
- window.addEventListener("load", attach);
793
- const timeout = window.setTimeout(() => {
944
+ readyTimer = setInterval(attach, 300);
945
+ readyTimeout = window.setTimeout(() => {
946
+ if (attached) return;
794
947
  clearInterval(readyTimer);
795
- attach();
948
+ readyTimer = null;
949
+ /* A very slow boot should not leave the picker permanently
950
+ * unmounted: keep a low-frequency retry instead of giving up. */
951
+ slowTimer = setInterval(attach, 2000);
796
952
  }, READY_TIMEOUT_MS);
953
+ window.addEventListener("load", attach);
954
+
955
+ /* Tier changes from another tab: adopt the stored tier without
956
+ * writing localStorage again (that would loop). */
957
+ const onStorage = (e) => {
958
+ if (!e || e.key !== KEY) return;
959
+ /* No storageArea identity check: spec does not require the
960
+ * event object to be the same Storage instance as
961
+ * window.localStorage, and dropping the event would break
962
+ * cross-tab sync. */
963
+ const next = e.newValue;
964
+ if (typeof next !== "string" || !TIERS.some((tier) => tier.id === next)) return;
965
+ if (currentTier() === next) return;
966
+ applyTier(next, false);
967
+ };
968
+ window.addEventListener("storage", onStorage);
797
969
 
798
970
  /* ---- self-heal: re-apply the width when the chat tree
799
971
  * remounts (conversation switch). Panels are never managed
@@ -812,15 +984,51 @@ window.__ModuleLoader__.load({
812
984
  let rootRetryDelay = ROOT_RETRY_MIN_MS;
813
985
  let rootRetryTimer = null;
814
986
  let healFrame = null;
987
+ let resizeFrame = null;
815
988
  let observer = null;
816
989
  let fallbackTimer = null;
990
+ let widthObserver = null;
991
+ let observedTarget = null;
992
+
993
+ /* Return the frame id so heal and resize can be debounced and
994
+ * cancelled independently. The old helper always wrote the
995
+ * heal slot, so resizeFrame never became non-null and every
996
+ * resize event scheduled another frame. */
997
+ function scheduleFrame(cb) {
998
+ return typeof requestAnimationFrame === "function"
999
+ ? requestAnimationFrame(cb)
1000
+ : window.setTimeout(cb, 0);
1001
+ }
1002
+ function cancelFrame(id) {
1003
+ if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(id);
1004
+ else window.clearTimeout(id);
1005
+ }
817
1006
 
818
- const scheduleFrame = typeof requestAnimationFrame === "function"
819
- ? (cb) => { healFrame = requestAnimationFrame(cb); }
820
- : (cb) => { healFrame = window.setTimeout(cb, 0); };
821
- const cancelFrame = typeof cancelAnimationFrame === "function"
822
- ? (id) => cancelAnimationFrame(id)
823
- : (id) => window.clearTimeout(id);
1007
+ /* Watch the column that sizes the transcript. Sidebar/rightbar
1008
+ * changes and frame resizes alter it; `full` (and a clamped
1009
+ * fixed/custom width) must follow without polling every
1010
+ * mutation. Guarded for browsers/tests without ResizeObserver. */
1011
+ function observeWidthTarget(root) {
1012
+ if (typeof ResizeObserver === "undefined") return;
1013
+ const target = widthTarget(root);
1014
+ if (target === observedTarget && widthObserver !== null) return;
1015
+ if (widthObserver !== null) {
1016
+ widthObserver.disconnect();
1017
+ widthObserver = null;
1018
+ }
1019
+ observedTarget = target;
1020
+ widthObserver = new ResizeObserver(() => {
1021
+ /* Never fight the app's native drag: it owns the width
1022
+ * while the user is moving a handle. */
1023
+ if (guideActive && guideDragging) return;
1024
+ const tier = currentTier();
1025
+ if (tier === "standard") return;
1026
+ applyContentWidth(tier);
1027
+ syncButton();
1028
+ syncMenu();
1029
+ });
1030
+ widthObserver.observe(target);
1031
+ }
824
1032
 
825
1033
  function scheduleRootRetry() {
826
1034
  if (rootRetryTimer !== null) return;
@@ -836,7 +1044,12 @@ window.__ModuleLoader__.load({
836
1044
  } else {
837
1045
  rootMissing = false;
838
1046
  rootRetryDelay = ROOT_RETRY_MIN_MS;
839
- if (root !== prev) applyContentWidth(tier);
1047
+ if (root !== prev) {
1048
+ applyContentWidth(tier);
1049
+ syncButton();
1050
+ syncMenu();
1051
+ }
1052
+ observeWidthTarget(root);
840
1053
  }
841
1054
  }, rootRetryDelay);
842
1055
  }
@@ -845,15 +1058,18 @@ window.__ModuleLoader__.load({
845
1058
  /* The right handle only mounts in the active phase; keep the
846
1059
  * guide pinned to it once the chat tree settles. */
847
1060
  if (guideActive) positionGuide();
1061
+ /* The app's native drag owns the inline width until release;
1062
+ * a heal must not restore the previous custom value on top. */
1063
+ if (guideActive && guideDragging) return;
848
1064
  const tier = currentTier();
849
1065
  if (tier === "standard") return;
850
1066
  const prev = widthRoot;
851
1067
  let root;
852
- if (rootMissing) {
853
- /* Mid-remount: a mutation-driven heal only walks the
854
- * recorded path (O(depth)) — the backoff retry owns
855
- * full scans until the new root exists. */
856
- root = fromObserver ? findWidthRoot(false) : findWidthRoot();
1068
+ if (rootMissing || fromObserver) {
1069
+ /* Mid-remount / mutation churn: walk the recorded path
1070
+ * only (O(depth)); the backoff retry owns full scans
1071
+ * until the new root exists. */
1072
+ root = findWidthRoot(false);
857
1073
  } else {
858
1074
  root = findWidthRoot();
859
1075
  }
@@ -861,27 +1077,33 @@ window.__ModuleLoader__.load({
861
1077
  if (!rootMissing) {
862
1078
  rootMissing = true;
863
1079
  rootRetryDelay = ROOT_RETRY_MIN_MS;
864
- scheduleRootRetry();
865
1080
  }
1081
+ /* Idempotent: keep a backoff retry alive even if the
1082
+ * previous one ended while this tier was standard. */
1083
+ scheduleRootRetry();
866
1084
  return;
867
1085
  }
868
1086
  if (rootMissing) {
869
1087
  rootMissing = false;
870
1088
  rootRetryDelay = ROOT_RETRY_MIN_MS;
871
1089
  }
872
- if (root !== prev) applyContentWidth(tier);
873
- /* full tracks the live column: after the sidebar collapse
874
- * animation settles the column widens, and the width must
875
- * follow (applyContentWidth is diff-checked, so steady
876
- * state writes nothing). */
877
- if (tier === "full") applyContentWidth("full");
1090
+ observeWidthTarget(root);
1091
+ if (root !== prev) {
1092
+ applyContentWidth(tier);
1093
+ syncButton();
1094
+ syncMenu();
1095
+ } else if (!fromObserver) {
1096
+ /* Fallback tick: retry an apply that was skipped while
1097
+ * the root had no layout box. */
1098
+ applyContentWidth(tier);
1099
+ }
878
1100
  }
879
1101
 
880
1102
  function startHealing() {
881
1103
  if (typeof MutationObserver !== "undefined") {
882
1104
  observer = new MutationObserver(() => {
883
1105
  if (healFrame !== null) return;
884
- scheduleFrame(() => {
1106
+ healFrame = scheduleFrame(() => {
885
1107
  healFrame = null;
886
1108
  heal(true);
887
1109
  });
@@ -894,7 +1116,7 @@ window.__ModuleLoader__.load({
894
1116
  });
895
1117
  }
896
1118
  /* Low-frequency fallback so self-heal still runs if the
897
- * observer misses something — cheap: no scan while the
1119
+ * observer misses something — cheap: no full scan while the
898
1120
  * cached root is alive. */
899
1121
  fallbackTimer = setInterval(() => heal(false), FALLBACK_HEAL_MS);
900
1122
  }
@@ -902,12 +1124,14 @@ window.__ModuleLoader__.load({
902
1124
  /* ---- keep the picker and the guide aligned with the live
903
1125
  * column. Standard/full re-resolve on resize, and the guide
904
1126
  * must stay beside the handle when the frame resizes. ---- */
905
- let resizeFrame = null;
906
-
907
1127
  const onWindowResize = () => {
908
1128
  if (resizeFrame !== null) return;
909
- scheduleFrame(() => {
1129
+ resizeFrame = scheduleFrame(() => {
910
1130
  resizeFrame = null;
1131
+ if (!(guideActive && guideDragging)) {
1132
+ const tier = currentTier();
1133
+ if (tier !== "standard") applyContentWidth(tier);
1134
+ }
911
1135
  syncButton();
912
1136
  syncMenu();
913
1137
  positionGuide();
@@ -915,34 +1139,70 @@ window.__ModuleLoader__.load({
915
1139
  };
916
1140
  window.addEventListener("resize", onWindowResize);
917
1141
 
1142
+ /* All healing/observer state is initialized by now; try an
1143
+ * immediate mount instead of waiting for the first interval. */
1144
+ attach();
1145
+
918
1146
  /* ---- disposer ---- */
919
1147
  return () => {
920
1148
  unsubscribeLocale();
921
- clearInterval(readyTimer);
1149
+ stopWaiting();
922
1150
  clearInterval(fallbackTimer);
923
- clearTimeout(timeout);
924
1151
  clearTimeout(rootRetryTimer);
925
1152
  clearTimeout(guideHideTimer);
1153
+ clearTimeout(pulseTimer);
926
1154
  if (healFrame !== null) cancelFrame(healFrame);
927
1155
  if (resizeFrame !== null) cancelFrame(resizeFrame);
1156
+ if (widthObserver !== null) {
1157
+ widthObserver.disconnect();
1158
+ widthObserver = null;
1159
+ observedTarget = null;
1160
+ }
928
1161
  window.removeEventListener("resize", onWindowResize);
1162
+ window.removeEventListener("load", attach);
1163
+ window.removeEventListener("storage", onStorage);
929
1164
  if (observer !== null) observer.disconnect();
930
1165
  document.removeEventListener("click", onDocClick);
931
1166
  document.removeEventListener("keydown", onDocKey);
932
1167
  document.removeEventListener("pointerdown", onHandlePointerDown, true);
933
1168
  document.removeEventListener("pointerup", onHandlePointerUp, true);
1169
+ document.removeEventListener("pointercancel", onHandlePointerCancel, true);
934
1170
  document.removeEventListener("pointermove", onGuidePointerMove, true);
935
1171
  if (btn.parentNode) btn.parentNode.removeChild(btn);
936
1172
  if (menu.parentNode) menu.parentNode.removeChild(menu);
937
1173
  if (guide.parentNode) guide.parentNode.removeChild(guide);
938
1174
  if (styleTag.parentNode) styleTag.parentNode.removeChild(styleTag);
939
- /* Hand the width back to the app default; the app's own
940
- * preference (which this plugin mirrors) stays so the last
941
- * width survives a plugin reload — matching native-drag
942
- * semantics. */
1175
+ /* Hand the width back to the app default. Only a value this
1176
+ * plugin wrote is removed, so a native drag preference that
1177
+ * predates the plugin survives unload. */
943
1178
  if (widthRoot !== null) {
944
1179
  widthTarget(widthRoot).style.removeProperty("--dsh-chat-user-width");
945
1180
  }
1181
+ if (preferenceOwned) {
1182
+ try {
1183
+ /* Only remove the exact value this plugin wrote; a
1184
+ * native drag since then owns the preference. */
1185
+ if (preferenceWritten !== null
1186
+ && window.localStorage.getItem(APP_PREF_KEY) === preferenceWritten) {
1187
+ window.localStorage.removeItem(APP_PREF_KEY);
1188
+ }
1189
+ } catch (e) {
1190
+ /* ignore */
1191
+ }
1192
+ preferenceOwned = false;
1193
+ preferenceWritten = null;
1194
+ }
1195
+ /* Undo a collapse this plugin owned; a manual collapse is
1196
+ * left alone. */
1197
+ if (collapsedByTier) {
1198
+ try {
1199
+ if (isSidebarCollapsed()) layout.toggleSidebar();
1200
+ } catch (e) {
1201
+ /* ignore */
1202
+ }
1203
+ collapsedByTier = false;
1204
+ }
1205
+ guideHandle = null;
946
1206
  body().removeAttribute("data-dsh-width");
947
1207
  body().removeAttribute("data-dsh-width-guide");
948
1208
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-width-tiers",
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).",
3
+ "version": "1.0.9",
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.2 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": {
@@ -48,6 +48,9 @@
48
48
  "type": "git",
49
49
  "url": "git+https://github.com/aaronlei/dsh-width-tiers.git"
50
50
  },
51
+ "scripts": {
52
+ "test": "node test.cjs"
53
+ },
51
54
  "engines": {
52
55
  "node": ">=18"
53
56
  }