dsh-width-tiers 1.0.9 → 2.0.1

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,150 +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
- * 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
- *
53
- * - Panels are driven through the layout service (ctx.layout), never
54
- * by simulating clicks.
55
- * - The sidebar is only ever collapsed by the `full` tier, through the
56
- * app's own toggle — never force-hidden, never locked. Going back to
57
- * `standard` restores it only if this plugin collapsed it.
58
- * - The rightbar is never touched: on 0.1.7+ its panel lives in its
59
- * own seat, so closing the track would leave it overlaying the
60
- * conversation; the app's width axis yields space on its own.
61
- * - The chosen tier persists in localStorage and is restored on the
62
- * next visit.
63
- * - All copy is localized through the app's locale service: tier
64
- * labels and the button's aria/title follow Settings → Language
65
- * (zh / en, browser-derived fallback) and re-render on switch.
66
- */
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. */
67
11
  const KEY = "dsh.widthTier";
12
+ /** Legacy key from the pre-tier releases; removed on attach. */
68
13
  const OLD_KEY = "dsh.wide";
69
14
  /** Last user-dragged width (px), backing the `custom` tier. */
70
15
  const CUSTOM_KEY = "dsh.widthTierCustom";
71
16
  /** The app's own transcript width preference (ConversationWidthControls). */
72
17
  const APP_PREF_KEY = "dsh.conversation.contentWidth";
73
- /* Mirrors ConversationWidthControls in 0.1.7-alpha.1: clamp floor and
74
- * the horizontal budget both width handles + safe edges reserve. */
75
- const CONTENT_MIN_PX = 640;
76
- const CONTENT_EDGE_BUDGET_PX = 176;
77
- /* Nominal content widths of the fixed tiers (px). */
78
- const NOMINAL_WIDTH_PX = { medium: 1120, wide: 1280, ultra: 1400 };
79
- /* The app's no-preference formula: clamp(680px, column × 0.64, 920px).
80
- * Used as the last-resort live readout before the first drag. */
81
- const DEFAULT_MIN_PX = 680;
82
- const DEFAULT_MAX_PX = 920;
83
- const DEFAULT_COLUMN_RATIO = 0.64;
84
- const READY_TIMEOUT_MS = 30000;
85
- /* Self-heal cadence: DOM-driven (MutationObserver scheduling on the
86
- * next animation frame — before paint, so the width never flashes
87
- * default) with a low-frequency fallback and backoff retries while
88
- * the width root is missing (conversation-switch remount). */
89
- const ROOT_RETRY_MIN_MS = 200;
90
- const ROOT_RETRY_MAX_MS = 2000;
91
- const FALLBACK_HEAL_MS = 2000;
92
-
93
- /** Dictionary namespace owned by this plugin. */
94
- const NS = "width-tiers";
95
-
96
- //#region lib/types/client/locales.js
97
- /** `width-tiers` namespace dictionaries (tier labels and picker copy). */
98
- /** Simplified Chinese dictionary (the key-set source of truth). */
99
- const zh = {
100
- "tier.standard": "标准",
101
- "tier.medium": "中等",
102
- "tier.wide": "宽",
103
- "tier.ultra": "超宽",
104
- "tier.full": "全宽",
105
- "tier.custom": "自定义",
106
- "value.custom.unset": "未设置",
107
- "guide.hint": "拖拽右侧边缘调整对话宽度",
108
- "button.aria": "对话区宽度档位:{label}({value}),点击选择档位",
109
- "button.title.summary": "对话区宽度档位:{label}({value})",
110
- "button.title.hint": "点击选择:{list}"
111
- };
112
- /** English dictionary, checked complete against the zh key set. */
113
- const en = {
114
- "tier.standard": "Standard",
115
- "tier.medium": "Medium",
116
- "tier.wide": "Wide",
117
- "tier.ultra": "Ultra",
118
- "tier.full": "Full",
119
- "tier.custom": "Custom",
120
- "value.custom.unset": "Not set",
121
- "guide.hint": "Drag the right edge to resize",
122
- "button.aria": "Chat width tier: {label} ({value}). Click to choose a tier.",
123
- "button.title.summary": "Chat width tier: {label} ({value})",
124
- "button.title.hint": "Click to choose: {list}"
18
+ /** Nominal content widths of the fixed tiers (px). */
19
+ const NOMINAL_WIDTH_PX = {
20
+ medium: 1120,
21
+ wide: 1280,
22
+ ultra: 1400
125
23
  };
126
- //#endregion
127
-
128
- /* Tier ids and label keys only — the picker resolves displayed values
129
- * in tierValueText (fixed px for medium/wide/ultra, live px for
130
- * 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
131
34
  const TIERS = [
132
- { id: "standard", key: "tier.standard" },
133
- { id: "medium", key: "tier.medium" },
134
- { id: "wide", key: "tier.wide" },
135
- { id: "ultra", key: "tier.ultra" },
136
- { id: "full", key: "tier.full" },
137
- /* Reached by dragging a native width handle, or by picking it in
138
- * the menu and following the right-edge drag guide. */
139
- { 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
+ }
140
59
  ];
141
-
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
+ */
142
65
  function tierOf(id) {
143
- for (let i = 0; i < TIERS.length; i++) {
144
- if (TIERS[i].id === id) return TIERS[i];
145
- }
66
+ for (const tier of TIERS) if (tier.id === id) return tier;
146
67
  return TIERS[0];
147
68
  }
148
-
149
- //#region lib/types/client/styles.js
150
- /** 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
+ */
151
153
  const css = [
152
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}",
153
155
  "#dsh-wide-toggle::after{content:'';position:fixed;right:10px;bottom:216px;width:36px;height:36px;pointer-events:auto}",
@@ -174,985 +176,671 @@ window.__ModuleLoader__.load({
174
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}",
175
177
  "body[data-dsh-width-guide] [data-width-handle]::after{opacity:1}"
176
178
  ].join("");
