dsh-panel-control 0.4.0 → 0.7.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/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.2';
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
 
@@ -377,10 +639,66 @@ function build(require) {
377
639
  }, SAVE_DEBOUNCE_MS);
378
640
  }
379
641
 
380
- 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) {
381
651
  void hostCall('report', { ...detail, href: String(locationMode()), at: Date.now() });
382
652
  }
383
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
+
384
702
  /** `location.href` minus any query, so one line says which origin we ran on. */
385
703
  function locationMode() {
386
704
  try {
@@ -684,7 +1002,7 @@ function build(require) {
684
1002
  let pollTimer = null;
685
1003
  let lastApplyAt = 0;
686
1004
 
687
- 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: '' };
688
1006
 
689
1007
  const readPlan = () => {
690
1008
  const entries = readEntries(ctx.slots);
@@ -718,6 +1036,39 @@ function build(require) {
718
1036
  return { entries, prefs, hidden, blocks };
719
1037
  };
720
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
+
721
1072
  const findList = (entries) => {
722
1073
  if (listEl && listEl.isConnected && listEl.querySelector(':scope > button')) return listEl;
723
1074
  listEl = null;
@@ -748,18 +1099,114 @@ function build(require) {
748
1099
  return null;
749
1100
  };
750
1101
 
751
- 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) => {
752
1111
  const element = document.createElement('div');
753
1112
  element.className = 'dpc-sep';
754
1113
  element.setAttribute('data-dpc-sep', '');
1114
+ element.setAttribute('data-dpc-group-id', block.id);
755
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');
756
1134
  const label = document.createElement('span');
757
1135
  label.className = 'dpc-sepLabel';
758
- 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);
759
1141
  element.appendChild(label);
1142
+ element.appendChild(count);
760
1143
  return element;
761
1144
  };
762
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
+
763
1210
  const pause = () => {
764
1211
  if (observer && observing) {
765
1212
  observer.disconnect();
@@ -804,11 +1251,23 @@ function build(require) {
804
1251
  }
805
1252
  diag.listClass = typeof list.className === 'string' ? list.className.split(/\s+/)[0] || '' : '';
806
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
+
807
1265
  const nextSignature = JSON.stringify({
808
1266
  blocks: plan.blocks,
809
1267
  hidden: plan.hidden ? Array.from(plan.hidden).sort() : [],
810
1268
  entries: plan.entries.map((entry) => entry.id + ':' + entry.label),
811
- wide: list.getBoundingClientRect().width >= WIDE_MIN_WIDTH,
1269
+ wide,
1270
+ folded: Array.from(folded),
812
1271
  });
813
1272
  const separatorsLeft = list.querySelectorAll('[data-dpc-sep]').length;
814
1273
  if (nextSignature === signature && (separatorsLeft > 0 || plan.blocks.length <= 1)) {
@@ -816,11 +1275,11 @@ function build(require) {
816
1275
  return;
817
1276
  }
818
1277
  signature = nextSignature;
1278
+ dumpPanellist(plan, list);
819
1279
 
820
1280
  pause();
821
1281
  try {
822
1282
  const buttons = Array.from(list.querySelectorAll(':scope > button'));
823
- const wide = list.getBoundingClientRect().width >= WIDE_MIN_WIDTH;
824
1283
  for (const node of Array.from(list.querySelectorAll('[data-dpc-sep]'))) node.remove();
825
1284
 
826
1285
  const map = mapButtons(plan.entries, buttons);
@@ -836,17 +1295,25 @@ function build(require) {
836
1295
 
837
1296
  let position = ORDER_START;
838
1297
  for (const block of plan.blocks) {
839
- const visible = block.ids.filter((id) => !plan.hidden.has(id) && map.has(id));
840
- 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.
841
1304
  if (wide && block.name) {
842
- list.appendChild(makeSeparator(block.name, position));
1305
+ list.appendChild(
1306
+ makeSeparator({ id: block.id, name: block.name, count: shown.length, folded: foldedHere }, position),
1307
+ );
843
1308
  position += ORDER_STEP;
844
1309
  }
845
- for (const id of visible) {
1310
+ if (foldedHere) continue;
1311
+ for (const id of shown) {
846
1312
  const button = map.get(id);
847
1313
  button.style.order = String(position);
848
1314
  button.style.display = '';
849
1315
  button.removeAttribute('data-dpc-hidden');
1316
+ button.removeAttribute('data-dpc-collapsed');
850
1317
  button.setAttribute('data-dpc-group', block.name || '');
851
1318
  position += ORDER_STEP;
852
1319
  }
@@ -857,6 +1324,19 @@ function build(require) {
857
1324
  if (!button) continue;
858
1325
  button.style.display = 'none';
859
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
+ }
860
1340
  }
861
1341
 
862
1342
  // A row we could not map is left in place, but kept after every managed row.
@@ -940,6 +1420,14 @@ function build(require) {
940
1420
  node.style.removeProperty('display');
941
1421
  }
942
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;
943
1431
  listEl = null;
944
1432
  };
945
1433
 
@@ -1717,6 +2205,96 @@ function build(require) {
1717
2205
  );
1718
2206
  };
1719
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
+
1720
2298
  const groups =
1721
2299
  groupsOf.length === 0
1722
2300
  ? h(
@@ -1788,6 +2366,7 @@ function build(require) {
1788
2366
  head,
1789
2367
  notice,
1790
2368
  auto,
2369
+ layout,
1791
2370
  order.length === 0
1792
2371
  ? h(
1793
2372
  'section',
@@ -1929,6 +2508,23 @@ function build(require) {
1929
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)}
1930
2509
  .dpc-ghost:disabled{opacity:.4;cursor:default}
1931
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
+
1932
2528
  @media (max-width:1000px){
1933
2529
  /* Below the column width the frame becomes the gutter instead of pushing
1934
2530
  content past the window edge. */
@@ -1954,15 +2550,31 @@ function build(require) {
1954
2550
  `;
1955
2551
 
1956
2552
  const SIDEBAR_CSS = `
1957
- .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}
1958
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}
1959
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}}
1960
2570
  `;
1961
2571
 
1962
2572
 
1963
2573
  /* ----------------------------------------------------------------- entry */
1964
2574
 
1965
2575
  function applyInner(ctx) {
2576
+ try { traceBoot('apply'); } catch { /* ignore */ }
2577
+ try { if (document.title.startsWith('DPC ')) document.title = 'DPC ok'; } catch { /* ignore */ }
1966
2578
  ctxRef = ctx;
1967
2579
 
1968
2580
  // A hot-swapped Client half re-runs apply() without reloading the page, so
@@ -2024,8 +2636,39 @@ function build(require) {
2024
2636
  ctx.effect(() => () => window.removeEventListener('focus', probe), `${PACKAGE}: inventory`);
2025
2637
  }
2026
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
+ }
2027
2669
 
2028
2670
 
2671
+ try { traceBoot('ready'); } catch { /* ignore */ }
2029
2672
  // A registration, a rename or a locale switch changes the list this plugin
2030
2673
  // lays out, so every one of them re-runs the pass.
2031
2674
  if (typeof ctx.slots.subscribe === 'function') {
@@ -2035,25 +2678,61 @@ function build(require) {
2035
2678
  ctx.effect(() => locale.subscribe(() => engineRef && engineRef.schedule()), `${PACKAGE}: labels`);
2036
2679
  }
2037
2680
 
2038
- ctx.slots.inject(SLOT_MAIN, () =>
2039
- ctx.slots.register({ name: SLOT_MAIN, key: PANEL_KEY, locale: NS }, PanelManager),
2040
- );
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
+ });
2041
2691
 
2042
- ctx.slots.inject(SLOT_SETTINGS, () =>
2043
- ctx.slots.register(
2044
- { name: SLOT_SETTINGS, id: 'panel-control-settings', order: 60, locale: NS, label: () => t('panelTitle') },
2045
- SettingsEntry,
2046
- ),
2047
- );
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
+ });
2048
2703
 
2049
2704
  // The entry lives in the very list it manages: it is registered last, and
2050
2705
  // the manager keeps it visible so the page stays reachable.
2051
- ctx.slots.inject(SLOT_PANELS, () =>
2052
- ctx.slots.register(
2053
- { name: SLOT_PANELS, id: PANEL_KEY, order: 90, locale: NS, label: () => t('panelTitle') },
2054
- PanelIcon,
2055
- ),
2056
- );
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
+ }
2057
2736
  }
2058
2737
 
2059
2738
  return {
@@ -2062,6 +2741,16 @@ function build(require) {
2062
2741
  testables: { createEngine, mapButtons, readEntries, normalize, sanitize, store, reorderList, applyInner, setCtx: (value) => { ctxRef = value; } },
2063
2742
  apply(ctx) {
2064
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
+ }
2065
2754
  applyInner(ctx);
2066
2755
  } catch (error) {
2067
2756
  console.error(`[${PACKAGE}] apply failed`, error);
@@ -2071,9 +2760,11 @@ function build(require) {
2071
2760
  };
2072
2761
  }
2073
2762
 
2763
+ try {
2074
2764
  window.__ModuleLoader__.load({
2075
2765
  id: 'dsh-panel-control',
2076
2766
  factory(require) {
2767
+ try { traceBoot('factory'); } catch { /* ignore */ }
2077
2768
  try {
2078
2769
  return build(require);
2079
2770
  } catch (error) {
@@ -2083,4 +2774,10 @@ window.__ModuleLoader__.load({
2083
2774
  }
2084
2775
  },
2085
2776
  });
2086
-
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
+ })();