dsh-width-tiers 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -29,11 +29,26 @@ window.__ModuleLoader__.load({
29
29
  * 全宽 full column max (sidebar collapsed to the icon rail)
30
30
  * 自定义 custom user-dragged width (plugin hands width over to
31
31
  * the app's native handles; the dragged value stays
32
- * re-selectable from the menu)
32
+ * re-selectable from the menu). Before the first
33
+ * drag, picking it opens a guide at the right edge
34
+ * and the live column width is shown while dragging.
33
35
  *
34
- * The menu and title always show the px a tier resolves to at the
35
- * CURRENT column (see `tierValueText`), so a clamped tier never
36
- * claims its nominal width on a narrow window.
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.
37
52
  *
38
53
  * - Panels are driven through the layout service (ctx.layout), never
39
54
  * by simulating clicks.
@@ -59,9 +74,10 @@ window.__ModuleLoader__.load({
59
74
  * the horizontal budget both width handles + safe edges reserve. */
60
75
  const CONTENT_MIN_PX = 640;
61
76
  const CONTENT_EDGE_BUDGET_PX = 176;
62
- /* The app's default transcript width (resolveContentWidth's
63
- * no-preference branch): clamp(680px, column × 0.64, 920px). Used to
64
- * show what `standard` actually resolves to at the current column. */
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. */
65
81
  const DEFAULT_MIN_PX = 680;
66
82
  const DEFAULT_MAX_PX = 920;
67
83
  const DEFAULT_COLUMN_RATIO = 0.64;
@@ -87,6 +103,8 @@ window.__ModuleLoader__.load({
87
103
  "tier.ultra": "超宽",
88
104
  "tier.full": "全宽",
89
105
  "tier.custom": "自定义",
106
+ "value.custom.unset": "未设置",
107
+ "guide.hint": "拖拽右侧边缘调整对话宽度",
90
108
  "button.aria": "对话区宽度档位:{label}({value}),点击选择档位",
91
109
  "button.title.summary": "对话区宽度档位:{label}({value})",
92
110
  "button.title.hint": "点击选择:{list}"
@@ -99,22 +117,25 @@ window.__ModuleLoader__.load({
99
117
  "tier.ultra": "Ultra",
100
118
  "tier.full": "Full",
101
119
  "tier.custom": "Custom",
120
+ "value.custom.unset": "Not set",
121
+ "guide.hint": "Drag the right edge to resize",
102
122
  "button.aria": "Chat width tier: {label} ({value}). Click to choose a tier.",
103
123
  "button.title.summary": "Chat width tier: {label} ({value})",
104
124
  "button.title.hint": "Click to choose: {list}"
105
125
  };
106
126
  //#endregion
107
127
 
108
- /* Tier ids and label keys only — every displayed width is resolved at
109
- * render time from the live column (see tierValueText), so the menu
110
- * never claims a width the app would not actually apply. */
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). */
111
131
  const TIERS = [
112
132
  { id: "standard", key: "tier.standard" },
113
133
  { id: "medium", key: "tier.medium" },
114
134
  { id: "wide", key: "tier.wide" },
115
135
  { id: "ultra", key: "tier.ultra" },
116
136
  { id: "full", key: "tier.full" },
117
- /* Reached only by dragging a native width handle. */
137
+ /* Reached by dragging a native width handle, or by picking it in
138
+ * the menu and following the right-edge drag guide. */
118
139
  { id: "custom", key: "tier.custom" }
119
140
  ];
120
141
 
@@ -136,15 +157,22 @@ window.__ModuleLoader__.load({
136
157
  "#dsh-wide-toggle:not(:hover):not(:focus-visible) svg rect{width:6px;x:5px}",
137
158
  "#dsh-wide-toggle.active{background:var(--dsw-alias-state-business-primary);border-color:transparent;color:var(--dsw-static-neutral-00);opacity:1}",
138
159
  "#dsh-wide-toggle.pulse{animation:dsh-wide-pulse .65s ease-out}",
160
+ "@media (prefers-reduced-motion:reduce){#dsh-wide-toggle{transition:none}#dsh-wide-toggle.pulse{animation:none}}",
139
161
  "@keyframes dsh-wide-pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--dsw-alias-state-business-primary) 60%,transparent)}100%{box-shadow:0 0 0 14px transparent}}",
140
162
  "#dsh-width-menu{position:fixed;right:20px;bottom:264px;z-index:1000;min-width:196px;padding:6px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-specific-menu);box-shadow:0 8px 28px rgba(0,0,0,.24);display:flex;flex-direction:column;gap:2px}",
141
163
  "#dsh-width-menu[hidden]{display:none}",
142
164
  "#dsh-width-menu button{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:18px;text-align:left;cursor:pointer}",
143
165
  "#dsh-width-menu button:hover{background:var(--dsw-alias-interactive-bg-hover)}",
144
- "#dsh-width-menu button.disabled{opacity:.45;cursor:default;pointer-events:none}",
166
+ "#dsh-width-menu button:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}",
145
167
  "#dsh-width-menu button.selected{background:var(--dsw-alias-interactive-bg-hover-solid);color:var(--dsw-alias-state-business-primary)}",
146
168
  "#dsh-width-menu .dshwm-value{color:var(--dsw-alias-label-tertiary);font-size:12px}",
147
- "#dsh-width-menu button.selected .dshwm-value{color:var(--dsw-alias-state-business-primary)}"
169
+ "#dsh-width-menu button.selected .dshwm-value{color:var(--dsw-alias-state-business-primary)}",
170
+ "#dsh-width-guide{position:fixed;z-index:1001;display:flex;align-items:center;gap:8px;padding:8px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-specific-menu);color:var(--dsw-alias-label-primary);font-size:12px;line-height:16px;box-shadow:0 8px 28px rgba(0,0,0,.24);pointer-events:none;white-space:nowrap;transform:translateY(-50%)}",
171
+ "#dsh-width-guide[hidden]{display:none}",
172
+ "#dsh-width-guide svg{flex:none;color:var(--dsw-alias-state-business-primary)}",
173
+ "#dsh-width-guide[data-mode=live]{font-size:13px;font-weight:600;font-variant-numeric:tabular-nums}",
174
+ "body[data-dsh-width-guide] [data-width-handle]{background:linear-gradient(to bottom,transparent calc(50% - 64px),color-mix(in srgb,var(--dsw-alias-state-business-primary) 18%,transparent) calc(50% - 64px),color-mix(in srgb,var(--dsw-alias-state-business-primary) 18%,transparent) calc(50% + 64px),transparent calc(50% + 64px));border-radius:3px}",
175
+ "body[data-dsh-width-guide] [data-width-handle]::after{opacity:1}"
148
176
  ].join("");
149
177
  //#endregion
150
178
 
@@ -224,6 +252,13 @@ window.__ModuleLoader__.load({
224
252
  let widthRoot = null;
225
253
  let widthPath = null;
226
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
+
227
262
  function hasWidthVar(el) {
228
263
  return getComputedStyle(el)
229
264
  .getPropertyValue("--dsh-chat-content-width")
@@ -242,14 +277,35 @@ window.__ModuleLoader__.load({
242
277
  widthPath = path.length > 0 ? path : null;
243
278
  }
244
279
 
280
+ /* Sidebar chat tabs render an embedded Conversation whose
281
+ * `--dsh-chat-content-width` is a local min() and never reads
282
+ * `--dsh-chat-user-width`. Writing there would be a no-op on
283
+ * the visible column and could starve the real transcript. */
284
+ function isSidebarConversation(el) {
285
+ return typeof el.closest === "function"
286
+ && el.closest("[data-sidebar-chat]") !== null;
287
+ }
288
+
289
+ function firstWidthRoot(list) {
290
+ for (let i = 0; i < list.length; i++) {
291
+ const el = list[i];
292
+ if (isSidebarConversation(el)) continue;
293
+ if (hasWidthVar(el)) return el;
294
+ }
295
+ return null;
296
+ }
297
+
245
298
  function scanWidthRoot() {
246
- const all = document.querySelectorAll("[class]");
247
- for (let i = 0; i < all.length; i++) {
248
- if (hasWidthVar(all[i])) {
249
- widthRoot = all[i];
250
- recordWidthPath(widthRoot);
251
- return widthRoot;
252
- }
299
+ /* Prefer the stable Conversation data attribute (one or two
300
+ * elements) over every classed node in the document. The
301
+ * [class] walk remains the compatibility fallback for older
302
+ * clients that do not publish data-conversation-content. */
303
+ let found = firstWidthRoot(document.querySelectorAll("[data-conversation-content]"));
304
+ if (found === null) found = firstWidthRoot(document.querySelectorAll("[class]"));
305
+ if (found !== null) {
306
+ widthRoot = found;
307
+ recordWidthPath(widthRoot);
308
+ return widthRoot;
253
309
  }
254
310
  widthRoot = null;
255
311
  widthPath = null;
@@ -257,14 +313,16 @@ window.__ModuleLoader__.load({
257
313
  }
258
314
 
259
315
  function findWidthRoot(allowScan) {
260
- if (widthRoot !== null && document.contains(widthRoot)) return widthRoot;
316
+ if (widthRoot !== null && document.contains(widthRoot)
317
+ && !isSidebarConversation(widthRoot)) return widthRoot;
261
318
  if (widthPath !== null) {
262
319
  let el = document.body;
263
320
  for (let i = 0; i < widthPath.length; i++) {
264
321
  el = el.children[widthPath[i]];
265
322
  if (!el) break;
266
323
  }
267
- if (el && el !== widthRoot && document.contains(el) && hasWidthVar(el)) {
324
+ if (el && el !== widthRoot && document.contains(el)
325
+ && !isSidebarConversation(el) && hasWidthVar(el)) {
268
326
  widthRoot = el;
269
327
  return widthRoot;
270
328
  }
@@ -293,33 +351,51 @@ window.__ModuleLoader__.load({
293
351
  }
294
352
  }
295
353
 
296
- /* Resolve a tier to the px the app would clamp it to
297
- * (resolveContentWidth: max = column - handle budget), or
298
- * null when the tier leaves the width to the app. */
299
- function tierPx(tier, root) {
300
- const cap = Math.max(CONTENT_MIN_PX, root.offsetWidth - CONTENT_EDGE_BUDGET_PX);
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) {
301
361
  switch (tier) {
302
- case "medium": return Math.min(1120, cap);
303
- case "wide": return Math.min(1280, cap);
304
- case "ultra": return Math.min(1400, cap);
305
- case "full": return cap;
306
- case "custom": {
307
- const dragged = readStoredNumber(CUSTOM_KEY);
308
- return dragged === null ? null : Math.min(dragged, cap);
309
- }
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);
310
369
  default: return null; /* standard: app default */
311
370
  }
312
371
  }
313
372
 
314
- /* The px a tier resolves to right now, including the app's own
315
- * default formula for `standard`; null when the tier has no
316
- * width of its own yet (custom before the first drag). */
317
- function resolvedWidthPx(tier, root) {
318
- if (tier === "standard") {
319
- const column = root.offsetWidth;
320
- return Math.round(Math.max(DEFAULT_MIN_PX, Math.min(column * DEFAULT_COLUMN_RATIO, DEFAULT_MAX_PX)));
321
- }
322
- return tierPx(tier, root);
373
+ /* The px actually applied to the column (same floor/max as the
374
+ * app's resolveContentWidth). */
375
+ function appliedTierPx(tier, root) {
376
+ const target = tierTargetPx(tier, root);
377
+ if (target === null) return null;
378
+ const cap = Math.max(CONTENT_MIN_PX, root.offsetWidth - CONTENT_EDGE_BUDGET_PX);
379
+ return Math.min(Math.max(target, CONTENT_MIN_PX), cap);
380
+ }
381
+
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
+ ));
323
399
  }
324
400
 
325
401
  function applyContentWidth(tier) {
@@ -329,28 +405,47 @@ window.__ModuleLoader__.load({
329
405
  /* custom before the first drag has no width of its own —
330
406
  * leave whatever the app last published in place. */
331
407
  if (tier === "custom" && readStoredNumber(CUSTOM_KEY) === null) return;
332
- const px = tierPx(tier, root);
333
- if (px === null) {
334
- if (target.style.getPropertyValue("--dsh-chat-user-width") !== "") {
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) {
335
414
  target.style.removeProperty("--dsh-chat-user-width");
336
415
  try {
337
416
  window.localStorage.removeItem(APP_PREF_KEY);
338
417
  } catch (e) {
339
418
  /* ignore */
340
419
  }
420
+ preferenceOwned = false;
421
+ preferenceWritten = null;
341
422
  }
342
423
  return;
343
424
  }
344
- /* Mirror into the app's preference so window resizes and
345
- * conversation remounts re-clamp through the app's own
346
- * publish cycle instead of relying on plugin healing. */
347
- const next = String(Math.round(px));
348
- if (target.style.getPropertyValue("--dsh-chat-user-width") !== next + "px") {
349
- target.style.setProperty("--dsh-chat-user-width", next + "px");
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
+ }
350
438
  }
439
+ /* Persist the unclamped target even while the root is not
440
+ * laid out (fixed tiers / recorded custom width do not
441
+ * depend on the column). The app clamps it for display and
442
+ * restores it after a resize / remount. */
443
+ const preference = String(Math.round(targetPx));
351
444
  try {
352
- if (window.localStorage.getItem(APP_PREF_KEY) !== next) {
353
- window.localStorage.setItem(APP_PREF_KEY, next);
445
+ if (window.localStorage.getItem(APP_PREF_KEY) !== preference) {
446
+ window.localStorage.setItem(APP_PREF_KEY, preference);
447
+ preferenceOwned = true;
448
+ preferenceWritten = preference;
354
449
  }
355
450
  } catch (e) {
356
451
  /* ignore */
@@ -360,25 +455,30 @@ window.__ModuleLoader__.load({
360
455
  /* true while the CURRENT tier is the one that collapsed the sidebar */
361
456
  let collapsedByTier = false;
362
457
 
363
- function applyTier(tier) {
458
+ /* `persist` is false when another tab already wrote the tier:
459
+ * adopting it must not write localStorage again. */
460
+ function applyTier(tier, persist = true) {
364
461
  body().setAttribute("data-dsh-width", tier);
365
- try {
366
- window.localStorage.setItem(KEY, tier);
367
- } catch (e) {
368
- /* storage unavailable — state still applies this session */
462
+ if (persist) {
463
+ try {
464
+ window.localStorage.setItem(KEY, tier);
465
+ } catch (e) {
466
+ /* storage unavailable — state still applies this session */
467
+ }
369
468
  }
370
469
  applyContentWidth(tier);
371
470
  if (tier === "full") {
372
471
  if (!isSidebarCollapsed()) {
373
472
  collapseSidebar();
374
473
  collapsedByTier = true;
375
- } else {
376
- collapsedByTier = false;
377
474
  }
475
+ /* already collapsed: don't claim ownership we may not have */
378
476
  } else if (tier === "standard") {
379
477
  if (collapsedByTier && isSidebarCollapsed()) expandSidebar();
380
478
  collapsedByTier = false;
381
- } else {
479
+ } else if (!isSidebarCollapsed()) {
480
+ /* the user (or another panel) expanded it — the plugin no
481
+ * longer owns the collapsed state */
382
482
  collapsedByTier = false;
383
483
  }
384
484
  syncButton();
@@ -398,27 +498,49 @@ window.__ModuleLoader__.load({
398
498
  for (let i = 0; i < TIERS.length; i++) {
399
499
  if (TIERS[i].id === tier) idx = i;
400
500
  }
401
- const widths = [3.5, 5.5, 7.5, 10, 13];
501
+ /* One bar per tier so full (5 bars) and custom (6 bars,
502
+ * all lit) remain distinguishable. */
503
+ const widths = [3.5, 5.3, 7.1, 8.9, 10.7, 12.5];
402
504
  let bars = "";
403
- for (let b = 0; b < 5; b++) {
404
- const y = 0.5 + b * 3;
405
- bars += `<rect x="1.5" y="${y}" width="${widths[b]}" height="2" rx="1" fill="currentColor" opacity="${b <= idx ? 1 : 0.3}"/>`;
505
+ for (let b = 0; b < TIERS.length; b++) {
506
+ const y = 0.75 + b * 2.55;
507
+ bars += `<rect x="1.5" y="${y}" width="${widths[b]}" height="1.6" rx="0.8" fill="currentColor" opacity="${b <= idx ? 1 : 0.3}"/>`;
406
508
  }
407
509
  return `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">${bars}</svg>`;
408
510
  }
409
511
 
410
- /* Display value for a tier: the px it resolves to at the live
411
- * column (what the app would actually apply), so narrow windows
412
- * show the clamped value instead of the nominal one. Falls back
413
- * to the recorded drag when the width root is not mounted. */
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). */
414
516
  function tierValueText(tierId) {
415
- const root = findWidthRoot();
416
- if (root !== null) {
417
- const px = resolvedWidthPx(tierId, root);
418
- if (px !== null) return `${px}px`;
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 "—";
419
543
  }
420
- const dragged = readStoredNumber(CUSTOM_KEY);
421
- return dragged === null ? "—" : `${Math.round(dragged)}px`;
422
544
  }
423
545
 
424
546
  function syncButton() {
@@ -447,12 +569,22 @@ window.__ModuleLoader__.load({
447
569
  item.type = "button";
448
570
  item.setAttribute("role", "menuitemradio");
449
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;
450
575
  item.innerHTML = `<span class="dshwm-label">${t(def.key)}</span><span class="dshwm-value">${tierValueText(def.id)}</span>`;
451
576
  item.addEventListener("click", (e) => {
452
577
  if (e && e.stopPropagation) e.stopPropagation();
453
- if (item.classList.contains("disabled")) return;
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);
454
586
  applyTier(def.id);
455
- hideMenu();
587
+ hideMenu(true);
456
588
  });
457
589
  menu.appendChild(item);
458
590
  }
@@ -476,40 +608,210 @@ window.__ModuleLoader__.load({
476
608
  if (labelEl) setText(labelEl, t(tierOf(id).key));
477
609
  const valueEl = items[i].querySelector(".dshwm-value");
478
610
  if (valueEl) setText(valueEl, tierValueText(id));
479
- /* custom is inert until the user has dragged once */
480
- const disabled = id === "custom" && readStoredNumber(CUSTOM_KEY) === null;
481
- items[i].classList.toggle("disabled", disabled);
482
- items[i].setAttribute("aria-disabled", String(disabled));
483
611
  }
484
612
  }
485
613
 
614
+ function menuItems() {
615
+ return Array.from(menu.querySelectorAll("button[data-tier]"));
616
+ }
617
+
486
618
  function showMenu() {
487
619
  menu.hidden = false;
488
620
  btn.setAttribute("aria-expanded", "true");
489
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();
490
625
  }
491
626
 
492
- function hideMenu() {
627
+ /* restoreFocus is true for Escape / item selection, false for a
628
+ * click-away so the click target keeps focus. */
629
+ function hideMenu(restoreFocus) {
630
+ const hadFocus = menu.contains(document.activeElement);
493
631
  menu.hidden = true;
494
632
  btn.setAttribute("aria-expanded", "false");
633
+ if (restoreFocus && hadFocus && typeof btn.focus === "function") btn.focus();
495
634
  }
496
635
 
497
636
  btn.addEventListener("click", (e) => {
498
637
  if (e && e.stopPropagation) e.stopPropagation();
499
638
  if (menu.hidden) showMenu();
500
- else hideMenu();
639
+ else hideMenu(true);
501
640
  });
502
641
 
503
642
  const onDocClick = (e) => {
504
- if (menu.hidden) return;
505
- if (e.target !== btn && !menu.contains(e.target)) hideMenu();
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);
648
+ }
506
649
  };
507
650
  const onDocKey = (e) => {
508
- if (e.key === "Escape" && !menu.hidden) hideMenu();
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();
509
674
  };
510
675
  document.addEventListener("click", onDocClick);
511
676
  document.addEventListener("keydown", onDocKey);
512
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) {
761
+ 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();
769
+ }
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();
780
+ }
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());
787
+ window.clearTimeout(guideHideTimer);
788
+ guideHideTimer = window.setTimeout(() => endGuide(false), 800);
789
+ }
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);
806
+ }
807
+
808
+ const onGuidePointerMove = () => {
809
+ if (!guideActive || !guideDragging) return;
810
+ setGuideText("live", guideValueText());
811
+ positionGuide();
812
+ };
813
+ document.addEventListener("pointermove", onGuidePointerMove, true);
814
+
513
815
  /* ---- hand the width over to the app's native drag handles ----
514
816
  * Pressing a `[data-width-handle]` means the user is taking the
515
817
  * width over: switch to the `custom` tier so healing stops
@@ -521,14 +823,15 @@ window.__ModuleLoader__.load({
521
823
  e.target instanceof Element && e.target.closest("[data-width-handle]") !== null;
522
824
 
523
825
  const onHandlePointerDown = (e) => {
524
- if (!isHandleEvent(e) || currentTier() === "custom") return;
826
+ if (!isHandleEvent(e)) return;
827
+ startLiveGuide(e.target.closest("[data-width-handle]"));
828
+ if (currentTier() === "custom") return;
525
829
  body().setAttribute("data-dsh-width", "custom");
526
830
  try {
527
831
  window.localStorage.setItem(KEY, "custom");
528
832
  } catch (err) {
529
833
  /* ignore */
530
834
  }
531
- collapsedByTier = false;
532
835
  syncButton();
533
836
  syncMenu();
534
837
  };
@@ -536,39 +839,80 @@ window.__ModuleLoader__.load({
536
839
  const onHandlePointerUp = (e) => {
537
840
  if (!isHandleEvent(e)) return;
538
841
  const root = findWidthRoot();
539
- if (root === null) return;
540
- const px = Number.parseFloat(
541
- widthTarget(root).style.getPropertyValue("--dsh-chat-user-width")
542
- );
543
- if (!Number.isFinite(px) || px <= 0) return;
544
- try {
545
- window.localStorage.setItem(CUSTOM_KEY, String(Math.round(px)));
546
- } catch (err) {
547
- /* ignore */
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
+ }
548
853
  }
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
+ endGuide(false);
549
867
  syncButton();
550
868
  syncMenu();
551
869
  };
552
870
  document.addEventListener("pointerdown", onHandlePointerDown, true);
553
871
  document.addEventListener("pointerup", onHandlePointerUp, true);
872
+ document.addEventListener("pointercancel", onHandlePointerCancel, true);
554
873
 
555
874
  /* ---- feedback ring so every selection is visibly answered ---- */
556
875
  function pulse() {
557
876
  btn.classList.remove("pulse");
558
877
  void btn.offsetWidth;
559
878
  btn.classList.add("pulse");
560
- window.setTimeout(() => btn.classList.remove("pulse"), 700);
879
+ window.clearTimeout(pulseTimer);
880
+ pulseTimer = window.setTimeout(() => {
881
+ btn.classList.remove("pulse");
882
+ pulseTimer = null;
883
+ }, 700);
561
884
  }
562
885
 
563
886
  /* ---- re-render localized copy when the app language changes ---- */
564
887
  const renderTexts = () => {
565
888
  syncButton();
566
889
  syncMenu();
890
+ if (guideActive && !guideDragging && guide.dataset.mode === "hint") {
891
+ setText(guideTextEl, t("guide.hint"));
892
+ }
567
893
  };
568
894
  const unsubscribeLocale = ctx.locale.subscribe(renderTexts);
569
895
 
570
896
  /* ---- attach once the app shell has mounted ---- */
571
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
+ }
572
916
 
573
917
  /* The old gate matched [data-sidebar-collapsed] /
574
918
  * [data-details-collapsed]. 0.1.5-rc.2 renders collapsed
@@ -581,6 +925,7 @@ window.__ModuleLoader__.load({
581
925
  if (attached || !body()) return;
582
926
  if (document.querySelector("[data-shell-overlay]") === null) return;
583
927
  attached = true;
928
+ stopWaiting();
584
929
  try {
585
930
  window.localStorage.removeItem(OLD_KEY);
586
931
  } catch (e) {
@@ -588,18 +933,39 @@ window.__ModuleLoader__.load({
588
933
  }
589
934
  body().appendChild(btn);
590
935
  body().appendChild(menu);
936
+ body().appendChild(guide);
591
937
  syncButton();
592
938
  const saved = getSavedTier();
593
939
  if (saved !== "standard") applyTier(saved);
594
940
  startHealing();
941
+ if (widthRoot !== null) observeWidthTarget(widthRoot);
595
942
  }
596
943
 
597
- const readyTimer = setInterval(attach, 300);
598
- window.addEventListener("load", attach);
599
- const timeout = window.setTimeout(() => {
944
+ readyTimer = setInterval(attach, 300);
945
+ readyTimeout = window.setTimeout(() => {
946
+ if (attached) return;
600
947
  clearInterval(readyTimer);
601
- attach();
948
+ readyTimer = null;
949
+ /* A very slow boot should not leave the picker permanently
950
+ * unmounted: keep a low-frequency retry instead of giving up. */
951
+ slowTimer = setInterval(attach, 2000);
602
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);
603
969
 
604
970
  /* ---- self-heal: re-apply the width when the chat tree
605
971
  * remounts (conversation switch). Panels are never managed
@@ -618,15 +984,51 @@ window.__ModuleLoader__.load({
618
984
  let rootRetryDelay = ROOT_RETRY_MIN_MS;
619
985
  let rootRetryTimer = null;
620
986
  let healFrame = null;
987
+ let resizeFrame = null;
621
988
  let observer = null;
622
989
  let fallbackTimer = null;
990
+ let widthObserver = null;
991
+ let observedTarget = null;
992
+
993
+ /* Return the frame id so heal and resize can be debounced and
994
+ * cancelled independently. The old helper always wrote the
995
+ * heal slot, so resizeFrame never became non-null and every
996
+ * resize event scheduled another frame. */
997
+ function scheduleFrame(cb) {
998
+ return typeof requestAnimationFrame === "function"
999
+ ? requestAnimationFrame(cb)
1000
+ : window.setTimeout(cb, 0);
1001
+ }
1002
+ function cancelFrame(id) {
1003
+ if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(id);
1004
+ else window.clearTimeout(id);
1005
+ }
623
1006
 
624
- const scheduleFrame = typeof requestAnimationFrame === "function"
625
- ? (cb) => { healFrame = requestAnimationFrame(cb); }
626
- : (cb) => { healFrame = window.setTimeout(cb, 0); };
627
- const cancelFrame = typeof cancelAnimationFrame === "function"
628
- ? (id) => cancelAnimationFrame(id)
629
- : (id) => window.clearTimeout(id);
1007
+ /* Watch the column that sizes the transcript. Sidebar/rightbar
1008
+ * changes and frame resizes alter it; `full` (and a clamped
1009
+ * fixed/custom width) must follow without polling every
1010
+ * mutation. Guarded for browsers/tests without ResizeObserver. */
1011
+ function observeWidthTarget(root) {
1012
+ if (typeof ResizeObserver === "undefined") return;
1013
+ const target = widthTarget(root);
1014
+ if (target === observedTarget && widthObserver !== null) return;
1015
+ if (widthObserver !== null) {
1016
+ widthObserver.disconnect();
1017
+ widthObserver = null;
1018
+ }
1019
+ observedTarget = target;
1020
+ widthObserver = new ResizeObserver(() => {
1021
+ /* Never fight the app's native drag: it owns the width
1022
+ * while the user is moving a handle. */
1023
+ if (guideActive && guideDragging) return;
1024
+ const tier = currentTier();
1025
+ if (tier === "standard") return;
1026
+ applyContentWidth(tier);
1027
+ syncButton();
1028
+ syncMenu();
1029
+ });
1030
+ widthObserver.observe(target);
1031
+ }
630
1032
 
631
1033
  function scheduleRootRetry() {
632
1034
  if (rootRetryTimer !== null) return;
@@ -642,21 +1044,32 @@ window.__ModuleLoader__.load({
642
1044
  } else {
643
1045
  rootMissing = false;
644
1046
  rootRetryDelay = ROOT_RETRY_MIN_MS;
645
- if (root !== prev) applyContentWidth(tier);
1047
+ if (root !== prev) {
1048
+ applyContentWidth(tier);
1049
+ syncButton();
1050
+ syncMenu();
1051
+ }
1052
+ observeWidthTarget(root);
646
1053
  }
647
1054
  }, rootRetryDelay);
648
1055
  }
649
1056
 
650
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. */
1063
+ if (guideActive && guideDragging) return;
651
1064
  const tier = currentTier();
652
1065
  if (tier === "standard") return;
653
1066
  const prev = widthRoot;
654
1067
  let root;
655
- if (rootMissing) {
656
- /* Mid-remount: a mutation-driven heal only walks the
657
- * recorded path (O(depth)) — the backoff retry owns
658
- * full scans until the new root exists. */
659
- root = fromObserver ? findWidthRoot(false) : findWidthRoot();
1068
+ if (rootMissing || fromObserver) {
1069
+ /* Mid-remount / mutation churn: walk the recorded path
1070
+ * only (O(depth)); the backoff retry owns full scans
1071
+ * until the new root exists. */
1072
+ root = findWidthRoot(false);
660
1073
  } else {
661
1074
  root = findWidthRoot();
662
1075
  }
@@ -664,27 +1077,33 @@ window.__ModuleLoader__.load({
664
1077
  if (!rootMissing) {
665
1078
  rootMissing = true;
666
1079
  rootRetryDelay = ROOT_RETRY_MIN_MS;
667
- scheduleRootRetry();
668
1080
  }
1081
+ /* Idempotent: keep a backoff retry alive even if the
1082
+ * previous one ended while this tier was standard. */
1083
+ scheduleRootRetry();
669
1084
  return;
670
1085
  }
671
1086
  if (rootMissing) {
672
1087
  rootMissing = false;
673
1088
  rootRetryDelay = ROOT_RETRY_MIN_MS;
674
1089
  }
675
- if (root !== prev) applyContentWidth(tier);
676
- /* full tracks the live column: after the sidebar collapse
677
- * animation settles the column widens, and the width must
678
- * follow (applyContentWidth is diff-checked, so steady
679
- * state writes nothing). */
680
- if (tier === "full") applyContentWidth("full");
1090
+ observeWidthTarget(root);
1091
+ if (root !== prev) {
1092
+ applyContentWidth(tier);
1093
+ syncButton();
1094
+ syncMenu();
1095
+ } else if (!fromObserver) {
1096
+ /* Fallback tick: retry an apply that was skipped while
1097
+ * the root had no layout box. */
1098
+ applyContentWidth(tier);
1099
+ }
681
1100
  }
682
1101
 
683
1102
  function startHealing() {
684
1103
  if (typeof MutationObserver !== "undefined") {
685
1104
  observer = new MutationObserver(() => {
686
1105
  if (healFrame !== null) return;
687
- scheduleFrame(() => {
1106
+ healFrame = scheduleFrame(() => {
688
1107
  healFrame = null;
689
1108
  heal(true);
690
1109
  });
@@ -697,53 +1116,95 @@ window.__ModuleLoader__.load({
697
1116
  });
698
1117
  }
699
1118
  /* Low-frequency fallback so self-heal still runs if the
700
- * observer misses something — cheap: no scan while the
1119
+ * observer misses something — cheap: no full scan while the
701
1120
  * cached root is alive. */
702
1121
  fallbackTimer = setInterval(() => heal(false), FALLBACK_HEAL_MS);
703
1122
  }
704
1123
 
705
- /* ---- keep the displayed px honest when the column changes ----
706
- * Window resizes (and layout handle drags) change what every
707
- * tier resolves to, so re-render the copy on the next frame.
708
- * The menu is also re-synced whenever it opens. */
709
- let resizeFrame = null;
710
-
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. ---- */
711
1127
  const onWindowResize = () => {
712
1128
  if (resizeFrame !== null) return;
713
- scheduleFrame(() => {
1129
+ resizeFrame = scheduleFrame(() => {
714
1130
  resizeFrame = null;
1131
+ if (!(guideActive && guideDragging)) {
1132
+ const tier = currentTier();
1133
+ if (tier !== "standard") applyContentWidth(tier);
1134
+ }
715
1135
  syncButton();
716
1136
  syncMenu();
1137
+ positionGuide();
717
1138
  });
718
1139
  };
719
1140
  window.addEventListener("resize", onWindowResize);
720
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
+
721
1146
  /* ---- disposer ---- */
722
1147
  return () => {
723
1148
  unsubscribeLocale();
724
- clearInterval(readyTimer);
1149
+ stopWaiting();
725
1150
  clearInterval(fallbackTimer);
726
- clearTimeout(timeout);
727
1151
  clearTimeout(rootRetryTimer);
1152
+ clearTimeout(guideHideTimer);
1153
+ clearTimeout(pulseTimer);
728
1154
  if (healFrame !== null) cancelFrame(healFrame);
729
1155
  if (resizeFrame !== null) cancelFrame(resizeFrame);
1156
+ if (widthObserver !== null) {
1157
+ widthObserver.disconnect();
1158
+ widthObserver = null;
1159
+ observedTarget = null;
1160
+ }
730
1161
  window.removeEventListener("resize", onWindowResize);
1162
+ window.removeEventListener("load", attach);
1163
+ window.removeEventListener("storage", onStorage);
731
1164
  if (observer !== null) observer.disconnect();
732
1165
  document.removeEventListener("click", onDocClick);
733
1166
  document.removeEventListener("keydown", onDocKey);
734
1167
  document.removeEventListener("pointerdown", onHandlePointerDown, true);
735
1168
  document.removeEventListener("pointerup", onHandlePointerUp, true);
1169
+ document.removeEventListener("pointercancel", onHandlePointerCancel, true);
1170
+ document.removeEventListener("pointermove", onGuidePointerMove, true);
736
1171
  if (btn.parentNode) btn.parentNode.removeChild(btn);
737
1172
  if (menu.parentNode) menu.parentNode.removeChild(menu);
1173
+ if (guide.parentNode) guide.parentNode.removeChild(guide);
738
1174
  if (styleTag.parentNode) styleTag.parentNode.removeChild(styleTag);
739
- /* Hand the width back to the app default; the app's own
740
- * preference (which this plugin mirrors) stays so the last
741
- * width survives a plugin reload — matching native-drag
742
- * semantics. */
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. */
743
1178
  if (widthRoot !== null) {
744
1179
  widthTarget(widthRoot).style.removeProperty("--dsh-chat-user-width");
745
1180
  }
1181
+ if (preferenceOwned) {
1182
+ try {
1183
+ /* Only remove the exact value this plugin wrote; a
1184
+ * native drag since then owns the preference. */
1185
+ if (preferenceWritten !== null
1186
+ && window.localStorage.getItem(APP_PREF_KEY) === preferenceWritten) {
1187
+ window.localStorage.removeItem(APP_PREF_KEY);
1188
+ }
1189
+ } catch (e) {
1190
+ /* ignore */
1191
+ }
1192
+ preferenceOwned = false;
1193
+ preferenceWritten = null;
1194
+ }
1195
+ /* Undo a collapse this plugin owned; a manual collapse is
1196
+ * left alone. */
1197
+ if (collapsedByTier) {
1198
+ try {
1199
+ if (isSidebarCollapsed()) layout.toggleSidebar();
1200
+ } catch (e) {
1201
+ /* ignore */
1202
+ }
1203
+ collapsedByTier = false;
1204
+ }
1205
+ guideHandle = null;
746
1206
  body().removeAttribute("data-dsh-width");
1207
+ body().removeAttribute("data-dsh-width-guide");
747
1208
  };
748
1209
  }, "dsh-width-tiers: ui");
749
1210
  }