177
- //#endregion
178
-
179
- /** Services required by the browser bundle. */
180
- const inject = ["layout", "locale"];
181
-
182
179
  /**
183
- * Client plugin body: inject the styles, build the picker button and
184
- * menu, and drive the width tiers through the layout service.
185
- * @param ctx - client root context (layout + locale injected).
186
- */
187
- function apply(ctx) {
188
- ctx.effect(() => ctx.locale.register(NS, {
189
- zh,
190
- en
191
- }), "dsh-width-tiers: dictionaries");
192
- ctx.effect(() => {
193
- if (typeof document === "undefined") return;
194
-
195
- const styleTag = document.createElement("style");
196
- styleTag.dataset.plugin = "dsh-width-tiers";
197
- styleTag.textContent = css;
198
- document.head.appendChild(styleTag);
199
-
200
- const layout = ctx.layout;
201
- const t = ctx.locale.bind(NS);
202
-
203
- /* ---- helpers ---- */
204
- const body = () => document.body;
205
-
206
- function currentTier() {
207
- return body().getAttribute("data-dsh-width") || "standard";
208
- }
209
-
210
- function getSavedTier() {
211
- try {
212
- const v = window.localStorage.getItem(KEY);
213
- for (const t of TIERS) {
214
- if (t.id === v) return v;
215
- }
216
- } catch (e) {
217
- /* ignore */
218
- }
219
- return "standard";
220
- }
221
-
222
- /* The AppFrame sets data-sidebar-collapsed on the frame element
223
- * (conditionally, only while collapsed) after each render. */
224
- function isSidebarCollapsed() {
225
- return document.querySelector("[data-sidebar-collapsed]") !== null;
226
- }
227
-
228
- /* NOTE: the 1.0.x "close the right panel on non-standard tiers"
229
- * self-heal is gone on purpose. On 0.1.7 the rightbar panel
230
- * lives in its own seat: closeRightbar() only retracts the
231
- * grid TRACK (rightbarTrack=false) while the seat stays
232
- * expanded, so the panel keeps rendering as an OVERLAY pinned
233
- * to the frame's right edge — hanging over and covering the
234
- * conversation (对话窗口被边侧栏覆盖). The plugin must never
235
- * touch the rightbar; the app's own width axis already yields
236
- * space to the panel. */
237
-
238
- function collapseSidebar() {
239
- if (!isSidebarCollapsed()) layout.toggleSidebar();
240
- }
241
-
242
- function expandSidebar() {
243
- if (isSidebarCollapsed()) layout.toggleSidebar();
244
- }
245
-
246
- /* Locate the element that defines --dsh-chat-content-width at
247
- * runtime (first match wins; only one element declares it).
248
- * Cached; the app remounts the chat tree on conversation switch,
249
- * so re-location first walks the recorded child-index path from
250
- * <body> (React keeps the tree shape — O(depth), one style
251
- * read) and only falls back to a full scan. */
252
- let widthRoot = null;
253
- let widthPath = null;
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
-
262
- function hasWidthVar(el) {
263
- return getComputedStyle(el)
264
- .getPropertyValue("--dsh-chat-content-width")
265
- .trim() !== "";
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;
266
227
  }
267
-
268
- function recordWidthPath(el) {
269
- const path = [];
270
- let node = el;
271
- while (node && node !== document.body) {
272
- const parent = node.parentNode;
273
- if (!parent || !parent.children) break;
274
- path.unshift(Array.prototype.indexOf.call(parent.children, node));
275
- node = parent;
276
- }
277
- widthPath = path.length > 0 ? path : null;
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;
278
235
  }
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;
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;
287
245
  }
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;
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;
260
+ }
261
+ el = next;
294
262
  }
295
- return null;
296
- }
297
-
298
- function scanWidthRoot() {
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);
263
+ if (el !== null && el !== widthRoot && document.contains(el) && !isSidebarConversation(el) && hasWidthVar(el)) {
264
+ widthRoot = el;
308
265
  return widthRoot;
309
266
  }
267
+ }
268
+ if (!allowScan) return null;
269
+ return scanWidthRoot();
270
+ }
271
+ return {
272
+ find,
273
+ cached: () => widthRoot,
274
+ reset: () => {
310
275
  widthRoot = null;
311
276
  widthPath = null;
312
- return null;
313
- }
314
-
315
- function findWidthRoot(allowScan) {
316
- if (widthRoot !== null && document.contains(widthRoot)
317
- && !isSidebarConversation(widthRoot)) return widthRoot;
318
- if (widthPath !== null) {
319
- let el = document.body;
320
- for (let i = 0; i < widthPath.length; i++) {
321
- el = el.children[widthPath[i]];
322
- if (!el) break;
323
- }
324
- if (el && el !== widthRoot && document.contains(el)
325
- && !isSidebarConversation(el) && hasWidthVar(el)) {
326
- widthRoot = el;
327
- return widthRoot;
328
- }
329
- }
330
- if (allowScan === false) return null;
331
- return scanWidthRoot();
332
277
  }
333
-
334
- /* ---- 0.1.7 native width axis ----
335
- * The conversation body's parent is the element the app's
336
- * ConversationWidthControls publishes
337
- * `--dsh-conversation-column-width` / `--dsh-chat-user-width`
338
- * on. Writing there (never with `!important`) keeps native
339
- * drag handles in charge: during a drag the app rewrites the
340
- * same inline property every frame and wins the cascade. */
341
- function widthTarget(root) {
342
- return (root && root.parentElement) || root;
343
- }
344
-
345
- function readStoredNumber(key) {
346
- try {
347
- const v = Number(window.localStorage.getItem(key));
348
- return Number.isFinite(v) && v > 0 ? v : null;
349
- } catch (e) {
350
- return null;
351
- }
352
- }
353
-
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) {
361
- switch (tier) {
362
- case "medium":
363
- case "wide":
364
- case "ultra":
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);
369
- default: return null; /* standard: app default */
370
- }
371
- }
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);
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";
380
340
  }
381
-
382
- /* The transcript width the app currently resolves for the live
383
- * column (after its own clamp), used by the custom-drag guide.
384
- * During a drag the app writes the clamped px to the target's
385
- * inline `--dsh-chat-user-width`, so reading that is exact and
386
- * avoids relying on how a browser serializes a computed custom
387
- * property (which may stay as a var()/clamp() token stream). */
388
- function liveWidthPx(root) {
389
- const target = widthTarget(root);
390
- const inline = Number.parseFloat(target.style.getPropertyValue("--dsh-chat-user-width"));
391
- if (Number.isFinite(inline) && inline > 0) return Math.round(inline);
392
- const raw = getComputedStyle(root).getPropertyValue("--dsh-chat-content-width");
393
- const computed = Number.parseFloat(raw);
394
- if (Number.isFinite(computed) && computed > 0) return Math.round(computed);
395
- return Math.round(Math.max(
396
- DEFAULT_MIN_PX,
397
- Math.min(root.offsetWidth * DEFAULT_COLUMN_RATIO, DEFAULT_MAX_PX)
398
- ));
341
+ }
342
+ function readNumber(key) {
343
+ try {
344
+ return readStoredNumber(window.localStorage, key);
345
+ } catch {
346
+ return null;
399
347
  }
400
-
401
- function applyContentWidth(tier) {
402
- const root = findWidthRoot();
403
- if (root === null) return;
404
- const target = widthTarget(root);
405
- /* custom before the first drag has no width of its own —
406
- * leave whatever the app last published in place. */
407
- if (tier === "custom" && readStoredNumber(CUSTOM_KEY) === null) return;
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) {
414
- target.style.removeProperty("--dsh-chat-user-width");
415
- try {
416
- window.localStorage.removeItem(APP_PREF_KEY);
417
- } catch (e) {
418
- /* ignore */
419
- }
420
- preferenceOwned = false;
421
- preferenceWritten = null;
422
- }
423
- return;
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`;
424
364
  }
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
- }
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`;
438
372
  }
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));
444
- try {
445
- if (window.localStorage.getItem(APP_PREF_KEY) !== preference) {
446
- window.localStorage.setItem(APP_PREF_KEY, preference);
447
- preferenceOwned = true;
448
- preferenceWritten = preference;
449
- }
450
- } catch (e) {
451
- /* ignore */
373
+ case "custom": {
374
+ const dragged = readNumber(CUSTOM_KEY);
375
+ return dragged === null ? t("value.custom.unset") : `${Math.round(dragged)}px`;
452
376
  }
377
+ default: return "—";
453
378
  }
