dsh-panel-control 0.3.16 → 0.7.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/client.js CHANGED
@@ -1,3 +1,12 @@
1
+ (function () {
2
+ // This bundle is served as a classic script and the page can evaluate it twice in one document
3
+ // (its own startup script plus the composed batch). At top level that is a "const" redeclaration,
4
+ // which is a parse-time SyntaxError - it aborts the boot instead of just skipping a duplicate.
5
+ // Everything below therefore lives inside this closure. Re-evaluation must stay possible: when the
6
+ // plugin is switched off the loader drops our factory, so switching it back on re-executes this
7
+ // script and needs the registration that follows.
8
+ const BUILD_STAMP = 'dsh-panel-control/0.7.1';
9
+ try { window.__dpcEvaluated = BUILD_STAMP; } catch { /* ignore */ }
1
10
  /**
2
11
  * Client half of dsh-panel-control.
3
12
  *
@@ -21,7 +30,232 @@
21
30
  * collapsed sidebar from leaving the list in a wrong state.
22
31
  */
23
32
 
33
+
34
+ /**
35
+ * Page-level error recorder.
36
+ *
37
+ * Another plugin's client half can disappear while the Host still reports it
38
+ * running: the only place that failure is visible is this page's console, and
39
+ * the console is not persisted anywhere. This hook is installed at script
40
+ * evaluation time (before the rest of the boot list activates) and keeps a
41
+ * small ring of errors/warnings so a later report can carry them to the Host
42
+ * log. It must never throw and never change control flow.
43
+ */
44
+ const PAGE_LOG = '__dpcPageLog';
45
+ (function installPageRecorder() {
46
+ try {
47
+ const box = window[PAGE_LOG] || (window[PAGE_LOG] = { started: Date.now(), events: [] });
48
+ if (box.installed) return;
49
+ box.installed = true;
50
+ const push = (kind, text) => {
51
+ try {
52
+ if (box.events.length >= 80) box.events.shift();
53
+ box.events.push(`${kind} ${String(text).slice(0, 600)}`);
54
+ pageBeacon(`page ${kind} ${String(text).slice(0, 500)}`);
55
+ const all = box.events.map((line) => String(line).replace(/[\r\n]+/g, ' ')).join(' ~ ');
56
+ pageNote(`events(${box.events.length}) ${all.slice(-3000)}`);
57
+ } catch { /* recording is best effort */ }
58
+ };
59
+ const describe = (value) => {
60
+ try {
61
+ if (!value) return '';
62
+ if (typeof value === 'string') return value;
63
+ if (value.stack) return String(value.stack);
64
+ if (value.message) return String(value.message);
65
+ return JSON.stringify(value).slice(0, 400);
66
+ } catch {
67
+ return String(value);
68
+ }
69
+ };
70
+ window.addEventListener('error', (event) => {
71
+ push('error', `${(event && event.message) || 'error'} @${(event && event.filename) || ''}:${(event && event.lineno) || 0} ${describe(event && event.error)}`);
72
+ }, true);
73
+ window.addEventListener('unhandledrejection', (event) => {
74
+ push('rejection', describe(event && event.reason));
75
+ }, true);
76
+ for (const level of ['error', 'warn']) {
77
+ const original = console[level];
78
+ if (typeof original !== 'function') continue;
79
+ console[level] = function patchedConsole(...args) {
80
+ try {
81
+ push(`console.${level}`, args.map(describe).join(' '));
82
+ } catch { /* never break logging */ }
83
+ return original.apply(this, args);
84
+ };
85
+ }
86
+ } catch { /* the recorder is optional */ }
87
+ })();
88
+
89
+
90
+ /**
91
+ * Trace channel that does not depend on the Host.
92
+ *
93
+ * `/plugins/` is answered by another plugin's shim (dsh-unlag) before our Host
94
+ * route runs, so a probe script cannot tell us what the page did — and the Host
95
+ * log cannot tell us whether our module ever executed. `localStorage` is written
96
+ * by this module itself, so reading Chromium's Local Storage LevelDB offline
97
+ * answers it exactly:
98
+ *
99
+ * top - the bundle executed at all (a missing key means it never did)
100
+ * build - the factory ran
101
+ * apply - the runner called our apply
102
+ * ready - our slots are registered
103
+ *
104
+ * `#ids=` is the boot manifest's entry count, `#ml=` says whether the module
105
+ * loader exists, `#errs=` carries the page's last boot errors.
106
+ */
107
+ const TRACE_KEY = 'dsh.panel-control.trace';
108
+ const PAGE_KEY = 'dsh.panel-control.page';
109
+ /** A short, always-rewritten record ("DPCPAGE|" marks it) for the offline reader. */
110
+ function pageNote(text) {
111
+ try {
112
+ window.localStorage.setItem(PAGE_KEY, `DPCPAGE|${String(text).slice(0, 3500)}`);
113
+ } catch { /* storage is best effort */ }
114
+ }
115
+ /** Talk to the Host before any service exists: a GET the route answers and logs. */
116
+ function pageBeacon(text) {
117
+ try {
118
+ const transport = window.__DSH_TRANSPORT__;
119
+ const base = transport && typeof transport.streamBaseUrl === 'string' ? transport.streamBaseUrl.replace(/\/+$/, '') : '';
120
+ const url = `${base}/api/dsh-panel-control/classify?d=${encodeURIComponent(String(text).slice(0, 700))}`;
121
+ window.fetch(url, { mode: 'no-cors', keepalive: true, cache: 'no-store' }).catch(() => {});
122
+ } catch { /* a beacon must never break the boot */ }
123
+ }
124
+ function traceBoot(tag, silent) {
125
+ try {
126
+ const box = window.__dpcTrace || (window.__dpcTrace = []);
127
+ if (!silent && box[box.length - 1] !== tag) box.push(tag);
128
+ const bootText = window.__dpcBoot || (window.__dpcBoot = describeBoot());
129
+ const log = window.__dpcPageLog;
130
+ const errs = log && Array.isArray(log.events)
131
+ ? log.events.slice(-2).map((event) => String((event && (event.detail || event.text || event.message)) || event)).join(' / ')
132
+ : '';
133
+ pageBeacon(`trace ${tag} :: ${box.join('>')} :: ${bootText} :: ${errs.slice(0, 200).replace(/[\r\n]+/g, ' ')}`);
134
+ pageNote(`${box.join('>')} @${new Date().toISOString()} :: ${bootText} :: errs=${errs.slice(0, 500).replace(/[\r\n]+/g, ' ')}`);
135
+ window.localStorage.setItem(
136
+ TRACE_KEY,
137
+ `${box.join('>')} @${new Date().toISOString()} #ml=${typeof window.__ModuleLoader__} #errs=${errs.replace(/[\r\n]+/g, ' ').slice(0, 400)} || ${bootText}`,
138
+ );
139
+ } catch { /* a trace must never break the boot */ }
140
+ }
141
+
142
+ /** What the boot handed this page. The full list goes under its own key; the
143
+ * one-line trace only says what a person has to be told to read it right. */
144
+ function describeBoot() {
145
+ try {
146
+ const boot = window.__DSH_BOOT__;
147
+ if (!boot) return 'boot=none';
148
+ const keys = Object.keys(boot).slice(0, 20).join(',');
149
+ const list = Array.isArray(boot.entries) ? boot.entries : (Array.isArray(boot.ids) ? boot.ids : []);
150
+ const rows = list.map((item) => (typeof item === 'string' ? { id: item } : item || {}));
151
+ const ids = rows.map((row) => String(row.id || '?'));
152
+ const counts = {};
153
+ for (const id of ids) counts[id] = (counts[id] || 0) + 1;
154
+ const dupes = Object.entries(counts).filter(([, times]) => times > 1).map(([id, times]) => `${id}x${times}`).join(',');
155
+ const immediate = rows.filter((row) => row.immediately === true).map((row) => String(row.id || '?'));
156
+ const batches = Array.isArray(boot.batches) ? boot.batches.map((batch) => String((batch && batch.url) || '')) : [];
157
+ const own = rows.find((row) => row.id === 'dsh-panel-control') || null;
158
+ try {
159
+ window.localStorage.setItem(
160
+ `${TRACE_KEY}.boot`,
161
+ JSON.stringify({ rev: boot.rev, release: boot.release, keys, n: ids.length, ids, dupes, immediate, batches, own }).slice(0, 30_000),
162
+ );
163
+ } catch { /* the summary below still says the part that matters */ }
164
+ return `boot.keys=${keys} n=${ids.length} dupes=${dupes || 'none'} immediate=${immediate.join(',') || 'none'} own=${own ? `${own.id}@${own.rev || '?'}${own.immediately ? '/immediately' : ''}` : 'MISSING'}`;
165
+ } catch (error) {
166
+ return `boot.err=${String((error && error.message) || error).slice(0, 200)}`;
167
+ }
168
+ }
169
+ try { traceBoot('top'); } catch { /* ignore */ }
170
+ // --- diagnostic self-reload ---------------------------------------------------
171
+ // The desktop window has no reload shortcut, so an updated bundle can only reach a
172
+ // live page when the page reloads itself once. It is capped hard: at most two
173
+ // reloads per ten minutes, and never for a revision this window already loaded.
174
+ // It exists so a fix can be tested without a person pressing the refresh button.
175
+ const SELF_RELOAD_KEY = 'dsh.panel-control.selfreload';
176
+ function ownScriptSrc() {
177
+ try {
178
+ const own = document.currentScript;
179
+ if (own && typeof own.src === 'string' && own.src.includes('dsh-panel-control')) return own.src;
180
+ const all = Array.from(document.scripts || []);
181
+ for (let i = all.length - 1; i >= 0; i -= 1) {
182
+ const src = String(all[i].src || '');
183
+ if (src.includes('dsh-panel-control/client.js')) return src;
184
+ }
185
+ } catch { /* ignore */ }
186
+ return '';
187
+ }
188
+ function maybeSelfReload() {
189
+ try {
190
+ if (!window.__DSH_BOOT__) return;
191
+ const src = ownScriptSrc();
192
+ if (src === '') return;
193
+ const now = Date.now();
194
+ const born = Math.max(0, now - Number((window.performance && window.performance.timeOrigin) || now));
195
+ let state = null;
196
+ try { state = JSON.parse(localStorage.getItem(SELF_RELOAD_KEY) || 'null'); } catch { /* ignore */ }
197
+ if (state === null || typeof state !== 'object') state = { src: '', at: [] };
198
+ const recent = (Array.isArray(state.at) ? state.at : []).filter((t) => typeof t === 'number' && now - t < 120000);
199
+ // One reload per two minutes, for either reason below: a reload that keeps coming back is
200
+ // worse than a plugin that is missing, because nobody can use the window at all.
201
+ if (recent.length > 0) return;
202
+ // Two reasons to reload, and both end at "the page boots from one manifest again":
203
+ // - a newer bundle: this window is running code older than what the Host now serves;
204
+ // - a hot apply: this document was already up for seconds when our script arrived, which
205
+ // is the delta path. DSH composes that delta as a second batch, and a plugin that is
206
+ // also its own startup script then loads twice; the second instance re-registers the
207
+ // slot ids the first one filled and the whole start() aborts. A fresh document has one
208
+ // manifest, one instance per plugin, and none of that.
209
+ const srcChanged = state.src !== src;
210
+ const hotApplied = born > 4000;
211
+ if (!srcChanged && !hotApplied) return;
212
+ recent.push(now);
213
+ try { localStorage.setItem(SELF_RELOAD_KEY, JSON.stringify({ src, at: recent })); } catch { /* ignore */ }
214
+ traceBoot(hotApplied ? 'selfreload-hot' : 'selfreload-new');
215
+ setTimeout(() => { try { window.location.reload(); } catch { /* ignore */ } }, 1200);
216
+ } catch { /* ignore */ }
217
+ }
218
+ try { maybeSelfReload(); } catch { /* ignore */ }
219
+ // --- self-heal ---------------------------------------------------------------
220
+ // A document that boots while the plugin set is being recomposed can end up with two module
221
+ // instances of one plugin: the second apply re-registers the slot ids the first filled, throws,
222
+ // and DSH aborts the whole start() - which is why a working plugin can be missing while its
223
+ // neighbour looks fine. Nothing here can repair that page; a fresh document composes one set
224
+ // and boots clean. So: if this module ran and apply never landed, reload once. Capped at three
225
+ // tries per ten minutes, and never while the page is still loading, so a page that cannot boot
226
+ // this plugin does not turn into a reload loop.
227
+ const HEAL_KEY = 'dsh.panel-control.heal';
228
+ function scheduleHeal() {
229
+ try {
230
+ if (!window.__DSH_BOOT__) return;
231
+ setTimeout(() => {
232
+ try {
233
+ if (window.__dpcApplied === true) return;
234
+ if (document.readyState !== 'complete') return;
235
+ const now = Date.now();
236
+ let seen = [];
237
+ try { seen = JSON.parse(localStorage.getItem(HEAL_KEY) || '[]'); } catch { /* ignore */ }
238
+ if (!Array.isArray(seen)) seen = [];
239
+ seen = seen.filter((t) => typeof t === 'number' && now - t < 600000);
240
+ if (seen.length >= 3) { try { traceBoot('heal-givenup'); } catch { /* ignore */ } return; }
241
+ seen.push(now);
242
+ try { localStorage.setItem(HEAL_KEY, JSON.stringify(seen)); } catch { /* ignore */ }
243
+ try { traceBoot('heal'); } catch { /* ignore */ }
244
+ window.location.reload();
245
+ } catch { /* ignore */ }
246
+ }, 12000);
247
+ } catch { /* ignore */ }
248
+ }
249
+ try { scheduleHeal(); } catch { /* ignore */ }
250
+ // Keep rewriting for half a minute: a late boot error then still lands in the file.
251
+ try {
252
+ for (const delay of [1200, 4000, 9000, 20000, 35000]) {
253
+ setTimeout(() => { try { traceBoot('tick', true); } catch { /* ignore */ } }, delay);
254
+ }
255
+ } catch { /* ignore */ }
256
+
24
257
  function build(require) {
258
+ try { traceBoot('build'); } catch { /* ignore */ }
25
259
  const React = require('react');
26
260
  const h = React.createElement;
27
261
 
@@ -132,6 +366,21 @@ function build(require) {
132
366
  diagFile: { zh: '设置文件', en: 'settings file' },
133
367
  diagOrigin: { zh: '页面来源', en: 'page origin' },
134
368
  autoTitle: { zh: '自动分类', en: 'Auto classify' },
369
+ layoutTitle: { zh: '侧栏布局', en: 'Sidebar layout' },
370
+ layoutHint: {
371
+ zh: '导航项变多时,让导航区自己收缩并滚动,下面的会话与工作区就不会被挤到看不见。兜底只作用于本页,不改官方包。',
372
+ en: 'When the panel list grows, let it shrink and scroll so sessions and workspaces keep their height. The guard only touches this page; the official package is untouched.',
373
+ },
374
+ layoutGuard: { zh: '高度兜底', en: 'Height guard' },
375
+ layoutGuardHint: { zh: '导航区超过上限后在原地滚动。', en: 'Scroll inside the list once it passes the cap.' },
376
+ layoutCollapse: { zh: '分组可折叠', en: 'Collapsible groups' },
377
+ layoutCollapseHint: { zh: '点分组标题收起该组,标题与数量仍保留。', en: 'Click a group title to fold it; the title and count stay.' },
378
+ layoutMax: { zh: '高度上限', en: 'Height cap' },
379
+ layoutMaxNone: { zh: '不限', en: 'No cap' },
380
+ layoutCollapseAll: { zh: '全部收起', en: 'Fold all' },
381
+ layoutExpandAll: { zh: '全部展开', en: 'Unfold all' },
382
+ layoutCollapseOne: { zh: '点此收起', en: 'click to fold' },
383
+ layoutExpandOne: { zh: '点此展开', en: 'click to unfold' },
135
384
  autoHint: {
136
385
  zh: '让默认模型把入口分成 2–4 组,结果保存在本机。',
137
386
  en: 'The default model files the entries into 2-4 groups; the result stays on this machine.',
@@ -216,6 +465,10 @@ function build(require) {
216
465
  assign: {},
217
466
  auto: false,
218
467
  autoFiled: [],
468
+ collapsed: [],
469
+ guard: true,
470
+ collapse: true,
471
+ panelMax: 45,
219
472
  });
220
473
 
221
474
  /**
@@ -226,7 +479,7 @@ function build(require) {
226
479
  * would then build that many separators. The numbers here are far above any
227
480
  * real sidebar and turn a hostile or corrupted store into a cheap no-op.
228
481
  */
229
- const LIMITS = { order: 200, hidden: 200, autoFiled: 400, groups: 24, assign: 400, name: 40, key: 120 };
482
+ const LIMITS = { order: 200, hidden: 200, autoFiled: 400, collapsed: 200, groups: 24, assign: 400, name: 40, key: 120 };
230
483
 
231
484
  /** Dedupe, drop anything that is not a usable id, and stop at `cap`. */
232
485
  function uniqueIds(raw, cap) {
@@ -249,6 +502,14 @@ function build(require) {
249
502
  out.order = uniqueIds(raw.order, LIMITS.order);
250
503
  out.autoFiled = uniqueIds(raw.autoFiled, LIMITS.autoFiled);
251
504
  out.hidden = uniqueIds(raw.hidden, LIMITS.hidden);
505
+ out.collapsed = uniqueIds(raw.collapsed, LIMITS.collapsed);
506
+ // Files written before the layout guard existed must come back with the
507
+ // guard on: the whole point is that an old sidebar keeps working. An
508
+ // explicit false is the only way to turn the CSS off.
509
+ out.guard = raw.guard !== false;
510
+ out.collapse = raw.collapse !== false;
511
+ const cap = Math.round(Number(raw.panelMax));
512
+ out.panelMax = Number.isFinite(cap) ? Math.min(70, Math.max(0, cap)) : 45;
252
513
  if (Array.isArray(raw.groups)) {
253
514
  const seen = new Set();
254
515
  for (const group of raw.groups) {
@@ -296,6 +557,7 @@ function build(require) {
296
557
  ((prefs.order || []).length > 0 ||
297
558
  (prefs.hidden || []).length > 0 ||
298
559
  (prefs.groups || []).length > 0 ||
560
+ (prefs.collapsed || []).length > 0 ||
299
561
  (prefs.autoFiled || []).length > 0 ||
300
562
  prefs.auto === true);
301
563
 
@@ -340,11 +602,6 @@ function build(require) {
340
602
  return;
341
603
  }
342
604
  const missing = hostIds.filter((id) => !boot.ids.has(id));
343
- // The Host can hand this very plugin to a page whose list predates it (see
344
- // `recovery nudge` in the Host half). In that case the page is still missing
345
- // an entry, but it is this one and it is already on screen: nagging the user
346
- // to reload for it would be noise. Every other gap still counts.
347
- const selfOnly = missing.length === 1 && missing[0] === PACKAGE && value.recovered === true;
348
605
  // Only a real gap is worth a banner. A revision that merely differs proves
349
606
  // nothing: the Host rebuilds that graph whenever any plugin file changes, so
350
607
  // comparing revisions nagged people whose page was missing nothing at all.
@@ -353,7 +610,7 @@ function build(require) {
353
610
  // Already said on an earlier load and a reload did not help: keep quiet, the
354
611
  // diagnostics still list it.
355
612
  const repeated = gap.length > 0 && readNagged() === gap;
356
- const stale = !selfOnly && missing.length > 0 && !repeated;
613
+ const stale = missing.length > 0 && !repeated;
357
614
  if (stale) rememberNagged(gap);
358
615
  else if (!missing.length) rememberNagged('');
359
616
  if (!stale) forgetReloadMark();
@@ -382,10 +639,66 @@ function build(require) {
382
639
  }, SAVE_DEBOUNCE_MS);
383
640
  }
384
641
 
385
- function reportHost(detail) {
642
+ /**
643
+ * Prove from the Host side that this half executed at all.
644
+ *
645
+ * The API route can refuse the page (403) and every `hostCall` then returns
646
+ * null in silence, so "ran but muted" is indistinguishable from "never ran".
647
+ * A throwaway bundle request rides the same channel the real bundles use, and
648
+ * the Host logs every bundle it is asked for, refusals included.
649
+ */
650
+ function reportHost(detail) {
386
651
  void hostCall('report', { ...detail, href: String(locationMode()), at: Date.now() });
387
652
  }
388
653
 
654
+ let pageLogSent = 0;
655
+
656
+ /** Ship the page's recent errors/warnings to the Host log, once per change. */
657
+ function dumpPageLog(tag) {
658
+ try {
659
+ const box = window[PAGE_LOG];
660
+ if (!box || !Array.isArray(box.events) || box.events.length === 0) return;
661
+ if (box.events.length === pageLogSent) return;
662
+ pageLogSent = box.events.length;
663
+ reportHost({ kind: 'page-log', tag, total: box.events.length, events: box.events.slice(-40) });
664
+ } catch { /* a dump must never break apply */ }
665
+ }
666
+ /** Every /plugins/ script this page asked for, with the status the browser saw.
667
+ * A plugin that never registered is either a request that failed or a module
668
+ * that failed after arriving; the two need different fixes, so log both. */
669
+ let slotsSent = '';
670
+
671
+ function dumpNet(tag) {
672
+ try {
673
+ const list = typeof performance !== 'undefined' && typeof performance.getEntriesByType === 'function'
674
+ ? performance.getEntriesByType('resource')
675
+ : [];
676
+ const rows = list
677
+ .filter((row) => String(row.name || '').includes('/plugins/'))
678
+ .slice(-60)
679
+ .map((row) => {
680
+ const url = String(row.name || '').replace(/^[a-z]+:\/\/[^/]+/i, '');
681
+ const status = row.responseStatus === undefined ? '?' : row.responseStatus;
682
+ return `${status}|${Math.round(row.transferSize || 0)}|${Math.round(row.duration || 0)}ms|${url.slice(0, 130)}`;
683
+ });
684
+ if (rows.length === 0) return;
685
+ reportHost({ kind: 'net', tag, scripts: rows });
686
+ } catch { /* diagnostics only */ }
687
+ }
688
+
689
+ /** Which plugins the panel slot holds right now, and every time that changes. */
690
+ function dumpSlots(slots, tag) {
691
+ try {
692
+ if (!slots || typeof slots.entriesOfSlot !== 'function') return;
693
+ const ids = slots.entriesOfSlot('sidebar.panellist')
694
+ .map((entry) => String((entry && entry.options && entry.options.id) || ''))
695
+ .join(',');
696
+ if (tag !== 'change' && ids === slotsSent) return;
697
+ slotsSent = ids;
698
+ reportHost({ kind: 'slots', tag, ids });
699
+ } catch { /* diagnostics only */ }
700
+ }
701
+
389
702
  /** `location.href` minus any query, so one line says which origin we ran on. */
390
703
  function locationMode() {
391
704
  try {
@@ -689,7 +1002,7 @@ function build(require) {
689
1002
  let pollTimer = null;
690
1003
  let lastApplyAt = 0;
691
1004
 
692
- const diag = { found: false, rows: 0, mapped: 0, at: 0, listClass: '', error: null };
1005
+ const diag = { found: false, rows: 0, mapped: 0, at: 0, listClass: '', error: null, guard: true, panelMax: 45, folded: '' };
693
1006
 
694
1007
  const readPlan = () => {
695
1008
  const entries = readEntries(ctx.slots);
@@ -723,6 +1036,39 @@ function build(require) {
723
1036
  return { entries, prefs, hidden, blocks };
724
1037
  };
725
1038
 
1039
+ /**
1040
+ * One line per real pass naming what the list holds right now.
1041
+ *
1042
+ * A row a sibling plugin registered and the page then lost is invisible from
1043
+ * here: the registration is gone before the DOM is touched. The dump records
1044
+ * both sides (slot entries and buttons), so the side that lost the row says
1045
+ * which half dropped it.
1046
+ */
1047
+ let dumpedKeys = '';
1048
+ const dumpPanellist = (plan, list) => {
1049
+ try {
1050
+ const buttons = Array.from(list.querySelectorAll(':scope > button'));
1051
+ const rows = buttons.map((button) => {
1052
+ const label = button.getAttribute('aria-label') || '';
1053
+ const order = button.style.order || '';
1054
+ const shown = button.style.display === 'none' ? 'hidden' : 'shown';
1055
+ return `${label}|${shown}|${order}`;
1056
+ });
1057
+ const key = rows.join(';');
1058
+ if (key === dumpedKeys) return;
1059
+ dumpedKeys = key;
1060
+ reportHost({
1061
+ kind: 'panellist',
1062
+ listClass: diag.listClass,
1063
+ entries: plan.entries.map((entry) => `${entry.id}@${entry.order}`).join(','),
1064
+ hidden: Array.from(plan.hidden).join(','),
1065
+ rows: rows.join(' · ').slice(0, 1500),
1066
+ });
1067
+ } catch {
1068
+ /* a dump must never break the pass */
1069
+ }
1070
+ };
1071
+
726
1072
  const findList = (entries) => {
727
1073
  if (listEl && listEl.isConnected && listEl.querySelector(':scope > button')) return listEl;
728
1074
  listEl = null;
@@ -753,18 +1099,114 @@ function build(require) {
753
1099
  return null;
754
1100
  };
755
1101
 
756
- const makeSeparator = (name, order) => {
1102
+ /**
1103
+ * A group title, and the fold control for that group.
1104
+ *
1105
+ * The host sidebar has no grouping of its own, so this element is the only
1106
+ * thing here the user can click. It is built inside the host's list, which
1107
+ * means it must survive that list's React re-renders: no listeners on the
1108
+ * list, only on the node itself, replaced on every pass.
1109
+ */
1110
+ const makeSeparator = (block, order) => {
757
1111
  const element = document.createElement('div');
758
1112
  element.className = 'dpc-sep';
759
1113
  element.setAttribute('data-dpc-sep', '');
1114
+ element.setAttribute('data-dpc-group-id', block.id);
760
1115
  element.style.order = String(order);
1116
+ if (block.id !== '') {
1117
+ element.setAttribute('role', 'button');
1118
+ element.setAttribute('tabindex', '0');
1119
+ element.setAttribute('aria-expanded', block.folded ? 'false' : 'true');
1120
+ element.setAttribute('aria-label', block.name + ' · ' + t(block.folded ? 'layoutExpandOne' : 'layoutCollapseOne'));
1121
+ if (block.folded) element.setAttribute('data-dpc-collapsed', '');
1122
+ const toggle = (event) => {
1123
+ if (event) event.preventDefault();
1124
+ toggleCollapse(block.id);
1125
+ };
1126
+ element.addEventListener('click', toggle);
1127
+ element.addEventListener('keydown', (event) => {
1128
+ if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') toggle(event);
1129
+ });
1130
+ }
1131
+ const caret = document.createElement('span');
1132
+ caret.className = 'dpc-caret';
1133
+ caret.setAttribute('aria-hidden', 'true');
761
1134
  const label = document.createElement('span');
762
1135
  label.className = 'dpc-sepLabel';
763
- label.textContent = name;
1136
+ label.textContent = block.name;
1137
+ const count = document.createElement('span');
1138
+ count.className = 'dpc-sepCount';
1139
+ count.textContent = String(block.count);
1140
+ element.appendChild(caret);
764
1141
  element.appendChild(label);
1142
+ element.appendChild(count);
765
1143
  return element;
766
1144
  };
767
1145
 
1146
+ /**
1147
+ * Mark the host nodes the layout CSS hangs off.
1148
+ *
1149
+ * The sidebar ships `panelList{flex:none}` next to `regionArea{flex:1;
1150
+ * overflow:hidden}`: a long panel list takes whatever height it wants and
1151
+ * the session area collapses to nothing with no scroller. Those class names
1152
+ * are hashed per build, so the hooks are attributes we add here and remove
1153
+ * in destroy(); the CSS in SIDEBAR_CSS keys off them, never off the hashed
1154
+ * names.
1155
+ */
1156
+ let regionEl = null;
1157
+ const tagLayout = (list, prefs) => {
1158
+ try {
1159
+ const off = prefs.guard === false;
1160
+ if (off) {
1161
+ list.removeAttribute('data-dpc-list');
1162
+ list.style.removeProperty('--dpc-panellist-max');
1163
+ list.removeAttribute('data-dpc-unbounded');
1164
+ if (regionEl) regionEl.removeAttribute('data-dpc-region');
1165
+ regionEl = null;
1166
+ diag.guard = false;
1167
+ return;
1168
+ }
1169
+ list.setAttribute('data-dpc-list', '');
1170
+ const max = Number(prefs.panelMax) || 0;
1171
+ if (max > 0) {
1172
+ list.style.setProperty('--dpc-panellist-max', max + 'vh');
1173
+ list.removeAttribute('data-dpc-unbounded');
1174
+ } else {
1175
+ list.style.removeProperty('--dpc-panellist-max');
1176
+ list.setAttribute('data-dpc-unbounded', '');
1177
+ }
1178
+ const parent = list.parentElement;
1179
+ let region = null;
1180
+ if (parent) {
1181
+ for (const sibling of Array.from(parent.children)) {
1182
+ if (sibling === list) continue;
1183
+ const className = typeof sibling.className === 'string' ? sibling.className : '';
1184
+ if (className.includes('regionArea')) {
1185
+ region = sibling;
1186
+ break;
1187
+ }
1188
+ }
1189
+ }
1190
+ if (regionEl && regionEl !== region) regionEl.removeAttribute('data-dpc-region');
1191
+ if (region) region.setAttribute('data-dpc-region', '');
1192
+ regionEl = region;
1193
+ diag.panelMax = max;
1194
+ } catch {
1195
+ /* a different list shape: leave the sidebar alone this pass */
1196
+ }
1197
+ };
1198
+
1199
+ /** Fold or unfold one group, persisting the choice through commit(). */
1200
+ const toggleCollapse = (id) => {
1201
+ if (!id) return;
1202
+ const prefs = store.state.prefs;
1203
+ if (prefs.collapse === false) return;
1204
+ const next = new Set(prefs.collapsed || []);
1205
+ if (next.has(id)) next.delete(id);
1206
+ else next.add(id);
1207
+ commit({ ...prefs, collapsed: Array.from(next) });
1208
+ };
1209
+
768
1210
  const pause = () => {
769
1211
  if (observer && observing) {
770
1212
  observer.disconnect();
@@ -809,11 +1251,23 @@ function build(require) {
809
1251
  }
810
1252
  diag.listClass = typeof list.className === 'string' ? list.className.split(/\s+/)[0] || '' : '';
811
1253
 
1254
+ diag.guard = plan.prefs.guard !== false;
1255
+ diag.panelMax = plan.prefs.panelMax;
1256
+ // Run on every pass, before the signature shortcut: changing the height
1257
+ // cap or the guard must reach the DOM even when the rows did not move.
1258
+ tagLayout(list, plan.prefs);
1259
+
1260
+ const wide = list.getBoundingClientRect().width >= WIDE_MIN_WIDTH;
1261
+ const folding = wide && plan.prefs.collapse !== false;
1262
+ const folded = folding ? new Set(plan.prefs.collapsed || []) : new Set();
1263
+ diag.folded = Array.from(folded).join(',');
1264
+
812
1265
  const nextSignature = JSON.stringify({
813
1266
  blocks: plan.blocks,
814
1267
  hidden: plan.hidden ? Array.from(plan.hidden).sort() : [],
815
1268
  entries: plan.entries.map((entry) => entry.id + ':' + entry.label),
816
- wide: list.getBoundingClientRect().width >= WIDE_MIN_WIDTH,
1269
+ wide,
1270
+ folded: Array.from(folded),
817
1271
  });
818
1272
  const separatorsLeft = list.querySelectorAll('[data-dpc-sep]').length;
819
1273
  if (nextSignature === signature && (separatorsLeft > 0 || plan.blocks.length <= 1)) {
@@ -821,11 +1275,11 @@ function build(require) {
821
1275
  return;
822
1276
  }
823
1277
  signature = nextSignature;
1278
+ dumpPanellist(plan, list);
824
1279
 
825
1280
  pause();
826
1281
  try {
827
1282
  const buttons = Array.from(list.querySelectorAll(':scope > button'));
828
- const wide = list.getBoundingClientRect().width >= WIDE_MIN_WIDTH;
829
1283
  for (const node of Array.from(list.querySelectorAll('[data-dpc-sep]'))) node.remove();
830
1284
 
831
1285
  const map = mapButtons(plan.entries, buttons);
@@ -841,17 +1295,25 @@ function build(require) {
841
1295
 
842
1296
  let position = ORDER_START;
843
1297
  for (const block of plan.blocks) {
844
- const visible = block.ids.filter((id) => !plan.hidden.has(id) && map.has(id));
845
- if (visible.length === 0) continue;
1298
+ const inBlock = block.ids.filter((id) => map.has(id));
1299
+ const shown = inBlock.filter((id) => !plan.hidden.has(id));
1300
+ if (shown.length === 0) continue;
1301
+ const foldedHere = block.id !== '' && folded.has(block.id);
1302
+ // The title stays while the group is folded: it is the only way back,
1303
+ // and the count keeps the hidden rows discoverable.
846
1304
  if (wide && block.name) {
847
- list.appendChild(makeSeparator(block.name, position));
1305
+ list.appendChild(
1306
+ makeSeparator({ id: block.id, name: block.name, count: shown.length, folded: foldedHere }, position),
1307
+ );
848
1308
  position += ORDER_STEP;
849
1309
  }
850
- for (const id of visible) {
1310
+ if (foldedHere) continue;
1311
+ for (const id of shown) {
851
1312
  const button = map.get(id);
852
1313
  button.style.order = String(position);
853
1314
  button.style.display = '';
854
1315
  button.removeAttribute('data-dpc-hidden');
1316
+ button.removeAttribute('data-dpc-collapsed');
855
1317
  button.setAttribute('data-dpc-group', block.name || '');
856
1318
  position += ORDER_STEP;
857
1319
  }
@@ -862,6 +1324,19 @@ function build(require) {
862
1324
  if (!button) continue;
863
1325
  button.style.display = 'none';
864
1326
  button.setAttribute('data-dpc-hidden', '');
1327
+ button.removeAttribute('data-dpc-collapsed');
1328
+ }
1329
+
1330
+ // Folding is a separate axis from "hidden in this plugin": a folded row
1331
+ // is still shown in the settings list and comes back with one click.
1332
+ for (const block of plan.blocks) {
1333
+ if (block.id === '' || !folded.has(block.id)) continue;
1334
+ for (const id of block.ids) {
1335
+ const button = map.get(id);
1336
+ if (!button || plan.hidden.has(id)) continue;
1337
+ button.style.display = 'none';
1338
+ button.setAttribute('data-dpc-collapsed', '');
1339
+ }
865
1340
  }
866
1341
 
867
1342
  // A row we could not map is left in place, but kept after every managed row.
@@ -945,6 +1420,14 @@ function build(require) {
945
1420
  node.style.removeProperty('display');
946
1421
  }
947
1422
  }
1423
+ for (const node of Array.from(document.querySelectorAll('[data-dpc-list]'))) {
1424
+ node.removeAttribute('data-dpc-list');
1425
+ node.removeAttribute('data-dpc-unbounded');
1426
+ node.style.removeProperty('--dpc-panellist-max');
1427
+ }
1428
+ for (const node of Array.from(document.querySelectorAll('[data-dpc-collapsed]'))) node.removeAttribute('data-dpc-collapsed');
1429
+ if (regionEl) regionEl.removeAttribute('data-dpc-region');
1430
+ regionEl = null;
948
1431
  listEl = null;
949
1432
  };
950
1433
 
@@ -1722,6 +2205,96 @@ function build(require) {
1722
2205
  );
1723
2206
  };
1724
2207
 
2208
+ // The sidebar is the host's DOM, but the trade-off is the user's: this card
2209
+ // is the only place the guard, the cap and the fold switch are explained.
2210
+ const layoutOn = prefs.guard !== false;
2211
+ const foldingOn = prefs.collapse !== false;
2212
+ const capNow = typeof prefs.panelMax === 'number' ? prefs.panelMax : 45;
2213
+ const setLayout = (patch) => commit({ ...prefsNow(), ...patch });
2214
+ const layoutSwitch = (on, label, onClick) =>
2215
+ h(
2216
+ 'button',
2217
+ {
2218
+ type: 'button',
2219
+ className: 'dpc-switch',
2220
+ role: 'switch',
2221
+ 'aria-checked': on ? 'true' : 'false',
2222
+ 'aria-label': label,
2223
+ onClick,
2224
+ },
2225
+ h('span', { className: 'dpc-switchKnob', 'aria-hidden': true }),
2226
+ );
2227
+ const layoutRow = (name, hint, control) =>
2228
+ h(
2229
+ 'div',
2230
+ { className: 'dpc-layoutRow' },
2231
+ h(
2232
+ 'div',
2233
+ { className: 'dpc-layoutText' },
2234
+ h('span', { className: 'dpc-layoutName' }, name),
2235
+ hint ? h('p', { className: 'dpc-note' }, hint) : null,
2236
+ ),
2237
+ control,
2238
+ );
2239
+ const layout = h(
2240
+ 'section',
2241
+ { className: 'dpc-card dpc-layout' },
2242
+ h('h2', { className: 'dpc-cardTitle' }, t('layoutTitle')),
2243
+ h('p', { className: 'dpc-note' }, t('layoutHint')),
2244
+ layoutRow(
2245
+ t('layoutGuard'),
2246
+ t('layoutGuardHint'),
2247
+ layoutSwitch(layoutOn, t('layoutGuard'), () => setLayout({ guard: !layoutOn })),
2248
+ ),
2249
+ layoutRow(
2250
+ t('layoutCollapse'),
2251
+ t('layoutCollapseHint'),
2252
+ layoutSwitch(foldingOn, t('layoutCollapse'), () => setLayout({ collapse: !foldingOn })),
2253
+ ),
2254
+ layoutRow(
2255
+ t('layoutMax'),
2256
+ null,
2257
+ h(
2258
+ 'div',
2259
+ { className: 'dpc-segment', role: 'group', 'aria-label': t('layoutMax') },
2260
+ [35, 45, 55, 0].map((value) =>
2261
+ h(
2262
+ 'button',
2263
+ {
2264
+ key: String(value),
2265
+ type: 'button',
2266
+ className: value === capNow ? 'dpc-segmentBtn dpc-segmentBtn--on' : 'dpc-segmentBtn',
2267
+ 'aria-pressed': value === capNow ? 'true' : 'false',
2268
+ disabled: !layoutOn,
2269
+ onClick: () => setLayout({ panelMax: value }),
2270
+ },
2271
+ value === 0 ? t('layoutMaxNone') : value + 'vh',
2272
+ ),
2273
+ ),
2274
+ ),
2275
+ ),
2276
+ layoutOn && groupsOf.length > 1
2277
+ ? h(
2278
+ 'div',
2279
+ { className: 'dpc-layoutActions' },
2280
+ h(
2281
+ 'button',
2282
+ {
2283
+ type: 'button',
2284
+ className: 'dpc-link',
2285
+ onClick: () => setLayout({ collapsed: groupsOf.map((group) => group.id) }),
2286
+ },
2287
+ t('layoutCollapseAll'),
2288
+ ),
2289
+ h(
2290
+ 'button',
2291
+ { type: 'button', className: 'dpc-link', onClick: () => setLayout({ collapsed: [] }) },
2292
+ t('layoutExpandAll'),
2293
+ ),
2294
+ )
2295
+ : null,
2296
+ );
2297
+
1725
2298
  const groups =
1726
2299
  groupsOf.length === 0
1727
2300
  ? h(
@@ -1793,6 +2366,7 @@ function build(require) {
1793
2366
  head,
1794
2367
  notice,
1795
2368
  auto,
2369
+ layout,
1796
2370
  order.length === 0
1797
2371
  ? h(
1798
2372
  'section',
@@ -1934,6 +2508,23 @@ function build(require) {
1934
2508
  .dpc-ghost:hover:not(:disabled){border-color:var(--dsw-alias-border-l3,rgba(127,127,127,.32));background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.16));color:var(--dsw-alias-label-primary,#8a8a8a)}
1935
2509
  .dpc-ghost:disabled{opacity:.4;cursor:default}
1936
2510
 
2511
+ /* Sidebar layout card: one row per switch, a segmented control for the cap.
2512
+ Hairlines only sit *between* rows: the first row has none, and the card
2513
+ hint keeps a real gap above it, so the small print never touches a line. */
2514
+ .dpc-layout{display:flex;flex-direction:column}
2515
+ .dpc-layout > .dpc-note{margin:6px 0 10px}
2516
+ .dpc-layoutRow{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 0;border-top:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.14))}
2517
+ .dpc-layoutRow:first-of-type{padding-top:4px;border-top:0}
2518
+ .dpc-layoutText{display:flex;flex:1 1 auto;flex-direction:column;gap:3px;min-width:0}
2519
+ .dpc-layoutName{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.95))}
2520
+ .dpc-layoutText .dpc-note{margin:0;line-height:17px}
2521
+ .dpc-layoutActions{display:flex;justify-content:flex-end;gap:4px;margin-top:12px;padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.14))}
2522
+ .dpc-layoutRow .dpc-switch,.dpc-layoutRow .dpc-segment{flex:none}
2523
+ .dpc-segment{display:inline-flex;flex:none;gap:2px;padding:2px;border-radius:var(--dsw-radius-sm,6px);background:var(--dsw-alias-fill-l2,rgba(127,127,127,.10))}
2524
+ .dpc-segmentBtn{appearance:none;border:0;background:transparent;padding:2px 8px;border-radius:4px;font:inherit;font-size:11px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.8));cursor:pointer;transition:background-color 120ms cubic-bezier(.16,1,.3,1),color 120ms cubic-bezier(.16,1,.3,1)}
2525
+ .dpc-segmentBtn--on{background:var(--dsw-alias-settings-card-fill,rgba(127,127,127,.22));color:var(--dsw-alias-label-primary,#8a8a8a)}
2526
+ .dpc-segmentBtn:disabled{opacity:.4;cursor:default}
2527
+
1937
2528
  @media (max-width:1000px){
1938
2529
  /* Below the column width the frame becomes the gutter instead of pushing
1939
2530
  content past the window edge. */
@@ -1959,16 +2550,32 @@ function build(require) {
1959
2550
  `;
1960
2551
 
1961
2552
  const SIDEBAR_CSS = `
1962
- .dpc-sep{display:flex;align-items:center;gap:8px;flex:none;padding:12px 8px 4px;font-size:11px;font-weight:500;line-height:14px;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.78));white-space:nowrap;overflow:hidden;pointer-events:none}
2553
+ /* Fallback for a sidebar that grows past the window: the list scrolls
2554
+ instead of pushing the session area to zero height. The host ships
2555
+ flex:none there and overflow:hidden below it (see #12083). */
2556
+ [data-dpc-list]{flex:0 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;max-height:max(120px,var(--dpc-panellist-max,45vh));overscroll-behavior:contain;scrollbar-gutter:stable}
2557
+ [data-dpc-list][data-dpc-unbounded]{max-height:none}
2558
+ [data-dpc-list]>button{flex:none}
2559
+ [data-dpc-region]{flex:1 1 auto;min-height:min(120px,18vh)}
2560
+
2561
+ .dpc-sep{display:flex;align-items:center;gap:6px;flex:none;padding:12px 8px 4px;font-size:11px;font-weight:500;line-height:14px;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.78));white-space:nowrap;overflow:hidden;cursor:pointer;border-radius:var(--dsw-radius-sm,6px);user-select:none}
2562
+ .dpc-sep:hover{color:var(--dsw-alias-label-secondary,rgba(127,127,127,.95))}
2563
+ .dpc-sep:focus-visible{outline:2px solid var(--dsw-focus-ring-color,rgba(127,127,127,.65));outline-offset:1px}
2564
+ .dpc-caret{flex:none;width:0;height:0;border-left:4px solid currentColor;border-top:3.5px solid transparent;border-bottom:3.5px solid transparent;opacity:.7;transform:rotate(90deg);transition:transform 120ms cubic-bezier(.16,1,.3,1)}
2565
+ .dpc-sep[data-dpc-collapsed] .dpc-caret{transform:none}
1963
2566
  .dpc-sepLabel{min-width:0;overflow:hidden;text-overflow:ellipsis}
2567
+ .dpc-sepCount{flex:none;margin-left:2px;font-family:var(--dsw-font-family-code,ui-monospace,monospace);font-size:10px;opacity:.72}
1964
2568
  .dpc-sep::after{content:'';flex:1;height:.5px;background:var(--dsw-alias-separator-primary,rgba(127,127,127,.16))}
2569
+ @media (prefers-reduced-motion:reduce){.dpc-caret{transition:none}}
1965
2570
  `;
1966
2571
 
1967
2572
 
1968
2573
  /* ----------------------------------------------------------------- entry */
1969
2574
 
1970
2575
  function applyInner(ctx) {
1971
- ctxRef = ctx;
2576
+ try { traceBoot('apply'); } catch { /* ignore */ }
2577
+ try { if (document.title.startsWith('DPC ')) document.title = 'DPC ok'; } catch { /* ignore */ }
2578
+ ctxRef = ctx;
1972
2579
 
1973
2580
  // A hot-swapped Client half re-runs apply() without reloading the page, so
1974
2581
  // the previous sheet is still in <head>: drop it, or two revisions of the
@@ -2029,8 +2636,39 @@ function build(require) {
2029
2636
  ctx.effect(() => () => window.removeEventListener('focus', probe), `${PACKAGE}: inventory`);
2030
2637
  }
2031
2638
  reportHost({ kind: 'boot', origin: locationMode(), agent: String(navigator.userAgent || '').slice(0, 120) });
2639
+ try {
2640
+ const boot = bootSnapshot();
2641
+ if (boot) {
2642
+ reportHost({ kind: 'boot-manifest', rev: boot.rev, entries: boot.ids.size, ids: Array.from(boot.ids).join(',') });
2643
+ }
2644
+ } catch {
2645
+ /* a dump must never break apply */
2646
+ }
2647
+ try {
2648
+ dumpPageLog('boot');
2649
+ dumpNet('boot');
2650
+ try {
2651
+ const slotsService = optionalService(ctx, 'slots');
2652
+ dumpSlots(slotsService, 'boot');
2653
+ if (slotsService && typeof slotsService.subscribe === 'function') {
2654
+ ctx.effect(
2655
+ () => slotsService.subscribe('sidebar.panellist', () => dumpSlots(slotsService, 'change')),
2656
+ `${PACKAGE}: slotwatch`,
2657
+ );
2658
+ }
2659
+ for (const delay of [8000, 20000, 45000]) {
2660
+ setTimeout(() => { dumpNet(`t${delay}`); dumpSlots(slotsService, `t${delay}`); }, delay);
2661
+ }
2662
+ } catch { /* a dump must never break apply */ }
2663
+ for (const delay of [8000, 20000, 45000]) {
2664
+ setTimeout(() => dumpPageLog(`t${delay}`), delay);
2665
+ }
2666
+ } catch {
2667
+ /* a dump must never break apply */
2668
+ }
2032
2669
 
2033
2670
 
2671
+ try { traceBoot('ready'); } catch { /* ignore */ }
2034
2672
  // A registration, a rename or a locale switch changes the list this plugin
2035
2673
  // lays out, so every one of them re-runs the pass.
2036
2674
  if (typeof ctx.slots.subscribe === 'function') {
@@ -2040,25 +2678,61 @@ function build(require) {
2040
2678
  ctx.effect(() => locale.subscribe(() => engineRef && engineRef.schedule()), `${PACKAGE}: labels`);
2041
2679
  }
2042
2680
 
2043
- ctx.slots.inject(SLOT_MAIN, () =>
2044
- ctx.slots.register({ name: SLOT_MAIN, key: PANEL_KEY, locale: NS }, PanelManager),
2045
- );
2681
+ ctx.slots.inject(SLOT_MAIN, () => {
2682
+ try {
2683
+ return ctx.slots.register({ name: SLOT_MAIN, key: PANEL_KEY, locale: NS }, PanelManager);
2684
+ } catch (error) {
2685
+ // A second instance of this module may hold the key already. Losing one entry is
2686
+ // survivable; letting the throw escape aborts the page's whole plugin start().
2687
+ console.warn(`[${PACKAGE}] duplicate "${SLOT_MAIN}" entry skipped`, error);
2688
+ return undefined;
2689
+ }
2690
+ });
2046
2691
 
2047
- ctx.slots.inject(SLOT_SETTINGS, () =>
2048
- ctx.slots.register(
2049
- { name: SLOT_SETTINGS, id: 'panel-control-settings', order: 60, locale: NS, label: () => t('panelTitle') },
2050
- SettingsEntry,
2051
- ),
2052
- );
2692
+ ctx.slots.inject(SLOT_SETTINGS, () => {
2693
+ try {
2694
+ return ctx.slots.register(
2695
+ { name: SLOT_SETTINGS, id: 'panel-control-settings', order: 60, locale: NS, label: () => t('panelTitle') },
2696
+ SettingsEntry,
2697
+ );
2698
+ } catch (error) {
2699
+ console.warn(`[${PACKAGE}] duplicate "${SLOT_SETTINGS}" entry skipped`, error);
2700
+ return undefined;
2701
+ }
2702
+ });
2053
2703
 
2054
2704
  // The entry lives in the very list it manages: it is registered last, and
2055
2705
  // the manager keeps it visible so the page stays reachable.
2056
- ctx.slots.inject(SLOT_PANELS, () =>
2057
- ctx.slots.register(
2058
- { name: SLOT_PANELS, id: PANEL_KEY, order: 90, locale: NS, label: () => t('panelTitle') },
2059
- PanelIcon,
2060
- ),
2061
- );
2706
+ ctx.slots.inject(SLOT_PANELS, () => {
2707
+ try {
2708
+ return ctx.slots.register(
2709
+ { name: SLOT_PANELS, id: PANEL_KEY, order: 90, locale: NS, label: () => t('panelTitle') },
2710
+ PanelIcon,
2711
+ );
2712
+ } catch (error) {
2713
+ console.warn(`[${PACKAGE}] duplicate "${SLOT_PANELS}" entry skipped`, error);
2714
+ return undefined;
2715
+ }
2716
+ });
2717
+ try { window.__dpcApplied = true; } catch { /* ignore */ }
2718
+ }
2719
+
2720
+ /**
2721
+ * True when this document already holds our sidebar entry, i.e. another instance of this module
2722
+ * applied first. Probing the slot table beats a window flag: a hot swap disposes the old instance
2723
+ * but keeps the window, so a flag would lock the replacement out.
2724
+ */
2725
+ function alreadyRegistered(ctx) {
2726
+ try {
2727
+ const entries = ctx.slots.entriesOfSlot(SLOT_MAIN) || [];
2728
+ return entries.some((entry) => {
2729
+ const options = entry && entry.options;
2730
+ const key = (options && options.key) || (entry && entry.key);
2731
+ return String(key || '') === PANEL_KEY;
2732
+ });
2733
+ } catch {
2734
+ return false;
2735
+ }
2062
2736
  }
2063
2737
 
2064
2738
  return {
@@ -2067,18 +2741,30 @@ function build(require) {
2067
2741
  testables: { createEngine, mapButtons, readEntries, normalize, sanitize, store, reorderList, applyInner, setCtx: (value) => { ctxRef = value; } },
2068
2742
  apply(ctx) {
2069
2743
  try {
2744
+
2745
+ // The runner can lay this module into the page twice - once as its own startup script and
2746
+ // once inside a composed batch - so a second instance applies into the same slot table.
2747
+ // DSH throws on the duplicate key and aborts the whole start(), which is what removes every
2748
+ // plugin scheduled after us. Skipping the second copy keeps the batch alive.
2749
+ if (alreadyRegistered(ctx)) {
2750
+ try { traceBoot('apply-dup-skip'); } catch { /* ignore */ }
2751
+ reportHost({ kind: 'apply-skip', reason: 'this document already holds our slots' });
2752
+ return;
2753
+ }
2070
2754
  applyInner(ctx);
2071
2755
  } catch (error) {
2072
- console.error(`[${PACKAGE}] apply failed`, error);
2756
+ console.error(`[${PACKAGE}] apply failed`, error);
2073
2757
  reportHost({ kind: 'apply-failed', message: (error && error.message) || String(error) });
2074
2758
  }
2075
2759
  },
2076
2760
  };
2077
2761
  }
2078
2762
 
2763
+ try {
2079
2764
  window.__ModuleLoader__.load({
2080
2765
  id: 'dsh-panel-control',
2081
2766
  factory(require) {
2767
+ try { traceBoot('factory'); } catch { /* ignore */ }
2082
2768
  try {
2083
2769
  return build(require);
2084
2770
  } catch (error) {
@@ -2088,4 +2774,10 @@ window.__ModuleLoader__.load({
2088
2774
  }
2089
2775
  },
2090
2776
  });
2091
-
2777
+ } catch (error) {
2778
+ // A second evaluation of the same build registers a factory that is already there; the loader
2779
+ // throws for it, and that throw is harmless (the first factory stays in place). A disable/enable
2780
+ // cycle removed the factory first, so this same call is how the plugin comes back.
2781
+ console.warn('[dsh-panel-control] the bundle was evaluated with its registration already present; keeping the first one', error);
2782
+ }
2783
+ })();