dsh-width-tiers 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,6 +47,11 @@ Restart `dsh web` (the plugin loads at boot) and hard-refresh the page
47
47
  (Ctrl+Shift+R). A round button with five bars appears at the bottom-right.
48
48
 
49
49
  > Installing from a local checkout: `dsh plugin --profile web add file:/path/to/dsh-width-tiers`.
50
+ > Local-development gotcha: `file:` dependencies are **copied** into the
51
+ > profile's `node_modules` (pnpm does not symlink them). After editing the
52
+ > plugin source, re-sync the copy (e.g. `rsync -a --delete --exclude .git
53
+ > /path/to/dsh-width-tiers/ ~/.dsh/profiles/web/node_modules/dsh-width-tiers/`)
54
+ > or re-install after removing it, then restart `dsh web` and hard-refresh.
50
55
 
51
56
  ## Usage
52
57
 
@@ -57,6 +62,14 @@ Restart `dsh web` (the plugin loads at boot) and hard-refresh the page
57
62
  - Non-standard tiers also close the details panel (re-closed automatically if
58
63
  reopened); `standard` leaves everything to the app.
59
64
 
65
+ ## Language
66
+
67
+ All picker copy (tier labels, button `aria-label` and title) follows the
68
+ app's **Settings → Language** (中文 / English), through the app's own locale
69
+ service. Switching the language re-renders the button and menu immediately;
70
+ when no explicit language is chosen the app falls back to the browser's
71
+ language, and Chinese is the last resort. No plugin-side setting needed.
72
+
60
73
  ## How it works
61
74
 
62
75
  - The element that defines `--dsh-chat-content-width` is **located at
@@ -67,6 +80,9 @@ Restart `dsh web` (the plugin loads at boot) and hard-refresh the page
67
80
  simulating clicks.
68
81
  - The sidebar is only ever collapsed by the `full` tier; returning to
69
82
  `standard` restores it only if this plugin collapsed it.
83
+ - Dictionaries are registered with the app's **locale service**
84
+ (`ctx.locale.register` / `bind`) and re-rendered through its change
85
+ subscription, so the copy always matches the app's active language.
70
86
 
71
87
  ## Limitations
72
88
 
package/README.zh.md CHANGED
@@ -43,6 +43,11 @@ dsh plugin --profile web add dsh-width-tiers
43
43
  右下角会出现五条横条的圆形按钮。
44
44
 
45
45
  > 本地目录安装:`dsh plugin --profile web add file:/path/to/dsh-width-tiers`。
46
+ > 本地开发注意:`file:` 依赖会被 **拷贝** 进 profile 的 node_modules(pnpm
47
+ > 不会建软链)。改完插件源码后,需要重新同步拷贝(例如 `rsync -a --delete
48
+ > --exclude .git /path/to/dsh-width-tiers/
49
+ > ~/.dsh/profiles/web/node_modules/dsh-width-tiers/`)或先 remove 再 add,
50
+ > 然后重启 `dsh web` 并硬刷新。
46
51
 
47
52
  ## 使用
48
53
 
@@ -51,6 +56,13 @@ dsh plugin --profile web add dsh-width-tiers
51
56
  随时点轨道图标展开,展开后保持展开。
52
57
  - 非标准档会关闭详情面板(被重新打开会自动再关);标准档完全交给应用。
53
58
 
59
+ ## 多语言
60
+
61
+ 选择器里的所有文案(档位名、按钮的 `aria-label` 和标题)跟随应用的
62
+ **设置 → 语言**(中文 / English),通过应用自身的 locale 服务实现。切换
63
+ 语言后按钮和菜单会立即重渲染;未显式选择语言时,应用会回退到浏览器语言,
64
+ 最后兜底中文。无需插件侧单独设置。
65
+
54
66
  ## 原理
55
67
 