454
-
455
- /* true while the CURRENT tier is the one that collapsed the sidebar */
456
- let collapsedByTier = false;
457
-
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) {
461
- body().setAttribute("data-dsh-width", tier);
462
- if (persist) {
463
- try {
464
- window.localStorage.setItem(KEY, tier);
465
- } catch (e) {
466
- /* storage unavailable — state still applies this session */
467
- }
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
468
421
  }
469
- applyContentWidth(tier);
470
- if (tier === "full") {
471
- if (!isSidebarCollapsed()) {
472
- collapseSidebar();
473
- collapsedByTier = true;
474
- }
475
- /* already collapsed: don't claim ownership we may not have */
476
- } else if (tier === "standard") {
477
- if (collapsedByTier && isSidebarCollapsed()) expandSidebar();
478
- collapsedByTier = false;
479
- } else if (!isSidebarCollapsed()) {
480
- /* the user (or another panel) expanded it — the plugin no
481
- * longer owns the collapsed state */
482
- collapsedByTier = false;
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;
483
442
  }
484
- syncButton();
485
- syncMenu();
486
- pulse();
443
+ return;
487
444
  }
488
-
489
- /* ---- floating button with level-bar icon ---- */
490
- const btn = document.createElement("button");
491
- btn.type = "button";
492
- btn.id = "dsh-wide-toggle";
493
- btn.setAttribute("aria-haspopup", "menu");
494
- btn.setAttribute("aria-expanded", "false");
495
-
496
- function barIcon(tier) {
497
- let idx = 0;
498
- for (let i = 0; i < TIERS.length; i++) {
499
- if (TIERS[i].id === tier) idx = i;
500
- }
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];
504
- let bars = "";
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}"/>`;
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");
508
452
  }
509
- return `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">${bars}</svg>`;
510
453
  }
