dsh-width-tiers 1.0.1 → 1.0.3

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.
Files changed (2) hide show
  1. package/lib/client.js +152 -18
  2. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -41,8 +41,14 @@ window.__ModuleLoader__.load({
41
41
  */
42
42
  const KEY = "dsh.widthTier";
43
43
  const OLD_KEY = "dsh.wide";
44
- const SELF_HEAL_MS = 1000;
45
44
  const READY_TIMEOUT_MS = 30000;
45
+ /* Self-heal cadence: DOM-driven (MutationObserver scheduling on the
46
+ * next animation frame — before paint, so the width never flashes
47
+ * default) with a low-frequency fallback and backoff retries while
48
+ * the width root is missing (conversation-switch remount). */
49
+ const ROOT_RETRY_MIN_MS = 200;
50
+ const ROOT_RETRY_MAX_MS = 2000;
51
+ const FALLBACK_HEAL_MS = 2000;
46
52
 
47
53
  /** Dictionary namespace owned by this plugin. */
48
54
  const NS = "width-tiers";
@@ -91,13 +97,16 @@ window.__ModuleLoader__.load({
91
97
  //#region lib/types/client/styles.js
92
98
  /** Button + menu styles (theme variables, light/dark aware). */
93
99
  const css = [
94
- "#dsh-wide-toggle{position:fixed;right:20px;bottom:76px;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%;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,transform .2s ease,background .2s ease,color .2s ease}",
95
- "#dsh-wide-toggle:hover{opacity:1;transform:scale(1.08)}",
96
- "#dsh-wide-toggle:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}",
100
+ "#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}",
101
+ "#dsh-wide-toggle::after{content:'';position:fixed;right:10px;bottom:216px;width:36px;height:36px;pointer-events:auto}",
102
+ "#dsh-wide-toggle:hover{opacity:1}",
103
+ "#dsh-wide-toggle:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px;width:36px;border-radius:50%}",
104
+ "#dsh-wide-toggle:not(:hover):not(:focus-visible){width:10px;border-radius:6px}",
105
+ "#dsh-wide-toggle:not(:hover):not(:focus-visible) svg rect{width:6px;x:5px}",
97
106
  "#dsh-wide-toggle.active{background:var(--dsw-alias-state-business-primary);border-color:transparent;color:var(--dsw-static-neutral-00);opacity:1}",
98
107
  "#dsh-wide-toggle.pulse{animation:dsh-wide-pulse .65s ease-out}",
99
108
  "@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}}",
100
- "#dsh-width-menu{position:fixed;right:20px;bottom:124px;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}",
109
+ "#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}",
101
110
  "#dsh-width-menu[hidden]{display:none}",
102
111
  "#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}",
103
112
  "#dsh-width-menu button:hover{background:var(--dsw-alias-interactive-bg-hover)}",
@@ -174,25 +183,62 @@ window.__ModuleLoader__.load({
174
183
 
175
184
  /* Locate the element that defines --dsh-chat-content-width at
176
185
  * runtime (first match wins; only one element declares it).
177
- * Cached; re-scanned whenever the cached node is gone. */
186
+ * Cached; the app remounts the chat tree on conversation switch,
187
+ * so re-location first walks the recorded child-index path from
188
+ * <body> (React keeps the tree shape — O(depth), one style
189
+ * read) and only falls back to a full scan. */
178
190
  let widthRoot = null;
191
+ let widthPath = null;
179
192
 
180
- function findWidthRoot() {
181
- if (widthRoot !== null && document.contains(widthRoot)) return widthRoot;
182
- widthRoot = null;
193
+ function hasWidthVar(el) {
194
+ return getComputedStyle(el)
195
+ .getPropertyValue("--dsh-chat-content-width")
196
+ .trim() !== "";
197
+ }
198
+
199
+ function recordWidthPath(el) {
200
+ const path = [];
201
+ let node = el;
202
+ while (node && node !== document.body) {
203
+ const parent = node.parentNode;
204
+ if (!parent || !parent.children) break;
205
+ path.unshift(Array.prototype.indexOf.call(parent.children, node));
206
+ node = parent;
207
+ }
208
+ widthPath = path.length > 0 ? path : null;
209
+ }
210
+
211
+ function scanWidthRoot() {
183
212
  const all = document.querySelectorAll("[class]");
184
213
  for (let i = 0; i < all.length; i++) {
185
- const v = getComputedStyle(all[i])
186
- .getPropertyValue("--dsh-chat-content-width")
187
- .trim();
188
- if (v) {
214
+ if (hasWidthVar(all[i])) {
189
215
  widthRoot = all[i];
216
+ recordWidthPath(widthRoot);
190
217
  return widthRoot;
191
218
  }
192
219
  }
220
+ widthRoot = null;
221
+ widthPath = null;
193
222
  return null;
194
223
  }
195
224
 
225
+ function findWidthRoot(allowScan) {
226
+ if (widthRoot !== null && document.contains(widthRoot)) return widthRoot;
227
+ if (widthPath !== null) {
228
+ let el = document.body;
229
+ for (let i = 0; i < widthPath.length; i++) {
230
+ el = el.children[widthPath[i]];
231
+ if (!el) break;
232
+ }
233
+ if (el && el !== widthRoot && document.contains(el) && hasWidthVar(el)) {
234
+ widthRoot = el;
235
+ return widthRoot;
236
+ }
237
+ }
238
+ if (allowScan === false) return null;
239
+ return scanWidthRoot();
240
+ }
241
+
196
242
  function tierWidth(tier) {
197
243
  switch (tier) {
198
244
  case "medium": return "960px";
@@ -367,6 +413,7 @@ window.__ModuleLoader__.load({
367
413
  syncButton();
368
414
  const saved = getSavedTier();
369
415
  if (saved !== "standard") applyTier(saved);
416
+ startHealing();
370
417
  }
371
418
 
372
419
  const readyTimer = setInterval(attach, 300);
@@ -378,20 +425,107 @@ window.__ModuleLoader__.load({
378
425
 
379
426
  /* ---- self-heal: keep the details panel closed for non-standard
380
427
  * tiers. The sidebar is never managed here — the user may expand
381
- * it any time. ---- */
382
- const healTimer = setInterval(() => {
428
+ * it any time. Driven by DOM mutations, scheduled on the next
429
+ * animation frame (before the browser paints) instead of a fixed
430
+ * hot loop: conversation switches remount the chat tree, and the
431
+ * width override must land on the fresh root before its first
432
+ * paint — otherwise the default width flashes for a frame.
433
+ * While the new root has not appeared yet (mid-remount), full
434
+ * scans are owned by a backoff retry; mutation-driven heals only
435
+ * try the cheap path walk so DOM churn cannot storm. ---- */
436
+ let rootMissing = false;
437
+ let rootRetryDelay = ROOT_RETRY_MIN_MS;
438
+ let rootRetryTimer = null;
439
+ let healFrame = null;
440
+ let observer = null;
441
+ let fallbackTimer = null;
442
+
443
+ const scheduleFrame = typeof requestAnimationFrame === "function"
444
+ ? (cb) => { healFrame = requestAnimationFrame(cb); }
445
+ : (cb) => { healFrame = window.setTimeout(cb, 0); };
446
+ const cancelFrame = typeof cancelAnimationFrame === "function"
447
+ ? (id) => cancelAnimationFrame(id)
448
+ : (id) => window.clearTimeout(id);
449
+
450
+ function scheduleRootRetry() {
451
+ if (rootRetryTimer !== null) return;
452
+ rootRetryTimer = window.setTimeout(() => {
453
+ rootRetryTimer = null;
454
+ const tier = currentTier();
455
+ if (tier === "standard") return;
456
+ const prev = widthRoot;
457
+ const root = findWidthRoot();
458
+ if (root === null) {
459
+ rootRetryDelay = Math.min(rootRetryDelay * 2, ROOT_RETRY_MAX_MS);
460
+ scheduleRootRetry();
461
+ } else {
462
+ rootMissing = false;
463
+ rootRetryDelay = ROOT_RETRY_MIN_MS;
464
+ if (root !== prev) applyContentWidth(tier);
465
+ }
466
+ }, rootRetryDelay);
467
+ }
468
+
469
+ function heal(fromObserver) {
383
470
  const tier = currentTier();
384
471
  if (tier === "standard") return;
385
472
  closeDetails();
386
- if (widthRoot !== null && !document.contains(widthRoot)) findWidthRoot();
387
- }, SELF_HEAL_MS);
473
+ const prev = widthRoot;
474
+ let root;
475
+ if (rootMissing) {
476
+ /* Mid-remount: a mutation-driven heal only walks the
477
+ * recorded path (O(depth)) — the backoff retry owns
478
+ * full scans until the new root exists. */
479
+ root = fromObserver ? findWidthRoot(false) : findWidthRoot();
480
+ } else {
481
+ root = findWidthRoot();
482
+ }
483
+ if (root === null) {
484
+ if (!rootMissing) {
485
+ rootMissing = true;
486
+ rootRetryDelay = ROOT_RETRY_MIN_MS;
487
+ scheduleRootRetry();
488
+ }
489
+ return;
490
+ }
491
+ if (rootMissing) {
492
+ rootMissing = false;
493
+ rootRetryDelay = ROOT_RETRY_MIN_MS;
494
+ }
495
+ if (root !== prev) applyContentWidth(tier);
496
+ }
497
+
498
+ function startHealing() {
499
+ if (typeof MutationObserver !== "undefined") {
500
+ observer = new MutationObserver(() => {
501
+ if (healFrame !== null) return;
502
+ scheduleFrame(() => {
503
+ healFrame = null;
504
+ heal(true);
505
+ });
506
+ });
507
+ observer.observe(document.body, {
508
+ subtree: true,
509
+ childList: true,
510
+ attributes: true,
511
+ attributeFilter: ["data-details-collapsed", "data-sidebar-collapsed"]
512
+ });
513
+ }
514
+ /* Low-frequency fallback so self-heal still runs if the
515
+ * observer misses something — cheap: no scan while the
516
+ * cached root is alive. */
517
+ fallbackTimer = setInterval(() => heal(false), FALLBACK_HEAL_MS);
518
+ }
388
519
 
389
520
  /* ---- disposer ---- */
390
521
  return () => {
391
522
  unsubscribeLocale();
392
523
  clearInterval(readyTimer);
393
- clearInterval(healTimer);
524
+ clearInterval(fallbackTimer);
394
525
  clearTimeout(timeout);
526
+ clearTimeout(rootRetryTimer);
527
+ if (healFrame !== null) cancelFrame(healFrame);
528
+ if (observer !== null) observer.disconnect();
395
529
  document.removeEventListener("click", onDocClick);
396
530
  document.removeEventListener("keydown", onDocKey);
397
531
  if (btn.parentNode) btn.parentNode.removeChild(btn);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-width-tiers",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "DSH Web client plugin: chat content width tiers (standard / medium / wide / ultra / full) with a floating picker button. Frees wide tables from the app's hard-coded 748px content cap. UI copy follows the app language (zh / en).",
5
5
  "license": "MIT",
6
6
  "author": "aaronlei",