56
68
  - 运行时**自动定位**定义 `--dsh-chat-content-width` 的元素(第一个计算值
@@ -58,6 +70,8 @@ dsh plugin --profile web add dsh-width-tiers
58
70
  - 面板操作走 **layout 服务**(`ctx.layout`),从不模拟点击。
59
71
  - 侧边栏只被「全宽」档收起;回到「标准」时,只有本插件自己收起的侧边栏
60
72
  才会被恢复展开(你手动收的不动)。
73
+ - 字典注册到应用的 **locale 服务**(`ctx.locale.register` / `bind`),并
74
+ 订阅其变更事件重渲染,文案始终与应用的当前语言一致。
61
75
 
62
76
  ## 已知限制
63
77
 
package/lib/client.js CHANGED
@@ -35,18 +35,56 @@ window.__ModuleLoader__.load({
35
35
  * active); `standard` leaves everything to the app.
36
36
  * - The chosen tier persists in localStorage and is restored on the
37
37
  * next visit.
38
+ * - All copy is localized through the app's locale service: tier
39
+ * labels and the button's aria/title follow Settings → Language
40
+ * (zh / en, browser-derived fallback) and re-render on switch.
38
41
  */
39
42
  const KEY = "dsh.widthTier";
40
43
  const OLD_KEY = "dsh.wide";
41
- const SELF_HEAL_MS = 1000;
42
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;
52
+
53
+ /** Dictionary namespace owned by this plugin. */
54
+ const NS = "width-tiers";
55
+
56
+ //#region lib/types/client/locales.js
57
+ /** `width-tiers` namespace dictionaries (tier labels and picker copy). */
58
+ /** Simplified Chinese dictionary (the key-set source of truth). */
59
+ const zh = {
60
+ "tier.standard": "标准",
61
+ "tier.medium": "中等",
62
+ "tier.wide": "宽",
63
+ "tier.ultra": "超宽",
64
+ "tier.full": "全宽",
65
+ "button.aria": "对话区宽度档位:{label}({value}),点击选择档位",
66
+ "button.title.summary": "对话区宽度档位:{label}({value})",
67
+ "button.title.hint": "点击选择:{list}"
68
+ };
69
+ /** English dictionary, checked complete against the zh key set. */
70
+ const en = {
71
+ "tier.standard": "Standard",
72
+ "tier.medium": "Medium",
73
+ "tier.wide": "Wide",
74
+ "tier.ultra": "Ultra",
75
+ "tier.full": "Full",
76
+ "button.aria": "Chat width tier: {label} ({value}). Click to choose a tier.",
77
+ "button.title.summary": "Chat width tier: {label} ({value})",
78
+ "button.title.hint": "Click to choose: {list}"
79
+ };
80
+ //#endregion
43
81
 
44
82
  const TIERS = [
45
- { id: "standard", label: "标准", value: "748px", note: "默认阅读宽度" },
46
- { id: "medium", label: "中等", value: "960px" },
47
- { id: "wide", label: "宽", value: "1280px" },
48
- { id: "ultra", label: "超宽", value: "1400px" },
49
- { id: "full", label: "全宽", value: "100%", note: "占满窗口" }
83
+ { id: "standard", key: "tier.standard", value: "748px" },
84
+ { id: "medium", key: "tier.medium", value: "960px" },
85
+ { id: "wide", key: "tier.wide", value: "1280px" },
86
+ { id: "ultra", key: "tier.ultra", value: "1400px" },
87
+ { id: "full", key: "tier.full", value: "100%" }
50
88
  ];
51
89
 
52
90
  function tierOf(id) {
@@ -76,14 +114,18 @@ window.__ModuleLoader__.load({
76
114
  //#endregion
77
115
 
78
116
  /** Services required by the browser bundle. */
79
- const inject = ["layout"];
117
+ const inject = ["layout", "locale"];
80
118
 
81
119
  /**
82
120
  * Client plugin body: inject the styles, build the picker button and
83
121
  * menu, and drive the width tiers through the layout service.
84
- * @param ctx - client root context (layout injected).
122
+ * @param ctx - client root context (layout + locale injected).
85
123
  */
86
124
  function apply(ctx) {
125
+ ctx.effect(() => ctx.locale.register(NS, {
126
+ zh,
127
+ en
128
+ }), "dsh-width-tiers: dictionaries");
87
129
  ctx.effect(() => {
88
130
  if (typeof document === "undefined") return;
89
131
 
@@ -93,6 +135,7 @@ window.__ModuleLoader__.load({
93
135
  document.head.appendChild(styleTag);
94
136
 
95
137
  const layout = ctx.layout;
138
+ const t = ctx.locale.bind(NS);
96
139
 
97
140
  /* ---- helpers ---- */
98
141
  const body = () => document.body;
@@ -137,25 +180,62 @@ window.__ModuleLoader__.load({
137
180
 
138
181
  /* Locate the element that defines --dsh-chat-content-width at
139
182
  * runtime (first match wins; only one element declares it).
140
- * Cached; re-scanned whenever the cached node is gone. */
183
+ * Cached; the app remounts the chat tree on conversation switch,
184
+ * so re-location first walks the recorded child-index path from
185
+ * <body> (React keeps the tree shape — O(depth), one style
186
+ * read) and only falls back to a full scan. */
141
187
  let widthRoot = null;
188
+ let widthPath = null;
142
189
 
143
- function findWidthRoot() {
144
- if (widthRoot !== null && document.contains(widthRoot)) return widthRoot;
145
- widthRoot = null;
190
+ function hasWidthVar(el) {
191
+ return getComputedStyle(el)
192
+ .getPropertyValue("--dsh-chat-content-width")
193
+ .trim() !== "";
194
+ }
195
+
196
+ function recordWidthPath(el) {
197
+ const path = [];
198
+ let node = el;
199
+ while (node && node !== document.body) {
200
+ const parent = node.parentNode;
201
+ if (!parent || !parent.children) break;
202
+ path.unshift(Array.prototype.indexOf.call(parent.children, node));
203
+ node = parent;
204
+ }
205
+ widthPath = path.length > 0 ? path : null;
206
+ }
207
+
208
+ function scanWidthRoot() {
146
209
  const all = document.querySelectorAll("[class]");
147
210
  for (let i = 0; i < all.length; i++) {
148
- const v = getComputedStyle(all[i])
149
- .getPropertyValue("--dsh-chat-content-width")
150
- .trim();
151
- if (v) {
211
+ if (hasWidthVar(all[i])) {
152
212
  widthRoot = all[i];
213
+ recordWidthPath(widthRoot);
153
214
  return widthRoot;
154
215
  }
155
216
  }
217
+ widthRoot = null;
218
+ widthPath = null;
156
219
  return null;
157
220
  }
158
221
 
222
+ function findWidthRoot(allowScan) {
223
+ if (widthRoot !== null && document.contains(widthRoot)) return widthRoot;
224
+ if (widthPath !== null) {
225
+ let el = document.body;
226
+ for (let i = 0; i < widthPath.length; i++) {
227
+ el = el.children[widthPath[i]];
228
+ if (!el) break;
229
+ }
230
+ if (el && el !== widthRoot && document.contains(el) && hasWidthVar(el)) {
231
+ widthRoot = el;
232
+ return widthRoot;
233
+ }
234
+ }
235
+ if (allowScan === false) return null;
236
+ return scanWidthRoot();
237
+ }
238
+
159
239
  function tierWidth(tier) {
160
240
  switch (tier) {
161
241
  case "medium": return "960px";
@@ -227,13 +307,16 @@ window.__ModuleLoader__.load({
227
307
 
228
308
  function syncButton() {
229
309
  const tier = currentTier();
230
- const t = tierOf(tier);
310
+ const def = tierOf(tier);
311
+ const label = t(def.key);
312
+ const summary = t("button.title.summary", { label, value: def.value });
313
+ const hint = t("button.title.hint", {
314
+ list: TIERS.map((tierDef) => `${t(tierDef.key)} ${tierDef.value}`).join(" / ")
315
+ });
231
316
  btn.innerHTML = barIcon(tier);
232
317
  btn.classList.toggle("active", tier !== "standard");
233
- btn.setAttribute("aria-label", `对话区宽度档位:${t.label}(${t.value}),点击选择档位`);
234
- btn.title =
235
- `对话区宽度档位:${t.label}(${t.value})\n` +
236
- "点击选择:标准 748px / 中等 960px / 宽 1280px / 超宽 1400px / 全宽 100%";
318
+ btn.setAttribute("aria-label", t("button.aria", { label, value: def.value }));
319
+ btn.title = `${summary}\n${hint}`;
237
320
  }
238
321
 
239
322
  /* ---- tier menu ---- */
@@ -242,15 +325,15 @@ window.__ModuleLoader__.load({
242
325
  menu.setAttribute("role", "menu");
243
326
  menu.hidden = true;
244
327
 
245
- for (const t of TIERS) {
328
+ for (const def of TIERS) {
246
329
  const item = document.createElement("button");
247
330
  item.type = "button";
248
331
  item.setAttribute("role", "menuitemradio");
249
- item.setAttribute("data-tier", t.id);
250
- item.innerHTML = `<span class="dshwm-label">${t.label}</span><span class="dshwm-value">${t.value}</span>`;
332
+ item.setAttribute("data-tier", def.id);
333
+ item.innerHTML = `<span class="dshwm-label">${t(def.key)}</span><span class="dshwm-value">${def.value}</span>`;
251
334
  item.addEventListener("click", (e) => {
252
335
  if (e && e.stopPropagation) e.stopPropagation();
253
- applyTier(t.id);
336
+ applyTier(def.id);
254
337
  hideMenu();
255
338
  });
256
339
  menu.appendChild(item);
@@ -263,6 +346,8 @@ window.__ModuleLoader__.load({
263
346
  const on = items[i].getAttribute("data-tier") === tier;
264
347
  items[i].classList.toggle("selected", on);
265
348
  items[i].setAttribute("aria-checked", String(on));
349
+ const labelEl = items[i].querySelector(".dshwm-label");
350
+ if (labelEl) labelEl.textContent = t(tierOf(items[i].getAttribute("data-tier")).key);
266
351
  }
267
352
  }
268
353
 
@@ -301,6 +386,13 @@ window.__ModuleLoader__.load({
301
386
  window.setTimeout(() => btn.classList.remove("pulse"), 700);
302
387
  }
303
388
 
389
+ /* ---- re-render localized copy when the app language changes ---- */
390
+ const renderTexts = () => {
391
+ syncButton();
392
+ syncMenu();
393
+ };
394
+ const unsubscribeLocale = ctx.locale.subscribe(renderTexts);
395
+
304
396
  /* ---- attach once the app shell has mounted ---- */
305
397
  let attached = false;
306
398
 
@@ -318,6 +410,7 @@ window.__ModuleLoader__.load({
318
410
  syncButton();
319
411
  const saved = getSavedTier();
320
412
  if (saved !== "standard") applyTier(saved);
413
+ startHealing();
321
414
  }
322
415
 
323
416
  const readyTimer = setInterval(attach, 300);
@@ -329,19 +422,107 @@ window.__ModuleLoader__.load({
329
422
 
330
423
  /* ---- self-heal: keep the details panel closed for non-standard
331
424
  * tiers. The sidebar is never managed here — the user may expand
332
- * it any time. ---- */
333
- const healTimer = setInterval(() => {
425
+ * it any time. Driven by DOM mutations, scheduled on the next
426
+ * animation frame (before the browser paints) instead of a fixed
427
+ * hot loop: conversation switches remount the chat tree, and the
428
+ * width override must land on the fresh root before its first
429
+ * paint — otherwise the default width flashes for a frame.
430
+ * While the new root has not appeared yet (mid-remount), full
431
+ * scans are owned by a backoff retry; mutation-driven heals only
432
+ * try the cheap path walk so DOM churn cannot storm. ---- */
433
+ let rootMissing = false;
434
+ let rootRetryDelay = ROOT_RETRY_MIN_MS;
435
+ let rootRetryTimer = null;
436
+ let healFrame = null;
437
+ let observer = null;
438
+ let fallbackTimer = null;
439
+
440
+ const scheduleFrame = typeof requestAnimationFrame === "function"
441
+ ? (cb) => { healFrame = requestAnimationFrame(cb); }
442
+ : (cb) => { healFrame = window.setTimeout(cb, 0); };
443
+ const cancelFrame = typeof cancelAnimationFrame === "function"
444
+ ? (id) => cancelAnimationFrame(id)
445
+ : (id) => window.clearTimeout(id);
446
+
447
+ function scheduleRootRetry() {
448
+ if (rootRetryTimer !== null) return;
449
+ rootRetryTimer = window.setTimeout(() => {
450
+ rootRetryTimer = null;
451
+ const tier = currentTier();
452
+ if (tier === "standard") return;
453
+ const prev = widthRoot;
454
+ const root = findWidthRoot();
455
+ if (root === null) {
456
+ rootRetryDelay = Math.min(rootRetryDelay * 2, ROOT_RETRY_MAX_MS);
457
+ scheduleRootRetry();
458
+ } else {
459
+ rootMissing = false;
460
+ rootRetryDelay = ROOT_RETRY_MIN_MS;
461
+ if (root !== prev) applyContentWidth(tier);
462
+ }
463
+ }, rootRetryDelay);
464
+ }
465
+
466
+ function heal(fromObserver) {
334
467
  const tier = currentTier();
335
468
  if (tier === "standard") return;
336
469
  closeDetails();
337
- if (widthRoot !== null && !document.contains(widthRoot)) findWidthRoot();
338
- }, SELF_HEAL_MS);
470
+ const prev = widthRoot;
471
+ let root;
472
+ if (rootMissing) {
473
+ /* Mid-remount: a mutation-driven heal only walks the
474
+ * recorded path (O(depth)) — the backoff retry owns
475
+ * full scans until the new root exists. */
476
+ root = fromObserver ? findWidthRoot(false) : findWidthRoot();
477
+ } else {
478
+ root = findWidthRoot();
479
+ }
480
+ if (root === null) {
481
+ if (!rootMissing) {
482
+ rootMissing = true;
483
+ rootRetryDelay = ROOT_RETRY_MIN_MS;
484
+ scheduleRootRetry();
485
+ }
486
+ return;
487
+ }
488
+ if (rootMissing) {
489
+ rootMissing = false;
490
+ rootRetryDelay = ROOT_RETRY_MIN_MS;
491
+ }
492
+ if (root !== prev) applyContentWidth(tier);
493
+ }
494
+
495
+ function startHealing() {
496
+ if (typeof MutationObserver !== "undefined") {
497
+ observer = new MutationObserver(() => {
498
+ if (healFrame !== null) return;
499
+ scheduleFrame(() => {
500
+ healFrame = null;
501
+ heal(true);
502
+ });
503
+ });
504
+ observer.observe(document.body, {
505
+ subtree: true,
506
+ childList: true,
507
+ attributes: true,
508
+ attributeFilter: ["data-details-collapsed", "data-sidebar-collapsed"]
509
+ });
510
+ }
511
+ /* Low-frequency fallback so self-heal still runs if the
512
+ * observer misses something — cheap: no scan while the
513
+ * cached root is alive. */
514
+ fallbackTimer = setInterval(() => heal(false), FALLBACK_HEAL_MS);
515
+ }
339
516
 
340
517
  /* ---- disposer ---- */
341
518
  return () => {
519
+ unsubscribeLocale();
342
520
  clearInterval(readyTimer);
343
- clearInterval(healTimer);
521
+ clearInterval(fallbackTimer);
344
522
  clearTimeout(timeout);
523
+ clearTimeout(rootRetryTimer);
524
+ if (healFrame !== null) cancelFrame(healFrame);
525
+ if (observer !== null) observer.disconnect();
345
526
  document.removeEventListener("click", onDocClick);
346
527
  document.removeEventListener("keydown", onDocKey);
347
528
  if (btn.parentNode) btn.parentNode.removeChild(btn);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-width-tiers",
3
- "version": "1.0.0",
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.",
3
+ "version": "1.0.2",
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",
7
7
  "publishConfig": {
@@ -24,7 +24,8 @@
24
24
  "client": {
25
25
  "platform": "web",
26
26
  "inject": [
27
- "@deepseek-ai/dsh-client-ui-layout"
27
+ "@deepseek-ai/dsh-client-ui-layout",
28
+ "@deepseek-ai/dsh-client-locale"
28
29
  ]
29
30
  }
30
31
  },