511
-
512
- /* Value shown in the picker. Fixed tiers advertise their
513
- * nominal px; standard and full are inherently relative to the
514
- * current column, so they show the px the app resolves right
515
- * now (full is the column's maximum, not a word). */
516
- function tierValueText(tierId) {
517
- switch (tierId) {
518
- case "standard": {
519
- const root = findWidthRoot();
520
- if (root === null) return "—";
521
- return `${Math.round(Math.max(
522
- DEFAULT_MIN_PX,
523
- Math.min(root.offsetWidth * DEFAULT_COLUMN_RATIO, DEFAULT_MAX_PX)
524
- ))}px`;
525
- }
526
- case "medium":
527
- case "wide":
528
- case "ultra":
529
- return `${NOMINAL_WIDTH_PX[tierId]}px`;
530
- case "full": {
531
- const root = findWidthRoot();
532
- if (root === null) return "—";
533
- return `${Math.round(Math.max(
534
- CONTENT_MIN_PX,
535
- root.offsetWidth - CONTENT_EDGE_BUDGET_PX
536
- ))}px`;
537
- }
538
- case "custom": {
539
- const dragged = readStoredNumber(CUSTOM_KEY);
540
- return dragged === null ? t("value.custom.unset") : `${Math.round(dragged)}px`;
541
- }
542
- default: return "—";
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;
543
460
  }
544
- }
545
-
546
- function syncButton() {
547
- const tier = currentTier();
548
- const def = tierOf(tier);
549
- const label = t(def.key);
550
- const value = tierValueText(tier);
551
- const summary = t("button.title.summary", { label, value });
552
- const hint = t("button.title.hint", {
553
- list: TIERS.map((tierDef) => `${t(tierDef.key)} ${tierValueText(tierDef.id)}`).join(" / ")
554
- });
555
- btn.innerHTML = barIcon(tier);
556
- btn.classList.toggle("active", tier !== "standard");
557
- btn.setAttribute("aria-label", t("button.aria", { label, value }));
558
- btn.title = `${summary}\n${hint}`;
559
- }
560
-
561
- /* ---- tier menu ---- */
562
- const menu = document.createElement("div");
563
- menu.id = "dsh-width-menu";
564
- menu.setAttribute("role", "menu");
565
- menu.hidden = true;
566
-
567
- for (const def of TIERS) {
568
- const item = document.createElement("button");
569
- item.type = "button";
570
- item.setAttribute("role", "menuitemradio");
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;
575
- item.innerHTML = `<span class="dshwm-label">${t(def.key)}</span><span class="dshwm-value">${tierValueText(def.id)}</span>`;
576
- item.addEventListener("click", (e) => {
577
- if (e && e.stopPropagation) e.stopPropagation();
578
- /* Custom before the first drag has no width of its own:
579
- * open the right-edge guide instead of applying a value. */
580
- if (def.id === "custom" && readStoredNumber(CUSTOM_KEY) === null) {
581
- beginCustomGuide();
582
- hideMenu(true);
583
- return;
584
- }
585
- endGuide(false);
586
- applyTier(def.id);
587
- hideMenu(true);
588
- });
589
- menu.appendChild(item);
590
- }
591
-
592
- /* Write only on change: syncMenu also runs on window resizes,
593
- * and untouched text should not churn the DOM (the plugin's own
594
- * MutationObserver watches childList). */
595
- function setText(el, text) {
596
- if (el.textContent !== text) el.textContent = text;
597
- }
598
-
599
- function syncMenu() {
600
- const tier = currentTier();
601
- const items = menu.querySelectorAll("button[data-tier]");
602
- for (let i = 0; i < items.length; i++) {
603
- const id = items[i].getAttribute("data-tier");
604
- const on = id === tier;
605
- items[i].classList.toggle("selected", on);
606
- items[i].setAttribute("aria-checked", String(on));
607
- const labelEl = items[i].querySelector(".dshwm-label");
608
- if (labelEl) setText(labelEl, t(tierOf(id).key));
609
- const valueEl = items[i].querySelector(".dshwm-value");
610
- if (valueEl) setText(valueEl, tierValueText(id));
611
- }
612
- }
613
-
614
- function menuItems() {
615
- return Array.from(menu.querySelectorAll("button[data-tier]"));
616
- }
617
-
618
- function showMenu() {
619
- menu.hidden = false;
620
- btn.setAttribute("aria-expanded", "true");
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();
625
- }
626
-
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);
631
- menu.hidden = true;
632
- btn.setAttribute("aria-expanded", "false");
633
- if (restoreFocus && hadFocus && typeof btn.focus === "function") btn.focus();
634
- }
635
-
636
- btn.addEventListener("click", (e) => {
637
- if (e && e.stopPropagation) e.stopPropagation();
638
- if (menu.hidden) showMenu();
639
- else hideMenu(true);
640
- });
641
-
642
- const onDocClick = (e) => {
643
- if (!menu.hidden && e.target !== btn && !menu.contains(e.target)) hideMenu(false);
644
- /* Clicking anywhere but the guide's handle cancels a
645
- * pending custom guide and restores the previous tier. */
646
- if (guideActive && !guideDragging && e.target !== btn && !menu.contains(e.target) && !isHandleEvent(e)) {
647
- 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;
648
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
649
492
  };
650
- const onDocKey = (e) => {
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();
493
+ return {
494
+ left: `${Math.max(8, rect.left - gap - guideWidth)}px`,
495
+ right: "auto",
496
+ top
674
497
  };
675
- document.addEventListener("click", onDocClick);
676
- document.addEventListener("keydown", onDocKey);
677
-
678
- /* ---- custom-drag guide ----
679
- * A first-time `custom` pick has no recorded width. Instead of
680
- * silently keeping the current width, point at the app's
681
- * right-edge width handle and show the live transcript width
682
- * while the user drags. The guide never writes the width
683
- * itself: the app's native drag does (and wins the cascade),
684
- * and the plugin only records the settled value on release. */
685
- const guide = document.createElement("div");
686
- guide.id = "dsh-width-guide";
687
- guide.setAttribute("aria-hidden", "true");
688
- guide.hidden = true;
689
- guide.innerHTML =
690
- '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">' +
691
- '<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>' +
692
- '<span class="dshwg-text"></span>';
693
- const guideTextEl = guide.querySelector(".dshwg-text");
694
-
695
- let guideActive = false;
696
- let guideDragging = false;
697
- /* true while the bubble came from picking custom in the menu (its
698
- * cancel path restores the previous tier). Direct handle drags
699
- * open the same bubble but never "cancel" a tier. */
700
- let guideFromMenu = false;
701
- let guideHideTimer = null;
702
- let guideHandle = null;
703
- let tierBeforeGuide = "standard";
704
-
705
- /* The px the app currently resolves for the transcript, after
706
- * its own clamp — what the guide's live readout shows. */
707
- function guideValueText() {
708
- const root = findWidthRoot();
709
- const px = root === null ? null : liveWidthPx(root);
710
- return px === null ? "—" : `${px}px`;
711
- }
712
-
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). */
717
- function positionGuide() {
718
- if (!guideActive) return;
719
- const handle = guideHandle !== null && document.contains(guideHandle)
720
- ? guideHandle
721
- : document.querySelector('[data-width-handle="right"]');
722
- const viewport = window.innerWidth || 0;
723
- if (handle === null || typeof handle.getBoundingClientRect !== "function" || viewport === 0) {
724
- guide.style.left = "auto";
725
- guide.style.right = "24px";
726
- guide.style.top = "50%";
727
- return;
728
- }
729
- const rect = handle.getBoundingClientRect();
730
- const gap = 10;
731
- const width = guide.offsetWidth || 180;
732
- guide.style.top = `${rect.top + rect.height / 2}px`;
733
- guide.style.right = "auto";
734
- if (viewport - rect.right >= width + gap) {
735
- guide.style.left = `${rect.right + gap}px`;
736
- } else {
737
- guide.style.left = `${Math.max(8, rect.left - gap - width)}px`;
738
- }
739
- }
740
-
741
- function setGuideText(mode, text) {
742
- guide.dataset.mode = mode;
743
- setText(guideTextEl, text);
744
- }
745
-
746
- function startGuide() {
747
- guideFromMenu = true;
748
- guideActive = true;
749
- guideDragging = false;
750
- guideHandle = null;
751
- guide.hidden = false;
752
- body().setAttribute("data-dsh-width-guide", "custom");
753
- setGuideText("hint", t("guide.hint"));
754
- positionGuide();
755
- }
756
-
757
- /* Every handle press shows the live readout, not just the first
758
- * guided drag — a repeat custom drag must keep the handle
759
- * highlight and the width bubble. */
760
- function startLiveGuide(handle) {
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) {
761
519
  window.clearTimeout(guideHideTimer);
762
- guideActive = true;
763
- guideDragging = true;
764
- guideHandle = handle || null;
765
- guide.hidden = false;
766
- body().setAttribute("data-dsh-width-guide", "custom");
767
- setGuideText("live", guideValueText());
768
- positionGuide();
520
+ guideHideTimer = null;
769
521
  }
770
-
771
- function beginCustomGuide() {
772
- /* Remember the tier to restore on cancel only on the first
773
- * entry; re-picking custom while the guide is open must not
774
- * make "custom" the restore target. */
775
- if (!guideActive) {
776
- tierBeforeGuide = currentTier();
777
- applyTier("custom");
778
- }
779
- startGuide();
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");
780
535
  }
781
-
782
- /* Show the settled width briefly, then remove the guide. */
783
- function finishGuide() {
784
- if (!guideActive) return;
785
- guideDragging = false;
786
- setGuideText("live", guideValueText());
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) {
787
552
  window.clearTimeout(guideHideTimer);
788
- guideHideTimer = window.setTimeout(() => endGuide(false), 800);
553
+ guideHideTimer = null;
789
554
  }
790
-
791
- /* `restoreTier` is for cancelling a menu-opened guide (click-away /
792
- * Escape): if it never recorded a width, go back to the tier the
793
- * user had before opening it. Drag-opened bubbles keep custom. */
794
- function endGuide(restoreTier) {
795
- if (!guideActive) return;
796
- guideActive = false;
797
- guideDragging = false;
798
- window.clearTimeout(guideHideTimer);
799
- guide.hidden = true;
800
- guide.dataset.mode = "hint";
801
- guideHandle = null;
802
- body().removeAttribute("data-dsh-width-guide");
803
- const restore = restoreTier && guideFromMenu && readStoredNumber(CUSTOM_KEY) === null;
804
- guideFromMenu = false;
805
- if (restore) applyTier(tierBeforeGuide);
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 {}
806
592
  }
807
-
808
- const onGuidePointerMove = () => {
809
- if (!guideActive || !guideDragging) return;
810
- setGuideText("live", guideValueText());
811
- positionGuide();
812
- };
813
- document.addEventListener("pointermove", onGuidePointerMove, true);
814
-
815
- /* ---- hand the width over to the app's native drag handles ----
816
- * Pressing a `[data-width-handle]` means the user is taking the
817
- * width over: switch to the `custom` tier so healing stops
818
- * enforcing a preset, and let the app's drag win the cascade
819
- * (it rewrites the same inline property every frame). The
820
- * settled width is recorded on release so it stays
821
- * re-selectable from the menu. */
822
- const isHandleEvent = (e) =>
823
- e.target instanceof Element && e.target.closest("[data-width-handle]") !== null;
824
-
825
- const onHandlePointerDown = (e) => {
826
- if (!isHandleEvent(e)) return;
827
- startLiveGuide(e.target.closest("[data-width-handle]"));
828
- if (currentTier() === "custom") return;
829
- body().setAttribute("data-dsh-width", "custom");
830
- try {
831
- window.localStorage.setItem(KEY, "custom");
832
- } catch (err) {
833
- /* ignore */
834
- }
835
- syncButton();
836
- syncMenu();
837
- };
838
-
839
- const onHandlePointerUp = (e) => {
840
- if (!isHandleEvent(e)) return;
841
- const root = findWidthRoot();
842
- if (root !== null) {
843
- const px = Number.parseFloat(
844
- widthTarget(root).style.getPropertyValue("--dsh-chat-user-width")
845
- );
846
- if (Number.isFinite(px) && px > 0) {
847
- try {
848
- window.localStorage.setItem(CUSTOM_KEY, String(Math.round(px)));
849
- } catch (err) {
850
- /* ignore */
851
- }
852
- }
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;
853
618
  }
854
- if (guideActive) finishGuide();
855
- syncButton();
856
- syncMenu();
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
619
  endGuide(false);
867
- syncButton();
868
- syncMenu();
869
- };
870
- document.addEventListener("pointerdown", onHandlePointerDown, true);
871
- document.addEventListener("pointerup", onHandlePointerUp, true);
872
- document.addEventListener("pointercancel", onHandlePointerCancel, true);
873
-
874
- /* ---- feedback ring so every selection is visibly answered ---- */
875
- function pulse() {
876
- btn.classList.remove("pulse");
877
- void btn.offsetWidth;
878
- btn.classList.add("pulse");
879
- window.clearTimeout(pulseTimer);
880
- pulseTimer = window.setTimeout(() => {
881
- btn.classList.remove("pulse");
882
- pulseTimer = null;
883
- }, 700);
884
- }
885
-
886
- /* ---- re-render localized copy when the app language changes ---- */
887
- const renderTexts = () => {
888
- syncButton();
889
- syncMenu();
890
- if (guideActive && !guideDragging && guide.dataset.mode === "hint") {
891
- setText(guideTextEl, t("guide.hint"));
892
- }
893
- };
894
- const unsubscribeLocale = ctx.locale.subscribe(renderTexts);
895
-
896
- /* ---- attach once the app shell has mounted ---- */
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
- }
916
-
917
- /* The old gate matched [data-sidebar-collapsed] /
918
- * [data-details-collapsed]. 0.1.5-rc.2 renders collapsed
919
- * markers conditionally (`sidebarCollapsed || void 0`) and
920
- * removed data-details-collapsed, so with the sidebar expanded
921
- * nothing ever matched and the button never mounted. Wait for
922
- * the AppFrame overlay layer instead — `data-shell-overlay` is
923
- * rendered unconditionally once the shell exists. */
924
- function attach() {
925
- if (attached || !body()) return;
926
- if (document.querySelector("[data-shell-overlay]") === null) return;
927
- attached = true;
928
- stopWaiting();
929
- try {
930
- window.localStorage.removeItem(OLD_KEY);
931
- } catch (e) {
932
- /* ignore */
933
- }
934
- body().appendChild(btn);
935
- body().appendChild(menu);
936
- body().appendChild(guide);
937
- syncButton();
938
- const saved = getSavedTier();
939
- if (saved !== "standard") applyTier(saved);
940
- startHealing();
941
- if (widthRoot !== null) observeWidthTarget(widthRoot);
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;
629
+ if (!guideActive) return;
630
+ guideStyle = computeGuideStyle();
631
+ emit();
942
632
  }
