dsh-width-tiers 1.0.8 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -4,141 +4,152 @@ window.__ModuleLoader__.load({
4
4
  var module = { exports: {} };
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
-
8
- //#region lib/types/client/index.js
9
- /**
10
- * dsh-width-tiers — chat content width tiers for the DSH Web GUI.
11
- *
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:
24
- *
25
- * 标准 standard app default (no override)
26
- * 中等 medium 1120px (sidebar untouched)
27
- * 宽 wide 1280px (sidebar untouched)
28
- * 超宽 ultra 1400px (sidebar untouched)
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). 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.
43
- *
44
- * - Panels are driven through the layout service (ctx.layout), never
45
- * by simulating clicks.
46
- * - The sidebar is only ever collapsed by the `full` tier, through the
47
- * app's own toggle — never force-hidden, never locked. Going back to
48
- * `standard` restores it only if this plugin collapsed it.
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.
52
- * - The chosen tier persists in localStorage and is restored on the
53
- * next visit.
54
- * - All copy is localized through the app's locale service: tier
55
- * labels and the button's aria/title follow Settings → Language
56
- * (zh / en, browser-derived fallback) and re-render on switch.
57
- */
7
+ let react = require("react");
8
+ let react_jsx_runtime = require("react/jsx-runtime");
9
+ // src/client/constants.ts
10
+ /** LocalStorage key holding the selected tier. */
58
11
  const KEY = "dsh.widthTier";
12
+ /** Legacy key from the pre-tier releases; removed on attach. */
59
13
  const OLD_KEY = "dsh.wide";
60
14
  /** Last user-dragged width (px), backing the `custom` tier. */
61
15
  const CUSTOM_KEY = "dsh.widthTierCustom";
62
16
  /** The app's own transcript width preference (ConversationWidthControls). */
63
17
  const APP_PREF_KEY = "dsh.conversation.contentWidth";
64
- /* Mirrors ConversationWidthControls in 0.1.7-alpha.1: clamp floor and
65
- * the horizontal budget both width handles + safe edges reserve. */
66
- const CONTENT_MIN_PX = 640;
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;
75
- const READY_TIMEOUT_MS = 30000;
76
- /* Self-heal cadence: DOM-driven (MutationObserver scheduling on the
77
- * next animation frame — before paint, so the width never flashes
78
- * default) with a low-frequency fallback and backoff retries while
79
- * the width root is missing (conversation-switch remount). */
80
- const ROOT_RETRY_MIN_MS = 200;
81
- const ROOT_RETRY_MAX_MS = 2000;
82
- const FALLBACK_HEAL_MS = 2000;
83
-
84
- /** Dictionary namespace owned by this plugin. */
85
- const NS = "width-tiers";
86
-
87
- //#region lib/types/client/locales.js
88
- /** `width-tiers` namespace dictionaries (tier labels and picker copy). */
89
- /** Simplified Chinese dictionary (the key-set source of truth). */
90
- const zh = {
91
- "tier.standard": "标准",
92
- "tier.medium": "中等",
93
- "tier.wide": "宽",
94
- "tier.ultra": "超宽",
95
- "tier.full": "全宽",
96
- "tier.custom": "自定义",
97
- "value.custom.unset": "未设置",
98
- "guide.hint": "拖拽右侧边缘调整对话宽度",
99
- "button.aria": "对话区宽度档位:{label}({value}),点击选择档位",
100
- "button.title.summary": "对话区宽度档位:{label}({value})",
101
- "button.title.hint": "点击选择:{list}"
18
+ /** Nominal content widths of the fixed tiers (px). */
19
+ const NOMINAL_WIDTH_PX = {
20
+ medium: 1120,
21
+ wide: 1280,
22
+ ultra: 1400
102
23
  };
103
- /** English dictionary, checked complete against the zh key set. */
104
- const en = {
105
- "tier.standard": "Standard",
106
- "tier.medium": "Medium",
107
- "tier.wide": "Wide",
108
- "tier.ultra": "Ultra",
109
- "tier.full": "Full",
110
- "tier.custom": "Custom",
111
- "value.custom.unset": "Not set",
112
- "guide.hint": "Drag the right edge to resize",
113
- "button.aria": "Chat width tier: {label} ({value}). Click to choose a tier.",
114
- "button.title.summary": "Chat width tier: {label} ({value})",
115
- "button.title.hint": "Click to choose: {list}"
116
- };
117
- //#endregion
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). */
24
+ const DEFAULT_COLUMN_RATIO = .64;
25
+ /** How long attach waits before falling back to a slow retry. */
26
+ const READY_TIMEOUT_MS = 3e4;
27
+ const ROOT_RETRY_MAX_MS = 2e3;
28
+ const FALLBACK_HEAL_MS = 2e3;
29
+ /** Attribute the plugin sets on <body> while a tier is active. */
30
+ const TIER_ATTR = "data-dsh-width";
31
+ /** Attribute the plugin sets on <body> while the custom-drag guide is open. */
32
+ const GUIDE_ATTR = "data-dsh-width-guide";
33
+ // src/client/model.ts
122
34
  const TIERS = [
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" }
35
+ {
36
+ id: "standard",
37
+ key: "tier.standard"
38
+ },
39
+ {
40
+ id: "medium",
41
+ key: "tier.medium"
42
+ },
43
+ {
44
+ id: "wide",
45
+ key: "tier.wide"
46
+ },
47
+ {
48
+ id: "ultra",
49
+ key: "tier.ultra"
50
+ },
51
+ {
52
+ id: "full",
53
+ key: "tier.full"
54
+ },
55
+ {
56
+ id: "custom",
57
+ key: "tier.custom"
58
+ }
131
59
  ];
132
-
60
+ /**
61
+ * Resolve a tier definition by id, falling back to `standard`.
62
+ * @param id - stored or attributed tier id.
63
+ * @returns the matching definition, or `standard`.
64
+ */
133
65
  function tierOf(id) {
134
- for (let i = 0; i < TIERS.length; i++) {
135
- if (TIERS[i].id === id) return TIERS[i];
136
- }
66
+ for (const tier of TIERS) if (tier.id === id) return tier;
137
67
  return TIERS[0];
138
68
  }
139
-
140
- //#region lib/types/client/styles.js
141
- /** Button + menu styles (theme variables, light/dark aware). */
69
+ /**
70
+ * Read a positive persisted number, or null when absent or corrupt.
71
+ * @param storage - backing storage.
72
+ * @param key - storage key.
73
+ * @returns the parsed positive number, or null.
74
+ */
75
+ function readStoredNumber(storage, key) {
76
+ try {
77
+ const value = Number(storage.getItem(key));
78
+ return Number.isFinite(value) && value > 0 ? value : null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+ /**
84
+ * Read the saved tier, defaulting to `standard` when absent or unknown.
85
+ * @param storage - backing storage.
86
+ * @returns a valid tier id.
87
+ */
88
+ function getSavedTier(storage) {
89
+ try {
90
+ const value = storage.getItem(KEY);
91
+ const found = TIERS.find((tier) => tier.id === value);
92
+ return found === void 0 ? "standard" : found.id;
93
+ } catch {
94
+ return "standard";
95
+ }
96
+ }
97
+ /**
98
+ * The tier's configured target, before the app's column clamp. This is the
99
+ * value persisted to the app preference: on a narrow column the app clamps it
100
+ * for display, and when the window widens it restores the configured target
101
+ * instead of staying pinned to the old clamp. `full` is inherently the live
102
+ * maximum; `standard` has no target and delegates to the app default.
103
+ * @param tier - selected tier.
104
+ * @param columnWidth - live width of the app's width column (px).
105
+ * @param customPx - recorded custom width, if any.
106
+ * @returns the target width in px, or null for the app default.
107
+ */
108
+ function tierTargetPx(tier, columnWidth, customPx) {
109
+ switch (tier) {
110
+ case "medium":
111
+ case "wide":
112
+ case "ultra": return NOMINAL_WIDTH_PX[tier];
113
+ case "full": return fullWidthPx(columnWidth);
114
+ case "custom": return customPx;
115
+ default: return null;
116
+ }
117
+ }
118
+ /**
119
+ * The px actually applied to the column (same floor/max as the app's
120
+ * `resolveContentWidth`).
121
+ * @param tier - selected tier.
122
+ * @param columnWidth - live width of the app's width column (px).
123
+ * @param customPx - recorded custom width, if any.
124
+ * @returns the clamped width in px, or null for the app default.
125
+ */
126
+ function appliedTierPx(tier, columnWidth, customPx) {
127
+ const target = tierTargetPx(tier, columnWidth, customPx);
128
+ if (target === null) return null;
129
+ const cap = fullWidthPx(columnWidth);
130
+ return Math.min(Math.max(target, 640), cap);
131
+ }
132
+ /**
133
+ * Resolve the app's default transcript width for a column.
134
+ * @param columnWidth - live width of the app's width column (px).
135
+ * @returns `max(680, min(column × 0.64, 920))`.
136
+ */
137
+ function defaultWidthPx(columnWidth) {
138
+ return Math.max(680, Math.min(columnWidth * DEFAULT_COLUMN_RATIO, 920));
139
+ }
140
+ /**
141
+ * Resolve the widest transcript the column can hold (app clamp maximum).
142
+ * @param columnWidth - live width of the app's width column (px).
143
+ * @returns `max(640, column − 176)`.
144
+ */
145
+ function fullWidthPx(columnWidth) {
146
+ return Math.max(640, columnWidth - 176);
147
+ }
148
+ // src/client/styles.ts
149
+ /**
150
+ * Button + menu styles. Theme variables keep the picker in the app's light /
151
+ * dark palettes; every selector is scoped to this plugin's own ids and classes.
152
+ */
142
153
  const css = [
143
154
  "#dsh-wide-toggle{position:fixed;right:10px;bottom:216px;z-index:999;display:flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;border:1px solid var(--dsw-alias-border-l2);border-radius:50%;overflow:hidden;background:var(--dsw-alias-button-floating-fill);color:var(--dsw-alias-label-secondary);box-shadow:0 2px 8px rgba(0,0,0,.18);cursor:pointer;opacity:.72;transition:opacity .2s ease,width .2s ease,border-radius .2s ease,background .2s ease,color .2s ease}",
144
155
  "#dsh-wide-toggle::after{content:'';position:fixed;right:10px;bottom:216px;width:36px;height:36px;pointer-events:auto}",
@@ -148,11 +159,13 @@ window.__ModuleLoader__.load({
148
159
  "#dsh-wide-toggle:not(:hover):not(:focus-visible) svg rect{width:6px;x:5px}",
149
160
  "#dsh-wide-toggle.active{background:var(--dsw-alias-state-business-primary);border-color:transparent;color:var(--dsw-static-neutral-00);opacity:1}",
150
161
  "#dsh-wide-toggle.pulse{animation:dsh-wide-pulse .65s ease-out}",
162
+ "@media (prefers-reduced-motion:reduce){#dsh-wide-toggle{transition:none}#dsh-wide-toggle.pulse{animation:none}}",
151
163
  "@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
164
  "#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
165
  "#dsh-width-menu[hidden]{display:none}",
154
166
  "#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
167
  "#dsh-width-menu button:hover{background:var(--dsw-alias-interactive-bg-hover)}",
168
+ "#dsh-width-menu button:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}",
156
169
  "#dsh-width-menu button.selected{background:var(--dsw-alias-interactive-bg-hover-solid);color:var(--dsw-alias-state-business-primary)}",
157
170
  "#dsh-width-menu .dshwm-value{color:var(--dsw-alias-label-tertiary);font-size:12px}",
158
171
  "#dsh-width-menu button.selected .dshwm-value{color:var(--dsw-alias-state-business-primary)}",
@@ -160,796 +173,969 @@ window.__ModuleLoader__.load({
160
173
  "#dsh-width-guide[hidden]{display:none}",
161
174
  "#dsh-width-guide svg{flex:none;color:var(--dsw-alias-state-business-primary)}",
162
175
  "#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}"
176
+ "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}",
177
+ "body[data-dsh-width-guide] [data-width-handle]::after{opacity:1}"
165
178
  ].join("");
166
- //#endregion
167
-
168
- /** Services required by the browser bundle. */
169
- const inject = ["layout", "locale"];
170
-
171
179
  /**
172
- * Client plugin body: inject the styles, build the picker button and
173
- * menu, and drive the width tiers through the layout service.
174
- * @param ctx - client root context (layout + locale injected).
175
- */
176
- function apply(ctx) {
177
- ctx.effect(() => ctx.locale.register(NS, {
178
- zh,
179
- en
180
- }), "dsh-width-tiers: dictionaries");
181
- ctx.effect(() => {
182
- if (typeof document === "undefined") return;
183
-
184
- const styleTag = document.createElement("style");
185
- styleTag.dataset.plugin = "dsh-width-tiers";
186
- styleTag.textContent = css;
187
- document.head.appendChild(styleTag);
188
-
189
- const layout = ctx.layout;
190
- const t = ctx.locale.bind(NS);
191
-
192
- /* ---- helpers ---- */
193
- const body = () => document.body;
194
-
195
- function currentTier() {
196
- return body().getAttribute("data-dsh-width") || "standard";
197
- }
198
-
199
- function getSavedTier() {
200
- try {
201
- const v = window.localStorage.getItem(KEY);
202
- for (const t of TIERS) {
203
- if (t.id === v) return v;
204
- }
205
- } catch (e) {
206
- /* ignore */
207
- }
208
- return "standard";
209
- }
210
-
211
- /* The AppFrame sets data-sidebar-collapsed on the frame element
212
- * (conditionally, only while collapsed) after each render. */
213
- function isSidebarCollapsed() {
214
- return document.querySelector("[data-sidebar-collapsed]") !== null;
215
- }
216
-
217
- /* NOTE: the 1.0.x "close the right panel on non-standard tiers"
218
- * self-heal is gone on purpose. On 0.1.7 the rightbar panel
219
- * lives in its own seat: closeRightbar() only retracts the
220
- * grid TRACK (rightbarTrack=false) while the seat stays
221
- * expanded, so the panel keeps rendering as an OVERLAY pinned
222
- * to the frame's right edge — hanging over and covering the
223
- * conversation (对话窗口被边侧栏覆盖). The plugin must never
224
- * touch the rightbar; the app's own width axis already yields
225
- * space to the panel. */
226
-
227
- function collapseSidebar() {
228
- if (!isSidebarCollapsed()) layout.toggleSidebar();
229
- }
230
-
231
- function expandSidebar() {
232
- if (isSidebarCollapsed()) layout.toggleSidebar();
180
+ * Install the plugin stylesheet and return its removal disposer.
181
+ * @returns removes the owned style element from the document head.
182
+ */
183
+ function installStyles() {
184
+ const styleTag = document.createElement("style");
185
+ styleTag.dataset.plugin = "dsh-width-tiers";
186
+ styleTag.textContent = css;
187
+ document.head.appendChild(styleTag);
188
+ return () => {
189
+ if (styleTag.parentNode) styleTag.parentNode.removeChild(styleTag);
190
+ };
191
+ }
192
+ // src/client/width-root.ts
193
+ /** Whether an element resolves a non-empty `--dsh-chat-content-width`. */
194
+ function hasWidthVar(el) {
195
+ return getComputedStyle(el).getPropertyValue("--dsh-chat-content-width").trim() !== "";
196
+ }
197
+ function isSidebarConversation(el) {
198
+ return typeof el.closest === "function" && el.closest("[data-sidebar-chat]") !== null;
199
+ }
200
+ /**
201
+ * The element the app's ConversationWidthControls publishes
202
+ * `--dsh-conversation-column-width` / `--dsh-chat-user-width` on.
203
+ * @param root - element that declares `--dsh-chat-content-width`.
204
+ * @returns the parent element, or the root itself when detached.
205
+ */
206
+ function widthTarget(root) {
207
+ return root.parentElement ?? root;
208
+ }
209
+ /**
210
+ * Create the runtime locator. Cached; the app remounts the chat tree on
211
+ * conversation switch, so re-location first walks the recorded child-index
212
+ * path from <body> (React keeps the tree shape — O(depth), one style read)
213
+ * and only falls back to a full scan.
214
+ * @returns locator bound to this page's document.
215
+ */
216
+ function createWidthRootLocator() {
217
+ let widthRoot = null;
218
+ let widthPath = null;
219
+ function recordWidthPath(el) {
220
+ const path = [];
221
+ let node = el;
222
+ while (node && node !== document.body) {
223
+ const parent = node.parentNode;
224
+ if (!parent || !parent.children) break;
225
+ path.unshift(Array.prototype.indexOf.call(parent.children, node));
226
+ node = parent;
233
227
  }
234
-
235
- /* Locate the element that defines --dsh-chat-content-width at
236
- * runtime (first match wins; only one element declares it).
237
- * Cached; the app remounts the chat tree on conversation switch,
238
- * so re-location first walks the recorded child-index path from
239
- * <body> (React keeps the tree shape — O(depth), one style
240
- * read) and only falls back to a full scan. */
241
- let widthRoot = null;
242
- let widthPath = null;
243
-
244
- function hasWidthVar(el) {
245
- return getComputedStyle(el)
246
- .getPropertyValue("--dsh-chat-content-width")
247
- .trim() !== "";
228
+ widthPath = path.length > 0 ? path : null;
229
+ }
230
+ function firstWidthRoot(list) {
231
+ for (let i = 0; i < list.length; i++) {
232
+ const el = list[i];
233
+ if (el === void 0 || isSidebarConversation(el)) continue;
234
+ if (hasWidthVar(el)) return el;
248
235
  }
249
-
250
- function recordWidthPath(el) {
251
- const path = [];
252
- let node = el;
253
- while (node && node !== document.body) {
254
- const parent = node.parentNode;
255
- if (!parent || !parent.children) break;
256
- path.unshift(Array.prototype.indexOf.call(parent.children, node));
257
- node = parent;
258
- }
259
- widthPath = path.length > 0 ? path : null;
236
+ return null;
237
+ }
238
+ function scanWidthRoot() {
239
+ let found = firstWidthRoot(document.querySelectorAll("[data-conversation-content]"));
240
+ if (found === null) found = firstWidthRoot(document.querySelectorAll("[class]"));
241
+ if (found !== null) {
242
+ widthRoot = found;
243
+ recordWidthPath(widthRoot);
244
+ return widthRoot;
260
245
  }
261
-
262
- 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;
246
+ widthRoot = null;
247
+ widthPath = null;
248
+ return null;
249
+ }
250
+ function find(allowScan = true) {
251
+ if (widthRoot !== null && document.contains(widthRoot) && !isSidebarConversation(widthRoot)) return widthRoot;
252
+ if (widthPath !== null) {
253
+ let el = document.body;
254
+ for (let i = 0; i < widthPath.length; i++) {
255
+ if (el === null) break;
256
+ const next = el.children[widthPath[i]];
257
+ if (!next) {
258
+ el = null;
259
+ break;
269
260
  }
261
+ el = next;
270
262
  }
271
- widthRoot = null;
272
- widthPath = null;
273
- return null;
274
- }
275
-
276
- function findWidthRoot(allowScan) {
277
- if (widthRoot !== null && document.contains(widthRoot)) return widthRoot;
278
- if (widthPath !== null) {
279
- let el = document.body;
280
- for (let i = 0; i < widthPath.length; i++) {
281
- el = el.children[widthPath[i]];
282
- if (!el) break;
283
- }
284
- if (el && el !== widthRoot && document.contains(el) && hasWidthVar(el)) {
285
- widthRoot = el;
286
- return widthRoot;
287
- }
263
+ if (el !== null && el !== widthRoot && document.contains(el) && !isSidebarConversation(el) && hasWidthVar(el)) {
264
+ widthRoot = el;
265
+ return widthRoot;
288
266
  }
289
- if (allowScan === false) return null;
290
- return scanWidthRoot();
291
267
  }
292
-
293
- /* ---- 0.1.7 native width axis ----
294
- * The conversation body's parent is the element the app's
295
- * ConversationWidthControls publishes
296
- * `--dsh-conversation-column-width` / `--dsh-chat-user-width`
297
- * on. Writing there (never with `!important`) keeps native
298
- * drag handles in charge: during a drag the app rewrites the
299
- * same inline property every frame and wins the cascade. */
300
- function widthTarget(root) {
301
- return (root && root.parentElement) || root;
302
- }
303
-
304
- function readStoredNumber(key) {
305
- try {
306
- const v = Number(window.localStorage.getItem(key));
307
- return Number.isFinite(v) && v > 0 ? v : null;
308
- } catch (e) {
309
- return null;
310
- }
268
+ if (!allowScan) return null;
269
+ return scanWidthRoot();
270
+ }
271
+ return {
272
+ find,
273
+ cached: () => widthRoot,
274
+ reset: () => {
275
+ widthRoot = null;
276
+ widthPath = null;
311
277
  }
312
-
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);
318
- switch (tier) {
319
- case "medium":
320
- case "wide":
321
- 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
- }
328
- default: return null; /* standard: app default */
329
- }
278
+ };
279
+ }
280
+ function liveWidthPx(root) {
281
+ const target = widthTarget(root);
282
+ const inline = Number.parseFloat(target.style.getPropertyValue("--dsh-chat-user-width"));
283
+ if (Number.isFinite(inline) && inline > 0) return Math.round(inline);
284
+ const raw = getComputedStyle(root).getPropertyValue("--dsh-chat-content-width");
285
+ const computed = Number.parseFloat(raw);
286
+ if (Number.isFinite(computed) && computed > 0) return Math.round(computed);
287
+ return Math.round(defaultWidthPx(root.offsetWidth));
288
+ }
289
+ // src/client/controller.ts
290
+ /** Estimated bubble width used when placing the guide beside a drag handle. */
291
+ const GUIDE_WIDTH_PX = 180;
292
+ /** How long the settled width stays visible after a drag. */
293
+ const GUIDE_HIDE_MS = 800;
294
+ /** Default guide placement (frame edge, vertical center). */
295
+ const GUIDE_FALLBACK = {
296
+ left: "auto",
297
+ right: "24px",
298
+ top: "50%"
299
+ };
300
+ /**
301
+ * Own the width-tier model: the app's native width axis, the tier/guide state
302
+ * machine and persistence. Rendering lives in the React picker, which reads
303
+ * `source` and dispatches `actions`; this controller never touches the picker
304
+ * DOM, so the UI can remount without rebuilding observers.
305
+ * @param options - layout service, locale subscription and translate function.
306
+ * @returns observable state, actions and the disposer.
307
+ */
308
+ function createWidthTiers(options) {
309
+ const { layout, t } = options;
310
+ const roots = createWidthRootLocator();
311
+ const removeStyles = installStyles();
312
+ const body = () => document.body;
313
+ const listeners = /* @__PURE__ */ new Set();
314
+ let snapshot;
315
+ let menuOpen = false;
316
+ let pulseSeq = 0;
317
+ let guideActive = false;
318
+ let guideDragging = false;
319
+ let guideFromMenu = false;
320
+ let guideMode = "hint";
321
+ let guideText = "";
322
+ let guideStyle = GUIDE_FALLBACK;
323
+ let guideHandle = null;
324
+ let guideHideTimer = null;
325
+ let guideWidth = GUIDE_WIDTH_PX;
326
+ let tierBeforeGuide = "standard";
327
+ let preferenceOwned = false;
328
+ let preferenceWritten = null;
329
+ let collapsedByTier = false;
330
+ function currentTier() {
331
+ const value = body().getAttribute(TIER_ATTR);
332
+ const found = value === null ? void 0 : TIERS.find((tier) => tier.id === value);
333
+ return found === void 0 ? "standard" : found.id;
334
+ }
335
+ function getSavedTier$1() {
336
+ try {
337
+ return getSavedTier(window.localStorage);
338
+ } catch {
339
+ return "standard";
330
340
  }
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
- ));
341
+ }
342
+ function readNumber(key) {
343
+ try {
344
+ return readStoredNumber(window.localStorage, key);
345
+ } catch {
346
+ return null;
349
347
  }
350
-
351
- function applyContentWidth(tier) {
352
- const root = findWidthRoot();
353
- if (root === null) return;
354
- const target = widthTarget(root);
355
- /* custom before the first drag has no width of its own —
356
- * leave whatever the app last published in place. */
357
- 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") !== "") {
361
- target.style.removeProperty("--dsh-chat-user-width");
362
- try {
363
- window.localStorage.removeItem(APP_PREF_KEY);
364
- } catch (e) {
365
- /* ignore */
366
- }
367
- }
368
- return;
369
- }
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");
376
- }
377
- try {
378
- if (window.localStorage.getItem(APP_PREF_KEY) !== next) {
379
- window.localStorage.setItem(APP_PREF_KEY, next);
380
- }
381
- } catch (e) {
382
- /* ignore */
348
+ }
349
+ function isSidebarCollapsed() {
350
+ return document.querySelector("[data-sidebar-collapsed]") !== null;
351
+ }
352
+ function collapseSidebar() {
353
+ if (!isSidebarCollapsed()) layout.toggleSidebar();
354
+ }
355
+ function expandSidebar() {
356
+ if (isSidebarCollapsed()) layout.toggleSidebar();
357
+ }
358
+ function tierValueText(tierId) {
359
+ switch (tierId) {
360
+ case "standard": {
361
+ const root = roots.find();
362
+ if (root === null) return "—";
363
+ return `${Math.round(defaultWidthPx(root.offsetWidth))}px`;
383
364
  }
384
- }
385
-
386
- /* true while the CURRENT tier is the one that collapsed the sidebar */
387
- let collapsedByTier = false;
388
-
389
- function applyTier(tier) {
390
- 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 */
365
+ case "medium":
366
+ case "wide":
367
+ case "ultra": return `${NOMINAL_WIDTH_PX[tierId]}px`;
368
+ case "full": {
369
+ const root = roots.find();
370
+ if (root === null) return "—";
371
+ return `${Math.round(fullWidthPx(root.offsetWidth))}px`;
395
372
  }
396
- applyContentWidth(tier);
397
- if (tier === "full") {
398
- if (!isSidebarCollapsed()) {
399
- collapseSidebar();
400
- collapsedByTier = true;
401
- } else {
402
- collapsedByTier = false;
403
- }
404
- } else if (tier === "standard") {
405
- if (collapsedByTier && isSidebarCollapsed()) expandSidebar();
406
- collapsedByTier = false;
407
- } else {
408
- collapsedByTier = false;
373
+ case "custom": {
374
+ const dragged = readNumber(CUSTOM_KEY);
375
+ return dragged === null ? t("value.custom.unset") : `${Math.round(dragged)}px`;
409
376
  }
410
- syncButton();
411
- syncMenu();
412
- pulse();
377
+ default: return "—";
413
378
  }
414
-
415
- /* ---- floating button with level-bar icon ---- */
416
- const btn = document.createElement("button");
417
- btn.type = "button";
418
- btn.id = "dsh-wide-toggle";
419
- btn.setAttribute("aria-haspopup", "menu");
420
- btn.setAttribute("aria-expanded", "false");
421
-
422
- function barIcon(tier) {
423
- let idx = 0;
424
- for (let i = 0; i < TIERS.length; i++) {
425
- if (TIERS[i].id === tier) idx = i;
379
+ }
380
+ function guideValueText() {
381
+ const root = roots.find();
382
+ const px = root === null ? null : liveWidthPx(root);
383
+ return px === null ? "—" : `${px}px`;
384
+ }
385
+ function buildSnapshot() {
386
+ const tier = currentTier();
387
+ const def = tierOf(tier);
388
+ const label = t(def.key);
389
+ const value = tierValueText(tier);
390
+ const summary = t("button.title.summary", {
391
+ label,
392
+ value
393
+ });
394
+ const hint = t("button.title.hint", { list: TIERS.map((tierDef) => `${t(tierDef.key)} ${tierValueText(tierDef.id)}`).join(" / ") });
395
+ return {
396
+ tier,
397
+ menuOpen,
398
+ pulse: pulseSeq,
399
+ button: {
400
+ active: tier !== "standard",
401
+ label,
402
+ value,
403
+ ariaLabel: t("button.aria", {
404
+ label,
405
+ value
406
+ }),
407
+ title: `${summary}\n${hint}`
408
+ },
409
+ items: TIERS.map((tierDef) => ({
410
+ id: tierDef.id,
411
+ label: t(tierDef.key),
412
+ value: tierValueText(tierDef.id),
413
+ selected: tierDef.id === tier
414
+ })),
415
+ guide: {
416
+ active: guideActive,
417
+ dragging: guideDragging,
418
+ mode: guideMode,
419
+ text: guideText,
420
+ style: guideStyle
426
421
  }
427
- const widths = [3.5, 5.5, 7.5, 10, 13];
428
- 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}"/>`;
422
+ };
423
+ }
424
+ function emit() {
425
+ snapshot = buildSnapshot();
426
+ for (const listener of listeners) listener();
427
+ }
428
+ function applyContentWidth(tier) {
429
+ const root = roots.find();
430
+ if (root === null) return;
431
+ const target = widthTarget(root);
432
+ if (tier === "custom" && readNumber("dsh.widthTierCustom") === null) return;
433
+ const targetPx = tierTargetPx(tier, root.offsetWidth, readNumber(CUSTOM_KEY));
434
+ if (targetPx === null) {
435
+ if (target.style.getPropertyValue("--dsh-chat-user-width") !== "" || preferenceOwned) {
436
+ target.style.removeProperty("--dsh-chat-user-width");
437
+ try {
438
+ window.localStorage.removeItem(APP_PREF_KEY);
439
+ } catch {}
440
+ preferenceOwned = false;
441
+ preferenceWritten = null;
432
442
  }
433
- return `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">${bars}</svg>`;
443
+ return;
434
444
  }
435
-
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). */
440
- function tierValueText(tierId) {
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 "—";
445
+ const laidOut = root.offsetWidth > 0;
446
+ if (!laidOut && tier === "full") return;
447
+ if (laidOut) {
448
+ const px = appliedTierPx(tier, root.offsetWidth, readNumber(CUSTOM_KEY));
449
+ if (px !== null) {
450
+ const next = String(Math.round(px));
451
+ if (target.style.getPropertyValue("--dsh-chat-user-width") !== next + "px") target.style.setProperty("--dsh-chat-user-width", next + "px");
467
452
  }
468
453
  }
469
-
470
- function syncButton() {
471
- const tier = currentTier();
472
- const def = tierOf(tier);
473
- const label = t(def.key);
474
- const value = tierValueText(tier);
475
- const summary = t("button.title.summary", { label, value });
476
- const hint = t("button.title.hint", {
477
- list: TIERS.map((tierDef) => `${t(tierDef.key)} ${tierValueText(tierDef.id)}`).join(" / ")
478
- });
479
- btn.innerHTML = barIcon(tier);
480
- btn.classList.toggle("active", tier !== "standard");
481
- btn.setAttribute("aria-label", t("button.aria", { label, value }));
482
- btn.title = `${summary}\n${hint}`;
483
- }
484
-
485
- /* ---- tier menu ---- */
486
- const menu = document.createElement("div");
487
- menu.id = "dsh-width-menu";
488
- menu.setAttribute("role", "menu");
489
- menu.hidden = true;
490
-
491
- for (const def of TIERS) {
492
- const item = document.createElement("button");
493
- item.type = "button";
494
- item.setAttribute("role", "menuitemradio");
495
- item.setAttribute("data-tier", def.id);
496
- item.innerHTML = `<span class="dshwm-label">${t(def.key)}</span><span class="dshwm-value">${tierValueText(def.id)}</span>`;
497
- item.addEventListener("click", (e) => {
498
- if (e && e.stopPropagation) e.stopPropagation();
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);
507
- applyTier(def.id);
508
- hideMenu();
509
- });
510
- menu.appendChild(item);
511
- }
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
-
520
- function syncMenu() {
521
- const tier = currentTier();
522
- const items = menu.querySelectorAll("button[data-tier]");
523
- for (let i = 0; i < items.length; i++) {
524
- const id = items[i].getAttribute("data-tier");
525
- const on = id === tier;
526
- items[i].classList.toggle("selected", on);
527
- items[i].setAttribute("aria-checked", String(on));
528
- const labelEl = items[i].querySelector(".dshwm-label");
529
- if (labelEl) setText(labelEl, t(tierOf(id).key));
530
- const valueEl = items[i].querySelector(".dshwm-value");
531
- if (valueEl) setText(valueEl, tierValueText(id));
454
+ const preference = String(Math.round(targetPx));
455
+ try {
456
+ if (window.localStorage.getItem("dsh.conversation.contentWidth") !== preference) {
457
+ window.localStorage.setItem(APP_PREF_KEY, preference);
458
+ preferenceOwned = true;
459
+ preferenceWritten = preference;
532
460
  }
533
- }
534
-
535
- function showMenu() {
536
- menu.hidden = false;
537
- btn.setAttribute("aria-expanded", "true");
538
- syncMenu();
539
- }
540
-
541
- function hideMenu() {
542
- menu.hidden = true;
543
- btn.setAttribute("aria-expanded", "false");
544
- }
545
-
546
- btn.addEventListener("click", (e) => {
547
- if (e && e.stopPropagation) e.stopPropagation();
548
- if (menu.hidden) showMenu();
549
- else hideMenu();
550
- });
551
-
552
- const onDocClick = (e) => {
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);
461
+ } catch {}
462
+ }
463
+ function applyTier(tier, persist = true) {
464
+ body().setAttribute(TIER_ATTR, tier);
465
+ if (persist) try {
466
+ window.localStorage.setItem(KEY, tier);
467
+ } catch {}
468
+ applyContentWidth(tier);
469
+ if (tier === "full") {
470
+ if (!isSidebarCollapsed()) {
471
+ collapseSidebar();
472
+ collapsedByTier = true;
558
473
  }
474
+ } else if (tier === "standard") {
475
+ if (collapsedByTier && isSidebarCollapsed()) expandSidebar();
476
+ collapsedByTier = false;
477
+ } else if (!isSidebarCollapsed()) collapsedByTier = false;
478
+ pulseSeq += 1;
479
+ emit();
480
+ }
481
+ function computeGuideStyle() {
482
+ const handle = guideHandle !== null && document.contains(guideHandle) ? guideHandle : document.querySelector("[data-width-handle=\"right\"]");
483
+ const viewport = window.innerWidth || 0;
484
+ if (handle === null || typeof handle.getBoundingClientRect !== "function" || viewport === 0) return GUIDE_FALLBACK;
485
+ const rect = handle.getBoundingClientRect();
486
+ const gap = 10;
487
+ const top = `${rect.top + rect.height / 2}px`;
488
+ if (viewport - rect.right >= guideWidth + gap) return {
489
+ left: `${rect.right + gap}px`,
490
+ right: "auto",
491
+ top
559
492
  };
560
- const onDocKey = (e) => {
561
- if (e.key !== "Escape") return;
562
- if (!menu.hidden) hideMenu();
563
- if (guideActive) endGuide(true);
493
+ return {
494
+ left: `${Math.max(8, rect.left - gap - guideWidth)}px`,
495
+ right: "auto",
496
+ top
564
497
  };
565
- document.addEventListener("click", onDocClick);
566
- document.addEventListener("keydown", onDocKey);
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);
498
+ }
499
+ function updateGuideStyle() {
500
+ if (!guideActive) return;
501
+ const next = computeGuideStyle();
502
+ if (next.left === guideStyle.left && next.right === guideStyle.right && next.top === guideStyle.top) return;
503
+ guideStyle = next;
504
+ emit();
505
+ }
506
+ function startGuide() {
507
+ guideFromMenu = true;
508
+ guideActive = true;
509
+ guideDragging = false;
510
+ guideHandle = null;
511
+ body().setAttribute(GUIDE_ATTR, "custom");
512
+ guideMode = "hint";
513
+ guideText = t("guide.hint");
514
+ guideStyle = computeGuideStyle();
515
+ emit();
516
+ }
517
+ function startLiveGuide(handle) {
518
+ if (guideHideTimer !== null) {
519
+ window.clearTimeout(guideHideTimer);
520
+ guideHideTimer = null;
630
521
  }
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();
522
+ guideActive = true;
523
+ guideDragging = true;
524
+ guideHandle = handle ?? null;
525
+ body().setAttribute(GUIDE_ATTR, "custom");
526
+ guideMode = "live";
527
+ guideText = guideValueText();
528
+ guideStyle = computeGuideStyle();
529
+ emit();
530
+ }
531
+ function beginCustomGuide() {
532
+ if (!guideActive) {
533
+ tierBeforeGuide = currentTier();
534
+ applyTier("custom");
640
535
  }
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() {
536
+ startGuide();
537
+ }
538
+ function finishGuide() {
539
+ if (!guideActive) return;
540
+ guideDragging = false;
541
+ guideMode = "live";
542
+ guideText = guideValueText();
543
+ if (guideHideTimer !== null) window.clearTimeout(guideHideTimer);
544
+ guideHideTimer = window.setTimeout(() => endGuide(false), GUIDE_HIDE_MS);
545
+ emit();
546
+ }
547
+ function endGuide(restoreTier) {
548
+ if (!guideActive) return;
549
+ guideActive = false;
550
+ guideDragging = false;
551
+ if (guideHideTimer !== null) {
646
552
  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();
553
+ guideHideTimer = null;
653
554
  }
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();
555
+ guideMode = "hint";
556
+ guideHandle = null;
557
+ guideWidth = GUIDE_WIDTH_PX;
558
+ guideStyle = GUIDE_FALLBACK;
559
+ body().removeAttribute(GUIDE_ATTR);
560
+ const restore = restoreTier && guideFromMenu && readNumber("dsh.widthTierCustom") === null;
561
+ guideFromMenu = false;
562
+ if (restore) applyTier(tierBeforeGuide);
563
+ else emit();
564
+ }
565
+ const onGuidePointerMove = () => {
566
+ if (!guideActive || !guideDragging) return;
567
+ guideText = guideValueText();
568
+ guideStyle = computeGuideStyle();
569
+ emit();
570
+ };
571
+ document.addEventListener("pointermove", onGuidePointerMove, true);
572
+ const isHandleEvent = (e) => e.target instanceof Element && e.target.closest("[data-width-handle]") !== null;
573
+ const onHandlePointerDown = (e) => {
574
+ if (!isHandleEvent(e)) return;
575
+ const target = e.target;
576
+ startLiveGuide(target.closest("[data-width-handle]"));
577
+ if (currentTier() === "custom") return;
578
+ body().setAttribute(TIER_ATTR, "custom");
579
+ try {
580
+ window.localStorage.setItem(KEY, "custom");
581
+ } catch {}
582
+ emit();
583
+ };
584
+ const onHandlePointerUp = (e) => {
585
+ if (!isHandleEvent(e)) return;
586
+ const root = roots.find();
587
+ if (root !== null) {
588
+ const px = Number.parseFloat(widthTarget(root).style.getPropertyValue("--dsh-chat-user-width"));
589
+ if (Number.isFinite(px) && px > 0) try {
590
+ window.localStorage.setItem(CUSTOM_KEY, String(Math.round(px)));
591
+ } catch {}
664
592
  }
665
-
666
- /* Show the settled width briefly, then remove the guide. */
667
- function finishGuide() {
593
+ if (guideActive) finishGuide();
594
+ else emit();
595
+ };
596
+ const onHandlePointerCancel = () => {
597
+ if (!guideActive || !guideDragging) return;
598
+ endGuide(false);
599
+ };
600
+ document.addEventListener("pointerdown", onHandlePointerDown, true);
601
+ document.addEventListener("pointerup", onHandlePointerUp, true);
602
+ document.addEventListener("pointercancel", onHandlePointerCancel, true);
603
+ const actions = {
604
+ toggleMenu() {
605
+ menuOpen = !menuOpen;
606
+ emit();
607
+ },
608
+ closeMenu() {
609
+ if (!menuOpen) return;
610
+ menuOpen = false;
611
+ emit();
612
+ },
613
+ selectTier(tier) {
614
+ menuOpen = false;
615
+ if (tier === "custom" && readNumber("dsh.widthTierCustom") === null) {
616
+ beginCustomGuide();
617
+ return;
618
+ }
619
+ endGuide(false);
620
+ applyTier(tier);
621
+ },
622
+ cancelGuide() {
623
+ endGuide(true);
624
+ },
625
+ measureGuide(width) {
626
+ const next = Math.round(width);
627
+ if (next <= 0 || next === guideWidth) return;
628
+ guideWidth = next;
668
629
  if (!guideActive) return;
669
- guideDragging = false;
670
- setGuideText("live", guideValueText());
671
- window.clearTimeout(guideHideTimer);
672
- guideHideTimer = window.setTimeout(() => endGuide(false), 800);
630
+ guideStyle = computeGuideStyle();
631
+ emit();
673
632
  }
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);
633
+ };
634
+ let attached = false;
635
+ let readyTimer = null;
636
+ let slowTimer = null;
637
+ let readyTimeout = null;
638
+ function stopWaiting() {
639
+ if (readyTimer !== null) {
640
+ clearInterval(readyTimer);
641
+ readyTimer = null;
689
642
  }
690
-
691
- const onGuidePointerMove = () => {
692
- if (!guideActive || !guideDragging) return;
693
- setGuideText("live", guideValueText());
694
- positionGuide();
695
- };
696
- document.addEventListener("pointermove", onGuidePointerMove, true);
697
-
698
- /* ---- hand the width over to the app's native drag handles ----
699
- * Pressing a `[data-width-handle]` means the user is taking the
700
- * width over: switch to the `custom` tier so healing stops
701
- * enforcing a preset, and let the app's drag win the cascade
702
- * (it rewrites the same inline property every frame). The
703
- * settled width is recorded on release so it stays
704
- * re-selectable from the menu. */
705
- const isHandleEvent = (e) =>
706
- e.target instanceof Element && e.target.closest("[data-width-handle]") !== null;
707
-
708
- const onHandlePointerDown = (e) => {
709
- if (!isHandleEvent(e)) return;
710
- startLiveGuide();
711
- if (currentTier() === "custom") return;
712
- body().setAttribute("data-dsh-width", "custom");
713
- try {
714
- window.localStorage.setItem(KEY, "custom");
715
- } catch (err) {
716
- /* ignore */
717
- }
718
- collapsedByTier = false;
719
- syncButton();
720
- syncMenu();
721
- };
722
-
723
- const onHandlePointerUp = (e) => {
724
- if (!isHandleEvent(e)) return;
725
- const root = findWidthRoot();
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
- }
737
- }
738
- if (guideActive) finishGuide();
739
- syncButton();
740
- syncMenu();
741
- };
742
- document.addEventListener("pointerdown", onHandlePointerDown, true);
743
- document.addEventListener("pointerup", onHandlePointerUp, true);
744
-
745
- /* ---- feedback ring so every selection is visibly answered ---- */
746
- function pulse() {
747
- btn.classList.remove("pulse");
748
- void btn.offsetWidth;
749
- btn.classList.add("pulse");
750
- window.setTimeout(() => btn.classList.remove("pulse"), 700);
643
+ if (slowTimer !== null) {
644
+ clearInterval(slowTimer);
645
+ slowTimer = null;
751
646
  }
752
-
753
- /* ---- re-render localized copy when the app language changes ---- */
754
- const renderTexts = () => {
755
- syncButton();
756
- syncMenu();
757
- if (guideActive && !guideDragging && guide.dataset.mode === "hint") {
758
- setText(guideTextEl, t("guide.hint"));
759
- }
760
- };
761
- const unsubscribeLocale = ctx.locale.subscribe(renderTexts);
762
-
763
- /* ---- attach once the app shell has mounted ---- */
764
- let attached = false;
765
-
766
- /* The old gate matched [data-sidebar-collapsed] /
767
- * [data-details-collapsed]. 0.1.5-rc.2 renders collapsed
768
- * markers conditionally (`sidebarCollapsed || void 0`) and
769
- * removed data-details-collapsed, so with the sidebar expanded
770
- * nothing ever matched and the button never mounted. Wait for
771
- * the AppFrame overlay layer instead — `data-shell-overlay` is
772
- * rendered unconditionally once the shell exists. */
773
- function attach() {
774
- if (attached || !body()) return;
775
- if (document.querySelector("[data-shell-overlay]") === null) return;
776
- attached = true;
777
- try {
778
- window.localStorage.removeItem(OLD_KEY);
779
- } catch (e) {
780
- /* ignore */
781
- }
782
- body().appendChild(btn);
783
- body().appendChild(menu);
784
- body().appendChild(guide);
785
- syncButton();
786
- const saved = getSavedTier();
787
- if (saved !== "standard") applyTier(saved);
788
- startHealing();
647
+ if (readyTimeout !== null) {
648
+ window.clearTimeout(readyTimeout);
649
+ readyTimeout = null;
789
650
  }
790
-
791
- const readyTimer = setInterval(attach, 300);
792
- window.addEventListener("load", attach);
793
- const timeout = window.setTimeout(() => {
651
+ }
652
+ function attach() {
653
+ if (attached || !body()) return;
654
+ if (document.querySelector("[data-shell-overlay]") === null) return;
655
+ attached = true;
656
+ stopWaiting();
657
+ try {
658
+ window.localStorage.removeItem(OLD_KEY);
659
+ } catch {}
660
+ const saved = getSavedTier$1();
661
+ if (saved !== "standard") applyTier(saved);
662
+ else emit();
663
+ startHealing();
664
+ const cached = roots.cached();
665
+ if (cached !== null) observeWidthTarget(cached);
666
+ }
667
+ readyTimer = setInterval(attach, 300);
668
+ readyTimeout = window.setTimeout(() => {
669
+ if (attached) return;
670
+ if (readyTimer !== null) {
794
671
  clearInterval(readyTimer);
795
- attach();
796
- }, READY_TIMEOUT_MS);
797
-
798
- /* ---- self-heal: re-apply the width when the chat tree
799
- * remounts (conversation switch). Panels are never managed
800
- * here — the user may expand/collapse either side any time;
801
- * forcing the rightbar closed only retracts the grid track
802
- * while the seat keeps rendering as an overlay that covers
803
- * the conversation. Driven by DOM mutations, scheduled on the
804
- * next animation frame (before the browser paints) instead of
805
- * a fixed hot loop: the width must land on the fresh root
806
- * before its first paint — otherwise the default width
807
- * flashes for a frame. While the new root has not appeared
808
- * yet (mid-remount), full scans are owned by a backoff retry;
809
- * mutation-driven heals only try the cheap path walk so DOM
810
- * churn cannot storm. ---- */
811
- let rootMissing = false;
812
- let rootRetryDelay = ROOT_RETRY_MIN_MS;
813
- let rootRetryTimer = null;
814
- let healFrame = null;
815
- let observer = null;
816
- let fallbackTimer = null;
817
-
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);
824
-
825
- function scheduleRootRetry() {
826
- if (rootRetryTimer !== null) return;
827
- rootRetryTimer = window.setTimeout(() => {
828
- rootRetryTimer = null;
829
- const tier = currentTier();
830
- if (tier === "standard") return;
831
- const prev = widthRoot;
832
- const root = findWidthRoot();
833
- if (root === null) {
834
- rootRetryDelay = Math.min(rootRetryDelay * 2, ROOT_RETRY_MAX_MS);
835
- scheduleRootRetry();
836
- } else {
837
- rootMissing = false;
838
- rootRetryDelay = ROOT_RETRY_MIN_MS;
839
- if (root !== prev) applyContentWidth(tier);
840
- }
841
- }, rootRetryDelay);
672
+ readyTimer = null;
842
673
  }
843
-
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();
674
+ slowTimer = setInterval(attach, 2e3);
675
+ }, READY_TIMEOUT_MS);
676
+ window.addEventListener("load", attach);
677
+ const onStorage = (e) => {
678
+ if (!e || e.key !== "dsh.widthTier") return;
679
+ const next = e.newValue;
680
+ if (typeof next !== "string") return;
681
+ const found = TIERS.find((tier) => tier.id === next);
682
+ if (found === void 0) return;
683
+ if (currentTier() === found.id) return;
684
+ applyTier(found.id, false);
685
+ };
686
+ window.addEventListener("storage", onStorage);
687
+ let rootMissing = false;
688
+ let rootRetryDelay = 200;
689
+ let rootRetryTimer = null;
690
+ let healFrame = null;
691
+ let resizeFrame = null;
692
+ let observer = null;
693
+ let fallbackTimer = null;
694
+ let widthObserver = null;
695
+ let observedTarget = null;
696
+ function scheduleFrame(cb) {
697
+ return typeof requestAnimationFrame === "function" ? requestAnimationFrame(cb) : window.setTimeout(cb, 0);
698
+ }
699
+ function cancelFrame(id) {
700
+ if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(id);
701
+ else window.clearTimeout(id);
702
+ }
703
+ function observeWidthTarget(root) {
704
+ if (typeof ResizeObserver === "undefined") return;
705
+ const target = widthTarget(root);
706
+ if (target === observedTarget && widthObserver !== null) return;
707
+ if (widthObserver !== null) {
708
+ widthObserver.disconnect();
709
+ widthObserver = null;
710
+ }
711
+ observedTarget = target;
712
+ widthObserver = new ResizeObserver(() => {
713
+ if (guideActive && guideDragging) return;
848
714
  const tier = currentTier();
849
715
  if (tier === "standard") return;
850
- const prev = widthRoot;
851
- 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();
857
- } else {
858
- root = findWidthRoot();
859
- }
716
+ applyContentWidth(tier);
717
+ emit();
718
+ });
719
+ widthObserver.observe(target);
720
+ }
721
+ function scheduleRootRetry() {
722
+ if (rootRetryTimer !== null) return;
723
+ rootRetryTimer = window.setTimeout(() => {
724
+ rootRetryTimer = null;
725
+ const tier = currentTier();
726
+ if (tier === "standard") return;
727
+ const prev = roots.cached();
728
+ const root = roots.find();
860
729
  if (root === null) {
861
- if (!rootMissing) {
862
- rootMissing = true;
863
- rootRetryDelay = ROOT_RETRY_MIN_MS;
864
- scheduleRootRetry();
730
+ rootRetryDelay = Math.min(rootRetryDelay * 2, ROOT_RETRY_MAX_MS);
731
+ scheduleRootRetry();
732
+ } else {
733
+ rootMissing = false;
734
+ rootRetryDelay = 200;
735
+ if (root !== prev) {
736
+ applyContentWidth(tier);
737
+ emit();
865
738
  }
866
- return;
739
+ observeWidthTarget(root);
867
740
  }
868
- if (rootMissing) {
869
- rootMissing = false;
870
- rootRetryDelay = ROOT_RETRY_MIN_MS;
741
+ }, rootRetryDelay);
742
+ }
743
+ function heal(fromObserver) {
744
+ if (guideActive) updateGuideStyle();
745
+ if (guideActive && guideDragging) return;
746
+ const tier = currentTier();
747
+ if (tier === "standard") return;
748
+ const prev = roots.cached();
749
+ let root;
750
+ if (rootMissing || fromObserver) root = roots.find(false);
751
+ else root = roots.find();
752
+ if (root === null) {
753
+ if (!rootMissing) {
754
+ rootMissing = true;
755
+ rootRetryDelay = 200;
871
756
  }
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");
757
+ scheduleRootRetry();
758
+ return;
878
759
  }
879
-
880
- function startHealing() {
881
- if (typeof MutationObserver !== "undefined") {
882
- observer = new MutationObserver(() => {
883
- if (healFrame !== null) return;
884
- scheduleFrame(() => {
885
- healFrame = null;
886
- heal(true);
887
- });
888
- });
889
- observer.observe(document.body, {
890
- subtree: true,
891
- childList: true,
892
- attributes: true,
893
- attributeFilter: ["data-sidebar-collapsed"]
894
- });
895
- }
896
- /* Low-frequency fallback so self-heal still runs if the
897
- * observer misses something — cheap: no scan while the
898
- * cached root is alive. */
899
- fallbackTimer = setInterval(() => heal(false), FALLBACK_HEAL_MS);
760
+ if (rootMissing) {
761
+ rootMissing = false;
762
+ rootRetryDelay = 200;
900
763
  }
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();
764
+ observeWidthTarget(root);
765
+ if (root !== prev) {
766
+ applyContentWidth(tier);
767
+ emit();
768
+ } else if (!fromObserver) {
769
+ applyContentWidth(tier);
770
+ emit();
771
+ }
772
+ }
773
+ function startHealing() {
774
+ if (typeof MutationObserver !== "undefined") {
775
+ observer = new MutationObserver(() => {
776
+ if (healFrame !== null) return;
777
+ healFrame = scheduleFrame(() => {
778
+ healFrame = null;
779
+ heal(true);
780
+ });
914
781
  });
915
- };
916
- window.addEventListener("resize", onWindowResize);
917
-
918
- /* ---- disposer ---- */
919
- return () => {
782
+ observer.observe(document.body, {
783
+ subtree: true,
784
+ childList: true,
785
+ attributes: true,
786
+ attributeFilter: ["data-sidebar-collapsed"]
787
+ });
788
+ }
789
+ fallbackTimer = setInterval(() => heal(false), FALLBACK_HEAL_MS);
790
+ }
791
+ const onWindowResize = () => {
792
+ if (resizeFrame !== null) return;
793
+ resizeFrame = scheduleFrame(() => {
794
+ resizeFrame = null;
795
+ if (!(guideActive && guideDragging)) {
796
+ const tier = currentTier();
797
+ if (tier !== "standard") applyContentWidth(tier);
798
+ }
799
+ if (guideActive) guideStyle = computeGuideStyle();
800
+ emit();
801
+ });
802
+ };
803
+ window.addEventListener("resize", onWindowResize);
804
+ snapshot = buildSnapshot();
805
+ const unsubscribeLocale = options.locale.subscribe(() => {
806
+ if (guideActive && !guideDragging && guideMode === "hint") guideText = t("guide.hint");
807
+ emit();
808
+ });
809
+ attach();
810
+ return {
811
+ source: {
812
+ getSnapshot: () => snapshot,
813
+ subscribe(listener) {
814
+ listeners.add(listener);
815
+ return () => {
816
+ listeners.delete(listener);
817
+ };
818
+ }
819
+ },
820
+ actions,
821
+ dispose() {
920
822
  unsubscribeLocale();
921
- clearInterval(readyTimer);
922
- clearInterval(fallbackTimer);
923
- clearTimeout(timeout);
924
- clearTimeout(rootRetryTimer);
925
- clearTimeout(guideHideTimer);
926
- if (healFrame !== null) cancelFrame(healFrame);
927
- if (resizeFrame !== null) cancelFrame(resizeFrame);
823
+ stopWaiting();
824
+ if (fallbackTimer !== null) {
825
+ clearInterval(fallbackTimer);
826
+ fallbackTimer = null;
827
+ }
828
+ if (rootRetryTimer !== null) {
829
+ window.clearTimeout(rootRetryTimer);
830
+ rootRetryTimer = null;
831
+ }
832
+ if (guideHideTimer !== null) {
833
+ window.clearTimeout(guideHideTimer);
834
+ guideHideTimer = null;
835
+ }
836
+ if (healFrame !== null) {
837
+ cancelFrame(healFrame);
838
+ healFrame = null;
839
+ }
840
+ if (resizeFrame !== null) {
841
+ cancelFrame(resizeFrame);
842
+ resizeFrame = null;
843
+ }
844
+ if (widthObserver !== null) {
845
+ widthObserver.disconnect();
846
+ widthObserver = null;
847
+ observedTarget = null;
848
+ }
928
849
  window.removeEventListener("resize", onWindowResize);
850
+ window.removeEventListener("load", attach);
851
+ window.removeEventListener("storage", onStorage);
929
852
  if (observer !== null) observer.disconnect();
930
- document.removeEventListener("click", onDocClick);
931
- document.removeEventListener("keydown", onDocKey);
932
853
  document.removeEventListener("pointerdown", onHandlePointerDown, true);
933
854
  document.removeEventListener("pointerup", onHandlePointerUp, true);
855
+ document.removeEventListener("pointercancel", onHandlePointerCancel, true);
934
856
  document.removeEventListener("pointermove", onGuidePointerMove, true);
935
- if (btn.parentNode) btn.parentNode.removeChild(btn);
936
- if (menu.parentNode) menu.parentNode.removeChild(menu);
937
- if (guide.parentNode) guide.parentNode.removeChild(guide);
938
- 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. */
943
- if (widthRoot !== null) {
944
- widthTarget(widthRoot).style.removeProperty("--dsh-chat-user-width");
857
+ removeStyles();
858
+ const root = roots.cached();
859
+ if (root !== null) widthTarget(root).style.removeProperty("--dsh-chat-user-width");
860
+ if (preferenceOwned) {
861
+ try {
862
+ if (preferenceWritten !== null && window.localStorage.getItem("dsh.conversation.contentWidth") === preferenceWritten) window.localStorage.removeItem(APP_PREF_KEY);
863
+ } catch {}
864
+ preferenceOwned = false;
865
+ preferenceWritten = null;
866
+ }
867
+ if (collapsedByTier) {
868
+ try {
869
+ if (isSidebarCollapsed()) layout.toggleSidebar();
870
+ } catch {}
871
+ collapsedByTier = false;
872
+ }
873
+ guideHandle = null;
874
+ body().removeAttribute(TIER_ATTR);
875
+ body().removeAttribute(GUIDE_ATTR);
876
+ listeners.clear();
877
+ }
878
+ };
879
+ }
880
+ // src/client/locales.ts
881
+ /** Dictionary namespace owned by this plugin. */
882
+ const NS = "width-tiers";
883
+ /** Simplified Chinese dictionary (the key-set source of truth). */
884
+ const zh = {
885
+ "tier.standard": "标准",
886
+ "tier.medium": "中等",
887
+ "tier.wide": "宽",
888
+ "tier.ultra": "超宽",
889
+ "tier.full": "全宽",
890
+ "tier.custom": "自定义",
891
+ "value.custom.unset": "未设置",
892
+ "guide.hint": "拖拽右侧边缘调整对话宽度",
893
+ "button.aria": "对话区宽度档位:{label}({value}),点击选择档位",
894
+ "button.title.summary": "对话区宽度档位:{label}({value})",
895
+ "button.title.hint": "点击选择:{list}"
896
+ };
897
+ /** English dictionary, checked complete against the zh key set. */
898
+ const en = {
899
+ "tier.standard": "Standard",
900
+ "tier.medium": "Medium",
901
+ "tier.wide": "Wide",
902
+ "tier.ultra": "Ultra",
903
+ "tier.full": "Full",
904
+ "tier.custom": "Custom",
905
+ "value.custom.unset": "Not set",
906
+ "guide.hint": "Drag the right edge to resize",
907
+ "button.aria": "Chat width tier: {label} ({value}). Click to choose a tier.",
908
+ "button.title.summary": "Chat width tier: {label} ({value})",
909
+ "button.title.hint": "Click to choose: {list}"
910
+ };
911
+ // src/client/WidthTierPicker.tsx
912
+ /** How long the selection ring stays on the button. */
913
+ const PULSE_MS = 700;
914
+ /** Six horizontal bars; one per tier, lit up to the selected index. */
915
+ function LevelIcon({ tier }) {
916
+ const index = TIERS.findIndex((candidate) => candidate.id === tier);
917
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
918
+ viewBox: "0 0 16 16",
919
+ width: "16",
920
+ height: "16",
921
+ "aria-hidden": "true",
922
+ focusable: "false",
923
+ children: [
924
+ 3.5,
925
+ 5.3,
926
+ 7.1,
927
+ 8.9,
928
+ 10.7,
929
+ 12.5
930
+ ].map((width, bar) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
931
+ x: 1.5,
932
+ y: .75 + bar * 2.55,
933
+ width,
934
+ height: 1.6,
935
+ rx: .8,
936
+ fill: "currentColor",
937
+ opacity: bar <= index ? 1 : .3
938
+ }, bar))
939
+ });
940
+ }
941
+ /**
942
+ * Floating tier picker and drag guide, mounted in `shell.overlay`.
943
+ *
944
+ * All width state, persistence and native-handle interception live in the
945
+ * controller; this component only renders the snapshot and dispatches
946
+ * actions. Menu focus, Escape/Tab handling, click-away dismissal and the
947
+ * selection ring are view concerns and stay local here.
948
+ * @param props - snapshot hook and controller actions.
949
+ * @returns the button, menu and guide inside one overlay host element.
950
+ */
951
+ function WidthTierPicker({ usePicker, actions }) {
952
+ const state = usePicker((snapshot) => snapshot);
953
+ const buttonRef = (0, react.useRef)(null);
954
+ const menuRef = (0, react.useRef)(null);
955
+ const wrapperRef = (0, react.useRef)(null);
956
+ const guideRef = (0, react.useRef)(null);
957
+ const stateRef = (0, react.useRef)(state);
958
+ const [pulsing, setPulsing] = (0, react.useState)(false);
959
+ (0, react.useLayoutEffect)(() => {
960
+ stateRef.current = state;
961
+ }, [state]);
962
+ (0, react.useEffect)(() => {
963
+ if (state.pulse === 0) return;
964
+ setPulsing(false);
965
+ const frame = requestAnimationFrame(() => setPulsing(true));
966
+ const timer = window.setTimeout(() => setPulsing(false), PULSE_MS);
967
+ return () => {
968
+ cancelAnimationFrame(frame);
969
+ window.clearTimeout(timer);
970
+ };
971
+ }, [state.pulse]);
972
+ (0, react.useEffect)(() => {
973
+ if (!state.menuOpen || menuRef.current === null) return;
974
+ const rows = Array.from(menuRef.current.querySelectorAll("button[data-tier]"));
975
+ (rows.find((row) => row.dataset.tier === state.tier) ?? rows[0])?.focus();
976
+ }, [state.menuOpen, state.tier]);
977
+ (0, react.useLayoutEffect)(() => {
978
+ if (!state.guide.active) return;
979
+ const width = guideRef.current?.offsetWidth ?? 0;
980
+ if (width > 0) actions.measureGuide(width);
981
+ }, [
982
+ state.guide.active,
983
+ state.guide.text,
984
+ state.guide.mode,
985
+ actions
986
+ ]);
987
+ (0, react.useEffect)(() => {
988
+ const onDocClick = (event) => {
989
+ const target = event.target;
990
+ if (!(target instanceof Node)) return;
991
+ const current = stateRef.current;
992
+ const inside = wrapperRef.current?.contains(target) ?? false;
993
+ if (current.menuOpen && !inside) actions.closeMenu();
994
+ if (current.guide.active && !current.guide.dragging && !inside && !(target instanceof Element && target.closest("[data-width-handle]") !== null)) actions.cancelGuide();
995
+ };
996
+ const onDocKey = (event) => {
997
+ const current = stateRef.current;
998
+ if (event.key === "Escape") {
999
+ if (current.menuOpen) {
1000
+ actions.closeMenu();
1001
+ buttonRef.current?.focus();
1002
+ }
1003
+ if (current.guide.active) actions.cancelGuide();
1004
+ return;
945
1005
  }
946
- body().removeAttribute("data-dsh-width");
947
- body().removeAttribute("data-dsh-width-guide");
1006
+ if (!current.menuOpen) return;
1007
+ if (event.key === "Tab") {
1008
+ actions.closeMenu();
1009
+ return;
1010
+ }
1011
+ const rows = Array.from(menuRef.current?.querySelectorAll("button[data-tier]") ?? []);
1012
+ if (rows.length === 0) return;
1013
+ const active = document.activeElement;
1014
+ const index = active === null ? -1 : rows.indexOf(active);
1015
+ let next = -1;
1016
+ if (event.key === "ArrowDown") next = index < 0 ? 0 : (index + 1) % rows.length;
1017
+ else if (event.key === "ArrowUp") next = index < 0 ? rows.length - 1 : (index - 1 + rows.length) % rows.length;
1018
+ else if (event.key === "Home") next = 0;
1019
+ else if (event.key === "End") next = rows.length - 1;
1020
+ if (next < 0) return;
1021
+ event.preventDefault();
1022
+ rows[next]?.focus();
948
1023
  };
949
- }, "dsh-width-tiers: ui");
1024
+ document.addEventListener("click", onDocClick);
1025
+ document.addEventListener("keydown", onDocKey);
1026
+ return () => {
1027
+ document.removeEventListener("click", onDocClick);
1028
+ document.removeEventListener("keydown", onDocKey);
1029
+ };
1030
+ }, [actions]);
1031
+ const className = [state.button.active ? "active" : "", pulsing ? "pulse" : ""].filter(Boolean).join(" ");
1032
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1033
+ ref: wrapperRef,
1034
+ "data-dsh-width-tiers-host": "",
1035
+ children: [
1036
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1037
+ ref: buttonRef,
1038
+ type: "button",
1039
+ id: "dsh-wide-toggle",
1040
+ "aria-haspopup": "menu",
1041
+ "aria-expanded": state.menuOpen,
1042
+ "aria-label": state.button.ariaLabel,
1043
+ title: state.button.title,
1044
+ className: className || void 0,
1045
+ onClick: () => actions.toggleMenu(),
1046
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LevelIcon, { tier: state.tier })
1047
+ }),
1048
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1049
+ ref: menuRef,
1050
+ id: "dsh-width-menu",
1051
+ role: state.menuOpen ? "menu" : void 0,
1052
+ hidden: !state.menuOpen,
1053
+ children: state.items.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1054
+ type: "button",
1055
+ role: "menuitemradio",
1056
+ "data-tier": item.id,
1057
+ tabIndex: -1,
1058
+ "aria-checked": item.selected,
1059
+ className: item.selected ? "selected" : void 0,
1060
+ onClick: () => {
1061
+ actions.selectTier(item.id);
1062
+ buttonRef.current?.focus();
1063
+ },
1064
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1065
+ className: "dshwm-label",
1066
+ children: item.label
1067
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1068
+ className: "dshwm-value",
1069
+ children: item.value
1070
+ })]
1071
+ }, item.id))
1072
+ }),
1073
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1074
+ ref: guideRef,
1075
+ id: "dsh-width-guide",
1076
+ "aria-hidden": "true",
1077
+ hidden: !state.guide.active,
1078
+ "data-mode": state.guide.mode,
1079
+ style: state.guide.style,
1080
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1081
+ viewBox: "0 0 16 16",
1082
+ width: "14",
1083
+ height: "14",
1084
+ "aria-hidden": "true",
1085
+ focusable: "false",
1086
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
1087
+ d: "M6 3 2 8l4 5M10 3l4 5-4 5",
1088
+ fill: "none",
1089
+ stroke: "currentColor",
1090
+ strokeWidth: 1.5,
1091
+ strokeLinecap: "round",
1092
+ strokeLinejoin: "round"
1093
+ })
1094
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1095
+ className: "dshwg-text",
1096
+ children: state.guide.text
1097
+ })]
1098
+ })
1099
+ ]
1100
+ });
1101
+ }
1102
+ // src/client/index.ts
1103
+ /** Services required by the browser bundle. */
1104
+ const inject = [
1105
+ "slots",
1106
+ "layout",
1107
+ "locale"
1108
+ ];
1109
+ /**
1110
+ * Client plugin body: register the dictionaries, start the width-tier
1111
+ * controller, then contribute the React picker to the frame-wide overlay
1112
+ * layer. The controller owns the width axis and persistence; the slot entry
1113
+ * owns only its own render lifetime.
1114
+ * @param ctx - client root context (slots + layout + locale injected).
1115
+ */
1116
+ function apply(ctx) {
1117
+ ctx.effect(() => ctx.locale.register(NS, {
1118
+ zh,
1119
+ en
1120
+ }), "dsh-width-tiers: dictionaries");
1121
+ const t = ctx.locale.bind(NS);
1122
+ const controller = createWidthTiers({
1123
+ layout: ctx.layout,
1124
+ locale: ctx.locale,
1125
+ t
1126
+ });
1127
+ ctx.effect(() => () => controller.dispose(), "dsh-width-tiers: controller");
1128
+ ctx.slots.inject("shell.overlay", () => ctx.slots.register({
1129
+ name: "shell.overlay",
1130
+ id: "width-tiers",
1131
+ order: 100,
1132
+ locale: NS,
1133
+ inject: () => ({
1134
+ hooks: { picker: controller.source },
1135
+ actions: controller.actions
1136
+ })
1137
+ }, WidthTierPicker));
950
1138
  }
951
- //#endregion
952
-
953
1139
  exports.apply = apply;
954
1140
  exports.inject = inject;
955
1141
  return module.exports;