943
-
944
- readyTimer = setInterval(attach, 300);
945
- readyTimeout = window.setTimeout(() => {
946
- if (attached) return;
633
+ };
634
+ let attached = false;
635
+ let readyTimer = null;
636
+ let slowTimer = null;
637
+ let readyTimeout = null;
638
+ function stopWaiting() {
639
+ if (readyTimer !== null) {
947
640
  clearInterval(readyTimer);
948
641
  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);
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);
969
-
970
- /* ---- self-heal: re-apply the width when the chat tree
971
- * remounts (conversation switch). Panels are never managed
972
- * here — the user may expand/collapse either side any time;
973
- * forcing the rightbar closed only retracts the grid track
974
- * while the seat keeps rendering as an overlay that covers
975
- * the conversation. Driven by DOM mutations, scheduled on the
976
- * next animation frame (before the browser paints) instead of
977
- * a fixed hot loop: the width must land on the fresh root
978
- * before its first paint — otherwise the default width
979
- * flashes for a frame. While the new root has not appeared
980
- * yet (mid-remount), full scans are owned by a backoff retry;
981
- * mutation-driven heals only try the cheap path walk so DOM
982
- * churn cannot storm. ---- */
983
- let rootMissing = false;
984
- let rootRetryDelay = ROOT_RETRY_MIN_MS;
985
- let rootRetryTimer = null;
986
- let healFrame = null;
987
- let resizeFrame = null;
988
- let observer = null;
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
642
  }
1002
- function cancelFrame(id) {
1003
- if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(id);
1004
- else window.clearTimeout(id);
643
+ if (slowTimer !== null) {
644
+ clearInterval(slowTimer);
645
+ slowTimer = null;
1005
646
  }
1006
-
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);
647
+ if (readyTimeout !== null) {
648
+ window.clearTimeout(readyTimeout);
649
+ readyTimeout = null;
1031
650
  }
1032
-
1033
- function scheduleRootRetry() {
1034
- if (rootRetryTimer !== null) return;
1035
- rootRetryTimer = window.setTimeout(() => {
1036
- rootRetryTimer = null;
1037
- const tier = currentTier();
1038
- if (tier === "standard") return;
1039
- const prev = widthRoot;
1040
- const root = findWidthRoot();
1041
- if (root === null) {
1042
- rootRetryDelay = Math.min(rootRetryDelay * 2, ROOT_RETRY_MAX_MS);
1043
- scheduleRootRetry();
1044
- } else {
1045
- rootMissing = false;
1046
- rootRetryDelay = ROOT_RETRY_MIN_MS;
1047
- if (root !== prev) {
1048
- applyContentWidth(tier);
1049
- syncButton();
1050
- syncMenu();
1051
- }
1052
- observeWidthTarget(root);
1053
- }
1054
- }, rootRetryDelay);
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) {
671
+ clearInterval(readyTimer);
672
+ readyTimer = null;
1055
673
  }
1056
-
1057
- function heal(fromObserver) {
1058
- /* The right handle only mounts in the active phase; keep the
1059
- * guide pinned to it once the chat tree settles. */
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. */
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(() => {
1063
713
  if (guideActive && guideDragging) return;
1064
714
  const tier = currentTier();
1065
715
  if (tier === "standard") return;
1066
- const prev = widthRoot;
1067
- let root;
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);
1073
- } else {
1074
- root = findWidthRoot();
1075
- }
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();
1076
729
  if (root === null) {
1077
- if (!rootMissing) {
1078
- rootMissing = true;
1079
- rootRetryDelay = ROOT_RETRY_MIN_MS;
1080
- }
1081
- /* Idempotent: keep a backoff retry alive even if the
1082
- * previous one ended while this tier was standard. */
730
+ rootRetryDelay = Math.min(rootRetryDelay * 2, ROOT_RETRY_MAX_MS);
1083
731
  scheduleRootRetry();
1084
- return;
1085
- }
1086
- if (rootMissing) {
732
+ } else {
1087
733
  rootMissing = false;
1088
- rootRetryDelay = ROOT_RETRY_MIN_MS;
734
+ rootRetryDelay = 200;
735
+ if (root !== prev) {
736
+ applyContentWidth(tier);
737
+ emit();
738
+ }
739
+ observeWidthTarget(root);
1089
740
  }
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);
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;
1099
756
  }
757
+ scheduleRootRetry();
758
+ return;
1100
759
  }
1101
-
1102
- function startHealing() {
1103
- if (typeof MutationObserver !== "undefined") {
1104
- observer = new MutationObserver(() => {
1105
- if (healFrame !== null) return;
1106
- healFrame = scheduleFrame(() => {
1107
- healFrame = null;
1108
- heal(true);
1109
- });
1110
- });
1111
- observer.observe(document.body, {
1112
- subtree: true,
1113
- childList: true,
1114
- attributes: true,
1115
- attributeFilter: ["data-sidebar-collapsed"]
1116
- });
1117
- }
1118
- /* Low-frequency fallback so self-heal still runs if the
1119
- * observer misses something — cheap: no full scan while the
1120
- * cached root is alive. */
1121
- fallbackTimer = setInterval(() => heal(false), FALLBACK_HEAL_MS);
760
+ if (rootMissing) {
761
+ rootMissing = false;
762
+ rootRetryDelay = 200;
1122
763
  }
1123
-
1124
- /* ---- keep the picker and the guide aligned with the live
1125
- * column. Standard/full re-resolve on resize, and the guide
1126
- * must stay beside the handle when the frame resizes. ---- */
1127
- const onWindowResize = () => {
1128
- if (resizeFrame !== null) return;
1129
- resizeFrame = scheduleFrame(() => {
1130
- resizeFrame = null;
1131
- if (!(guideActive && guideDragging)) {
1132
- const tier = currentTier();
1133
- if (tier !== "standard") applyContentWidth(tier);
1134
- }
1135
- syncButton();
1136
- syncMenu();
1137
- 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
+ });
1138
781
  });
1139
- };
1140
- window.addEventListener("resize", onWindowResize);
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
-
1146
- /* ---- disposer ---- */
1147
- 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() {
1148
822
  unsubscribeLocale();
1149
823
  stopWaiting();
1150
- clearInterval(fallbackTimer);
1151
- clearTimeout(rootRetryTimer);
1152
- clearTimeout(guideHideTimer);
1153
- clearTimeout(pulseTimer);
1154
- if (healFrame !== null) cancelFrame(healFrame);
1155
- if (resizeFrame !== null) cancelFrame(resizeFrame);
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
+ }
1156
844
  if (widthObserver !== null) {
1157
845
  widthObserver.disconnect();
1158
846
  widthObserver = null;
@@ -1162,54 +850,377 @@ window.__ModuleLoader__.load({
1162
850
  window.removeEventListener("load", attach);
1163
851
  window.removeEventListener("storage", onStorage);
1164
852
  if (observer !== null) observer.disconnect();
1165
- document.removeEventListener("click", onDocClick);
1166
- document.removeEventListener("keydown", onDocKey);
1167
853
  document.removeEventListener("pointerdown", onHandlePointerDown, true);
1168
854
  document.removeEventListener("pointerup", onHandlePointerUp, true);
1169
855
  document.removeEventListener("pointercancel", onHandlePointerCancel, true);
1170
856
  document.removeEventListener("pointermove", onGuidePointerMove, true);
1171
- if (btn.parentNode) btn.parentNode.removeChild(btn);
1172
- if (menu.parentNode) menu.parentNode.removeChild(menu);
1173
- if (guide.parentNode) guide.parentNode.removeChild(guide);
1174
- if (styleTag.parentNode) styleTag.parentNode.removeChild(styleTag);
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. */
1178
- if (widthRoot !== null) {
1179
- widthTarget(widthRoot).style.removeProperty("--dsh-chat-user-width");
1180
- }
857
+ removeStyles();
858
+ const root = roots.cached();
859
+ if (root !== null) widthTarget(root).style.removeProperty("--dsh-chat-user-width");
1181
860
  if (preferenceOwned) {
1182
861
  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
- }
862
+ if (preferenceWritten !== null && window.localStorage.getItem("dsh.conversation.contentWidth") === preferenceWritten) window.localStorage.removeItem(APP_PREF_KEY);
863
+ } catch {}
1192
864
  preferenceOwned = false;
1193
865
  preferenceWritten = null;
1194
866
  }
1195
- /* Undo a collapse this plugin owned; a manual collapse is
1196
- * left alone. */
1197
867
  if (collapsedByTier) {
1198
868
  try {
1199
869
  if (isSidebarCollapsed()) layout.toggleSidebar();
1200
- } catch (e) {
1201
- /* ignore */
1202
- }
870
+ } catch {}
1203
871
  collapsedByTier = false;
1204
872
  }
1205
873
  guideHandle = null;
1206
- body().removeAttribute("data-dsh-width");
1207
- body().removeAttribute("data-dsh-width-guide");
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
+ /**
884
+ * Every shipped dictionary, keyed by the locale id the DSH locale service
885
+ * accepts. The set matches the languages DSH 0.2.2 ships (zh, en, ja, es,
886
+ * fr, de, ru, pt); other languages fall back through English.
887
+ */
888
+ const dictionaries = {
889
+ zh: {
890
+ "tier.standard": "标准",
891
+ "tier.medium": "中等",
892
+ "tier.wide": "宽",
893
+ "tier.ultra": "超宽",
894
+ "tier.full": "全宽",
895
+ "tier.custom": "自定义",
896
+ "value.custom.unset": "未设置",
897
+ "guide.hint": "拖拽右侧边缘调整对话宽度",
898
+ "button.aria": "对话区宽度档位:{label}({value}),点击选择档位",
899
+ "button.title.summary": "对话区宽度档位:{label}({value})",
900
+ "button.title.hint": "点击选择:{list}"
901
+ },
902
+ en: {
903
+ "tier.standard": "Standard",
904
+ "tier.medium": "Medium",
905
+ "tier.wide": "Wide",
906
+ "tier.ultra": "Ultra",
907
+ "tier.full": "Full",
908
+ "tier.custom": "Custom",
909
+ "value.custom.unset": "Not set",
910
+ "guide.hint": "Drag the right edge to resize",
911
+ "button.aria": "Chat width tier: {label} ({value}). Click to choose a tier.",
912
+ "button.title.summary": "Chat width tier: {label} ({value})",
913
+ "button.title.hint": "Click to choose: {list}"
914
+ },
915
+ ja: {
916
+ "tier.standard": "標準",
917
+ "tier.medium": "中",
918
+ "tier.wide": "ワイド",
919
+ "tier.ultra": "ウルトラワイド",
920
+ "tier.full": "全幅",
921
+ "tier.custom": "カスタム",
922
+ "value.custom.unset": "未設定",
923
+ "guide.hint": "右端をドラッグして会話幅を調整",
924
+ "button.aria": "会話エリアの幅設定:{label}({value})。クリックして選択",
925
+ "button.title.summary": "会話エリアの幅設定:{label}({value})",
926
+ "button.title.hint": "クリックして選択:{list}"
927
+ },
928
+ es: {
929
+ "tier.standard": "Estándar",
930
+ "tier.medium": "Medio",
931
+ "tier.wide": "Ancho",
932
+ "tier.ultra": "Muy ancho",
933
+ "tier.full": "Completo",
934
+ "tier.custom": "Personalizado",
935
+ "value.custom.unset": "Sin definir",
936
+ "guide.hint": "Arrastra el borde derecho para ajustar el ancho",
937
+ "button.aria": "Ancho del área de chat: {label} ({value}). Haz clic para elegir un nivel.",
938
+ "button.title.summary": "Ancho del área de chat: {label} ({value})",
939
+ "button.title.hint": "Haz clic para elegir: {list}"
940
+ },
941
+ fr: {
942
+ "tier.standard": "Standard",
943
+ "tier.medium": "Moyen",
944
+ "tier.wide": "Large",
945
+ "tier.ultra": "Très large",
946
+ "tier.full": "Pleine largeur",
947
+ "tier.custom": "Personnalisé",
948
+ "value.custom.unset": "Non défini",
949
+ "guide.hint": "Faites glisser le bord droit pour ajuster la largeur",
950
+ "button.aria": "Largeur de la zone de chat : {label} ({value}). Cliquez pour choisir un palier.",
951
+ "button.title.summary": "Largeur de la zone de chat : {label} ({value})",
952
+ "button.title.hint": "Cliquez pour choisir : {list}"
953
+ },
954
+ de: {
955
+ "tier.standard": "Standard",
956
+ "tier.medium": "Mittel",
957
+ "tier.wide": "Breit",
958
+ "tier.ultra": "Sehr breit",
959
+ "tier.full": "Volle Breite",
960
+ "tier.custom": "Benutzerdefiniert",
961
+ "value.custom.unset": "Nicht festgelegt",
962
+ "guide.hint": "Rechten Rand ziehen, um die Breite anzupassen",
963
+ "button.aria": "Chatbreite: {label} ({value}). Klicken, um eine Stufe zu wählen.",
964
+ "button.title.summary": "Chatbreite: {label} ({value})",
965
+ "button.title.hint": "Klicken zum Auswählen: {list}"
966
+ },
967
+ ru: {
968
+ "tier.standard": "Стандартная",
969
+ "tier.medium": "Средняя",
970
+ "tier.wide": "Широкая",
971
+ "tier.ultra": "Очень широкая",
972
+ "tier.full": "На всю ширину",
973
+ "tier.custom": "Своя",
974
+ "value.custom.unset": "Не задано",
975
+ "guide.hint": "Перетащите правый край, чтобы изменить ширину",
976
+ "button.aria": "Ширина области чата: {label} ({value}). Нажмите, чтобы выбрать уровень.",
977
+ "button.title.summary": "Ширина области чата: {label} ({value})",
978
+ "button.title.hint": "Нажмите, чтобы выбрать: {list}"
979
+ },
980
+ pt: {
981
+ "tier.standard": "Padrão",
982
+ "tier.medium": "Médio",
983
+ "tier.wide": "Largo",
984
+ "tier.ultra": "Muito largo",
985
+ "tier.full": "Largura total",
986
+ "tier.custom": "Personalizado",
987
+ "value.custom.unset": "Não definido",
988
+ "guide.hint": "Arraste a borda direita para ajustar a largura",
989
+ "button.aria": "Largura da área de chat: {label} ({value}). Clique para escolher um nível.",
990
+ "button.title.summary": "Largura da área de chat: {label} ({value})",
991
+ "button.title.hint": "Clique para escolher: {list}"
992
+ }
993
+ };
994
+ // src/client/WidthTierPicker.tsx
995
+ /** How long the selection ring stays on the button. */
996
+ const PULSE_MS = 700;
997
+ /** Six horizontal bars; one per tier, lit up to the selected index. */
998
+ function LevelIcon({ tier }) {
999
+ const index = TIERS.findIndex((candidate) => candidate.id === tier);
1000
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1001
+ viewBox: "0 0 16 16",
1002
+ width: "16",
1003
+ height: "16",
1004
+ "aria-hidden": "true",
1005
+ focusable: "false",
1006
+ children: [
1007
+ 3.5,
1008
+ 5.3,
1009
+ 7.1,
1010
+ 8.9,
1011
+ 10.7,
1012
+ 12.5
1013
+ ].map((width, bar) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
1014
+ x: 1.5,
1015
+ y: .75 + bar * 2.55,
1016
+ width,
1017
+ height: 1.6,
1018
+ rx: .8,
1019
+ fill: "currentColor",
1020
+ opacity: bar <= index ? 1 : .3
1021
+ }, bar))
1022
+ });
1023
+ }
1024
+ /**
1025
+ * Floating tier picker and drag guide, mounted in `shell.overlay`.
1026
+ *
1027
+ * All width state, persistence and native-handle interception live in the
1028
+ * controller; this component only renders the snapshot and dispatches
1029
+ * actions. Menu focus, Escape/Tab handling, click-away dismissal and the
1030
+ * selection ring are view concerns and stay local here.
1031
+ * @param props - snapshot hook and controller actions.
1032
+ * @returns the button, menu and guide inside one overlay host element.
1033
+ */
1034
+ function WidthTierPicker({ usePicker, actions }) {
1035
+ const state = usePicker((snapshot) => snapshot);
1036
+ const buttonRef = (0, react.useRef)(null);
1037
+ const menuRef = (0, react.useRef)(null);
1038
+ const wrapperRef = (0, react.useRef)(null);
1039
+ const guideRef = (0, react.useRef)(null);
1040
+ const stateRef = (0, react.useRef)(state);
1041
+ const [pulsing, setPulsing] = (0, react.useState)(false);
1042
+ (0, react.useLayoutEffect)(() => {
1043
+ stateRef.current = state;
1044
+ }, [state]);
1045
+ (0, react.useEffect)(() => {
1046
+ if (state.pulse === 0) return;
1047
+ setPulsing(false);
1048
+ const frame = requestAnimationFrame(() => setPulsing(true));
1049
+ const timer = window.setTimeout(() => setPulsing(false), PULSE_MS);
1050
+ return () => {
1051
+ cancelAnimationFrame(frame);
1052
+ window.clearTimeout(timer);
1053
+ };
1054
+ }, [state.pulse]);
1055
+ (0, react.useEffect)(() => {
1056
+ if (!state.menuOpen || menuRef.current === null) return;
1057
+ const rows = Array.from(menuRef.current.querySelectorAll("button[data-tier]"));
1058
+ (rows.find((row) => row.dataset.tier === state.tier) ?? rows[0])?.focus();
1059
+ }, [state.menuOpen, state.tier]);
1060
+ (0, react.useLayoutEffect)(() => {
1061
+ if (!state.guide.active) return;
1062
+ const width = guideRef.current?.offsetWidth ?? 0;
1063
+ if (width > 0) actions.measureGuide(width);
1064
+ }, [
1065
+ state.guide.active,
1066
+ state.guide.text,
1067
+ state.guide.mode,
1068
+ actions
1069
+ ]);
1070
+ (0, react.useEffect)(() => {
1071
+ const onDocClick = (event) => {
1072
+ const target = event.target;
1073
+ if (!(target instanceof Node)) return;
1074
+ const current = stateRef.current;
1075
+ const inside = wrapperRef.current?.contains(target) ?? false;
1076
+ if (current.menuOpen && !inside) actions.closeMenu();
1077
+ if (current.guide.active && !current.guide.dragging && !inside && !(target instanceof Element && target.closest("[data-width-handle]") !== null)) actions.cancelGuide();
1078
+ };
1079
+ const onDocKey = (event) => {
1080
+ const current = stateRef.current;
1081
+ if (event.key === "Escape") {
1082
+ if (current.menuOpen) {
1083
+ actions.closeMenu();
1084
+ buttonRef.current?.focus();
1085
+ }
1086
+ if (current.guide.active) actions.cancelGuide();
1087
+ return;
1088
+ }
1089
+ if (!current.menuOpen) return;
1090
+ if (event.key === "Tab") {
1091
+ actions.closeMenu();
1092
+ return;
1093
+ }
1094
+ const rows = Array.from(menuRef.current?.querySelectorAll("button[data-tier]") ?? []);
1095
+ if (rows.length === 0) return;
1096
+ const active = document.activeElement;
1097
+ const index = active === null ? -1 : rows.indexOf(active);
1098
+ let next = -1;
1099
+ if (event.key === "ArrowDown") next = index < 0 ? 0 : (index + 1) % rows.length;
1100
+ else if (event.key === "ArrowUp") next = index < 0 ? rows.length - 1 : (index - 1 + rows.length) % rows.length;
1101
+ else if (event.key === "Home") next = 0;
1102
+ else if (event.key === "End") next = rows.length - 1;
1103
+ if (next < 0) return;
1104
+ event.preventDefault();
1105
+ rows[next]?.focus();
1106
+ };
1107
+ document.addEventListener("click", onDocClick);
1108
+ document.addEventListener("keydown", onDocKey);
1109
+ return () => {
1110
+ document.removeEventListener("click", onDocClick);
1111
+ document.removeEventListener("keydown", onDocKey);
1112
+ };
1113
+ }, [actions]);
1114
+ const className = [state.button.active ? "active" : "", pulsing ? "pulse" : ""].filter(Boolean).join(" ");
1115
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1116
+ ref: wrapperRef,
1117
+ "data-dsh-width-tiers-host": "",
1118
+ children: [
1119
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1120
+ ref: buttonRef,
1121
+ type: "button",
1122
+ id: "dsh-wide-toggle",
1123
+ "aria-haspopup": "menu",
1124
+ "aria-expanded": state.menuOpen,
1125
+ "aria-label": state.button.ariaLabel,
1126
+ title: state.button.title,
1127
+ className: className || void 0,
1128
+ onClick: () => actions.toggleMenu(),
1129
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LevelIcon, { tier: state.tier })
1130
+ }),
1131
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1132
+ ref: menuRef,
1133
+ id: "dsh-width-menu",
1134
+ role: state.menuOpen ? "menu" : void 0,
1135
+ hidden: !state.menuOpen,
1136
+ children: state.items.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1137
+ type: "button",
1138
+ role: "menuitemradio",
1139
+ "data-tier": item.id,
1140
+ tabIndex: -1,
1141
+ "aria-checked": item.selected,
1142
+ className: item.selected ? "selected" : void 0,
1143
+ onClick: () => {
1144
+ actions.selectTier(item.id);
1145
+ buttonRef.current?.focus();
1146
+ },
1147
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1148
+ className: "dshwm-label",
1149
+ children: item.label
1150
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1151
+ className: "dshwm-value",
1152
+ children: item.value
1153
+ })]
1154
+ }, item.id))
1155
+ }),
1156
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1157
+ ref: guideRef,
1158
+ id: "dsh-width-guide",
1159
+ "aria-hidden": "true",
1160
+ hidden: !state.guide.active,
1161
+ "data-mode": state.guide.mode,
1162
+ style: state.guide.style,
1163
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1164
+ viewBox: "0 0 16 16",
1165
+ width: "14",
1166
+ height: "14",
1167
+ "aria-hidden": "true",
1168
+ focusable: "false",
1169
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
1170
+ d: "M6 3 2 8l4 5M10 3l4 5-4 5",
1171
+ fill: "none",
1172
+ stroke: "currentColor",
1173
+ strokeWidth: 1.5,
1174
+ strokeLinecap: "round",
1175
+ strokeLinejoin: "round"
1176
+ })
1177
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1178
+ className: "dshwg-text",
1179
+ children: state.guide.text
1180
+ })]
1181
+ })
1182
+ ]
1183
+ });
1184
+ }
1185
+ // src/client/index.ts
1186
+ /** Services required by the browser bundle. */
1187
+ const inject = [
1188
+ "slots",
1189
+ "layout",
1190
+ "locale"
1191
+ ];
1192
+ /**
1193
+ * Client plugin body: register the dictionaries, start the width-tier
1194
+ * controller, then contribute the React picker to the frame-wide overlay
1195
+ * layer. The controller owns the width axis and persistence; the slot entry
1196
+ * owns only its own render lifetime.
1197
+ * @param ctx - client root context (slots + layout + locale injected).
1198
+ */
1199
+ function apply(ctx) {
1200
+ ctx.effect(() => {
1201
+ const disposers = Object.entries(dictionaries).map(([locale, dictionary]) => ctx.locale.register(NS, locale, dictionary));
1202
+ return () => {
1203
+ for (const dispose of disposers) dispose();
1208
1204
  };
1209
- }, "dsh-width-tiers: ui");
1205
+ }, "dsh-width-tiers: dictionaries");
1206
+ const t = ctx.locale.bind(NS);
1207
+ const controller = createWidthTiers({
1208
+ layout: ctx.layout,
1209
+ locale: ctx.locale,
1210
+ t
1211
+ });
1212
+ ctx.effect(() => () => controller.dispose(), "dsh-width-tiers: controller");
1213
+ ctx.slots.inject("shell.overlay", () => ctx.slots.register({
1214
+ name: "shell.overlay",
1215
+ id: "width-tiers",
1216
+ order: 100,
1217
+ locale: NS,
1218
+ inject: () => ({
1219
+ hooks: { picker: controller.source },
1220
+ actions: controller.actions
1221
+ })
1222
+ }, WidthTierPicker));
1210
1223
  }
1211
- //#endregion
1212
-
1213
1224
  exports.apply = apply;
1214
1225
  exports.inject = inject;
1215
1226
  return module.exports;