dsh-panel-control 0.7.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // Everything below therefore lives inside this closure. Re-evaluation must stay possible: when the
6
6
  // plugin is switched off the loader drops our factory, so switching it back on re-executes this
7
7
  // script and needs the registration that follows.
8
- const BUILD_STAMP = 'dsh-panel-control/0.7.1';
8
+ const BUILD_STAMP = 'dsh-panel-control/0.8.3';
9
9
  try { window.__dpcEvaluated = BUILD_STAMP; } catch { /* ignore */ }
10
10
  /**
11
11
  * Client half of dsh-panel-control.
@@ -265,6 +265,14 @@ function build(require) {
265
265
  const SLOT_MAIN = 'main';
266
266
  const SLOT_SETTINGS = 'settings.section';
267
267
  const PANEL_KEY = 'panel-control';
268
+ /** Bridged pages get their own ids; the prefix also keeps them out of the host's reserved keys. */
269
+ const BRIDGE_PREFIX = 'dpc-bridge-';
270
+ const BRIDGE_SLOT_PREFIX = 'dpc-bridge-slot-';
271
+ /** Between the ordinary entries and this manager's own row (order 90). */
272
+ const BRIDGE_ORDER = 85;
273
+ const BRIDGE_DEBOUNCE_MS = 120;
274
+ /** Boot-time rescans: plugins register their settings page while the page starts. */
275
+ const BRIDGE_RETRY_MS = [350, 900, 2000, 4200, 8000, 15000];
268
276
  const STORAGE_KEY = 'dsh.panel-control.v1';
269
277
  /** Host route that answers "file these entries into groups" (see index.js). */
270
278
  const RPC_CHANNEL = '/api';
@@ -377,6 +385,16 @@ function build(require) {
377
385
  layoutCollapseHint: { zh: '点分组标题收起该组,标题与数量仍保留。', en: 'Click a group title to fold it; the title and count stay.' },
378
386
  layoutMax: { zh: '高度上限', en: 'Height cap' },
379
387
  layoutMaxNone: { zh: '不限', en: 'No cap' },
388
+ layoutBridge: { zh: '补全设置页入口', en: 'Entries for settings pages' },
389
+ layoutBridgeHint: {
390
+ zh: '第三方插件只做了设置页、没做侧栏入口时,替它在侧栏补一个入口。关掉后这些入口全部移除,插件自己的设置页不受影响。',
391
+ en: 'When a third-party plugin ships a settings page but no sidebar entry, add one for it. Turning this off removes only those entries; the plugin keeps its settings page.',
392
+ },
393
+ bridgeBack: { zh: '返回对话', en: 'Back to conversation' },
394
+ bridgeTag: { zh: '设置页', en: 'Settings page' },
395
+ bridgeFrom: { zh: '来自 {p}', en: 'From {p}' },
396
+ bridgeMissing: { zh: '这个设置页已经不可用。', en: 'This settings page is no longer available.' },
397
+ diagBridge: { zh: '补全入口', en: 'bridged entries' },
380
398
  layoutCollapseAll: { zh: '全部收起', en: 'Fold all' },
381
399
  layoutExpandAll: { zh: '全部展开', en: 'Unfold all' },
382
400
  layoutCollapseOne: { zh: '点此收起', en: 'click to fold' },
@@ -468,6 +486,7 @@ function build(require) {
468
486
  collapsed: [],
469
487
  guard: true,
470
488
  collapse: true,
489
+ bridge: true,
471
490
  panelMax: 45,
472
491
  });
473
492
 
@@ -508,6 +527,9 @@ function build(require) {
508
527
  // explicit false is the only way to turn the CSS off.
509
528
  out.guard = raw.guard !== false;
510
529
  out.collapse = raw.collapse !== false;
530
+ // Missing in files written before the bridge existed: those installs get the
531
+ // feature by default, exactly like a fresh one.
532
+ out.bridge = raw.bridge !== false;
511
533
  const cap = Math.round(Number(raw.panelMax));
512
534
  out.panelMax = Number.isFinite(cap) ? Math.min(70, Math.max(0, cap)) : 45;
513
535
  if (Array.isArray(raw.groups)) {
@@ -559,7 +581,13 @@ function build(require) {
559
581
  (prefs.groups || []).length > 0 ||
560
582
  (prefs.collapsed || []).length > 0 ||
561
583
  (prefs.autoFiled || []).length > 0 ||
562
- prefs.auto === true);
584
+ prefs.auto === true ||
585
+ // A file that only turned a switch off is still a file the reader edited;
586
+ // without this, the next load treats it as empty and drops the edit.
587
+ prefs.guard === false ||
588
+ prefs.collapse === false ||
589
+ prefs.bridge === false ||
590
+ (typeof prefs.panelMax === 'number' && prefs.panelMax !== 45));
563
591
 
564
592
  /** One call to the Host half, or null when this Client cannot reach it. */
565
593
  async function hostCall(method, payload) {
@@ -900,6 +928,339 @@ function build(require) {
900
928
  left.length === right.length &&
901
929
  left.every((row, index) => row.id === right[index].id && row.label === right[index].label);
902
930
 
931
+ /* -------------------------------------------------------------- bridges */
932
+
933
+ /**
934
+ * Settings pages with no panel of their own.
935
+ *
936
+ * A Client plugin addresses its settings page with `settings.section`; the
937
+ * sidebar only lists `sidebar.panellist` entries, and the two registries are
938
+ * unrelated. A plugin that never registers a panel therefore has a reachable
939
+ * settings page and no way to open it from the navigation rail. This block
940
+ * gives each such page a panel of its own: a panellist row plus the `main`
941
+ * entry it addresses, whose body is the host's own renderer for that section.
942
+ *
943
+ * A slot entry's `registrant` names the Cordis fiber that registered it, but
944
+ * the client runner applies many plugins inside one fiber: in practice every
945
+ * plugin except the market reports the same name, so a registrant match cannot
946
+ * tell one plugin from another. Attribution is therefore by ownership of the
947
+ * navigation row: a settings id whose base name (with a trailing `-settings`
948
+ * removed) is an existing panel id, or whose label is an existing panel label,
949
+ * belongs to a plugin that already has a way in. Core settings ids are listed
950
+ * explicitly; they ship without a panel because that is the product decision,
951
+ * not a gap.
952
+ */
953
+ const CORE_SECTION_IDS = new Set(['account', 'general', 'models', 'agent-presets', 'plugins']);
954
+
955
+ const bridgeState = {
956
+ entries: new Map(),
957
+ labels: new Map(),
958
+ timer: null,
959
+ retries: new Set(),
960
+ retriesStarted: false,
961
+ syncing: false,
962
+ scans: 0,
963
+ unsub: null,
964
+ };
965
+
966
+ function registrantOf(entry) {
967
+ const options = (entry && entry.options) || {};
968
+ const value = (entry && entry.registrant) || options.registrant;
969
+ return typeof value === 'string' ? value : '';
970
+ }
971
+
972
+ function readSections(slots) {
973
+ try {
974
+ const raw = slots && typeof slots.entriesOfSlot === 'function' ? slots.entriesOfSlot(SLOT_SETTINGS) || [] : [];
975
+ return raw
976
+ .map((entry) => {
977
+ const options = (entry && entry.options) || {};
978
+ return {
979
+ id: typeof options.id === 'string' ? options.id : '',
980
+ label: options.label,
981
+ registrant: registrantOf(entry),
982
+ component: entry && entry.component,
983
+ inject: options.inject,
984
+ store: options.store,
985
+ locale: options.locale,
986
+ };
987
+ })
988
+ .filter((section) => section.id !== '');
989
+ } catch {
990
+ return [];
991
+ }
992
+ }
993
+
994
+ function panelEntries(slots) {
995
+ try {
996
+ return (slots && typeof slots.entriesOfSlot === 'function' ? slots.entriesOfSlot(SLOT_PANELS) || [] : [])
997
+ .map((entry) => entry && entry.options)
998
+ .filter((options) => options && typeof options.id === 'string' && options.id !== '')
999
+ .map((options) => ({ id: options.id, label: resolveLabel(options.label, options.id) }));
1000
+ } catch {
1001
+ return [];
1002
+ }
1003
+ }
1004
+
1005
+ function sectionBase(id) {
1006
+ return id.endsWith('-settings') ? id.slice(0, -'-settings'.length) : id;
1007
+ }
1008
+
1009
+ /**
1010
+ * Every settings page whose plugin owns no sidebar entry.
1011
+ *
1012
+ * A plugin may expose more than one settings page; each becomes its own row,
1013
+ * labelled with the plugin's own section label. A label already used by a real
1014
+ * panel is suffixed with the settings id so two rows never read the same.
1015
+ */
1016
+ function bridgeCandidates(slots) {
1017
+ const rows = panelEntries(slots).filter((row) => !row.id.startsWith(BRIDGE_PREFIX));
1018
+ const panelIds = new Set(rows.map((row) => row.id));
1019
+ const panelLabels = new Set(rows.map((row) => row.label));
1020
+ // Real panel labels own their names; a bridged row must not steal one, and
1021
+ // must not collide with another bridged row either.
1022
+ const used = new Set(panelLabels);
1023
+ const out = [];
1024
+ const taken = new Set();
1025
+ for (const section of readSections(slots)) {
1026
+ if (CORE_SECTION_IDS.has(section.id)) continue;
1027
+ const base = sectionBase(section.id);
1028
+ let owned = panelIds.has(section.id) || panelIds.has(base);
1029
+ if (!owned) {
1030
+ for (const panelId of panelIds) {
1031
+ if (panelId.endsWith('-' + base)) {
1032
+ owned = true;
1033
+ break;
1034
+ }
1035
+ }
1036
+ }
1037
+ if (owned) continue;
1038
+ const label = resolveLabel(section.label, section.id);
1039
+ if (panelLabels.has(label)) continue;
1040
+ if (typeof section.component !== 'function') continue;
1041
+ const bridgeId = BRIDGE_PREFIX + section.id;
1042
+ if (bridgeId.length > LIMITS.key || taken.has(bridgeId)) continue;
1043
+ taken.add(bridgeId);
1044
+ const chosen = used.has(label) ? `${label} · ${section.id}` : label;
1045
+ used.add(chosen);
1046
+ out.push({
1047
+ bridgeId,
1048
+ sectionId: section.id,
1049
+ label: chosen,
1050
+ component: section.component,
1051
+ inject: section.inject,
1052
+ store: section.store,
1053
+ locale: section.locale,
1054
+ });
1055
+ }
1056
+ return out;
1057
+ }
1058
+
1059
+ /** The layout's live selection, read through the public store when it exposes one. */
1060
+ function activePanelId() {
1061
+ try {
1062
+ const layout = optionalService(ctxRef, 'layout');
1063
+ const info = layout && layout.panelInfo;
1064
+ const snapshot = info && typeof info.getSnapshot === 'function' ? info.getSnapshot() : info;
1065
+ return snapshot && typeof snapshot.activePanelId === 'string' ? snapshot.activePanelId : null;
1066
+ } catch {
1067
+ return null;
1068
+ }
1069
+ }
1070
+
1071
+ function disposeBridge(id, record) {
1072
+ bridgeState.entries.delete(id);
1073
+ for (const dispose of record.disposers.slice().reverse()) {
1074
+ try {
1075
+ dispose();
1076
+ } catch (error) {
1077
+ console.warn(`[${PACKAGE}] bridge cleanup failed for "${id}"`, error);
1078
+ }
1079
+ }
1080
+ // The main key this row addressed is gone, so a reader sitting on it would
1081
+ // stare at an empty centre column. The Conversation is the only safe landing.
1082
+ if (activePanelId() === id) {
1083
+ try {
1084
+ const layout = optionalService(ctxRef, 'layout');
1085
+ if (layout && typeof layout.selectPanel === 'function') layout.selectPanel(null);
1086
+ } catch {
1087
+ /* nothing was selected after all */
1088
+ }
1089
+ }
1090
+ }
1091
+
1092
+ function syncBridges() {
1093
+ const slots = ctxRef && ctxRef.slots;
1094
+ if (!slots || typeof slots.register !== 'function' || bridgeState.syncing) return;
1095
+ bridgeState.syncing = true;
1096
+ bridgeState.scans += 1;
1097
+ let changed = false;
1098
+ try {
1099
+ const enabled = prefsNow().bridge !== false;
1100
+ watchSettings();
1101
+ const sections = readSections(slots);
1102
+ const panelIds = panelEntries(slots).map((row) => row.id);
1103
+ const candidates = enabled ? bridgeCandidates(slots) : [];
1104
+ const desired = new Set(candidates.map((candidate) => candidate.bridgeId));
1105
+ bridgeState.labels = new Map(candidates.map((candidate) => [candidate.bridgeId, candidate.label]));
1106
+ for (const [id, record] of Array.from(bridgeState.entries)) {
1107
+ if (desired.has(id)) continue;
1108
+ disposeBridge(id, record);
1109
+ changed = true;
1110
+ }
1111
+ for (const candidate of candidates) {
1112
+ if (bridgeState.entries.has(candidate.bridgeId)) continue;
1113
+ const slotName = BRIDGE_SLOT_PREFIX + candidate.sectionId;
1114
+ const record = { disposers: [] };
1115
+ try {
1116
+ record.disposers.push(
1117
+ slots.register(
1118
+ {
1119
+ name: SLOT_MAIN,
1120
+ key: candidate.bridgeId,
1121
+ locale: NS,
1122
+ children: { [slotName]: { kind: 'single', scope: 'root' } },
1123
+ },
1124
+ makeBridgePanel(candidate, slotName),
1125
+ ),
1126
+ );
1127
+ // The settings shell owns the `settings.section` declaration, so its
1128
+ // props-face renderer is not ours to call. Declaring one child slot of
1129
+ // our own and re-registering the same component into it gives the
1130
+ // section the host kit it expects (inject, locale, store, boundary)
1131
+ // without touching the shell or the foreign registration.
1132
+ record.disposers.push(
1133
+ slots.register(
1134
+ {
1135
+ name: slotName,
1136
+ inject: candidate.inject,
1137
+ store: candidate.store,
1138
+ locale: candidate.locale,
1139
+ },
1140
+ candidate.component,
1141
+ ),
1142
+ );
1143
+ record.disposers.push(
1144
+ slots.register(
1145
+ {
1146
+ name: SLOT_PANELS,
1147
+ id: candidate.bridgeId,
1148
+ order: BRIDGE_ORDER,
1149
+ locale: NS,
1150
+ label: () => bridgeState.labels.get(candidate.bridgeId) || candidate.label,
1151
+ },
1152
+ BridgeIcon,
1153
+ ),
1154
+ );
1155
+ bridgeState.entries.set(candidate.bridgeId, record);
1156
+ changed = true;
1157
+ } catch (error) {
1158
+ for (const dispose of record.disposers.slice().reverse()) {
1159
+ try {
1160
+ dispose();
1161
+ } catch {
1162
+ /* the pair is already half gone */
1163
+ }
1164
+ }
1165
+ console.warn(`[${PACKAGE}] could not add a panel for "${candidate.sectionId}"`, error);
1166
+ }
1167
+ }
1168
+ // Early scans run while plugins are still registering. Keeping a trace of
1169
+ // exactly what the slot returned tells a later reader whether a missing
1170
+ // row is a timing problem or an ownership guess that did not hold.
1171
+ if (bridgeState.scans <= 8 || changed) {
1172
+ reportHost({
1173
+ kind: 'bridge-scan',
1174
+ enabled,
1175
+ scans: bridgeState.scans,
1176
+ sections: sections.map((section) => `${section.id}:${section.registrant || '?'}`).join('|'),
1177
+ panels: panelIds.join('|'),
1178
+ ids: Array.from(bridgeState.entries.keys()).join(','),
1179
+ });
1180
+ }
1181
+ if (changed) {
1182
+ reportHost({
1183
+ kind: 'bridge',
1184
+ enabled,
1185
+ ids: Array.from(bridgeState.entries.keys()).join(','),
1186
+ sections: candidates.length,
1187
+ });
1188
+ // The diagnostics line in the settings card reads this count; without a
1189
+ // re-render it would keep showing the value from before the sync.
1190
+ emit();
1191
+ }
1192
+ } catch (error) {
1193
+ console.warn(`[${PACKAGE}] bridge sync failed`, error);
1194
+ } finally {
1195
+ bridgeState.syncing = false;
1196
+ }
1197
+ }
1198
+
1199
+ function scheduleBridgeSync() {
1200
+ if (bridgeState.timer) clearTimeout(bridgeState.timer);
1201
+ bridgeState.timer = setTimeout(() => {
1202
+ bridgeState.timer = null;
1203
+ syncBridges();
1204
+ }, BRIDGE_DEBOUNCE_MS);
1205
+ }
1206
+
1207
+ /**
1208
+ * Watch the section registry.
1209
+ *
1210
+ * `subscribe` is trustworthy only once the slot is declared. The plugin runs
1211
+ * before some of the plugins it bridges, so an early subscription can resolve
1212
+ * to nothing and never be retried; the declaration callback calls this again.
1213
+ */
1214
+ function watchSettings() {
1215
+ if (bridgeState.unsub) return;
1216
+ const slots = ctxRef && ctxRef.slots;
1217
+ if (!slots || typeof slots.subscribe !== 'function') return;
1218
+ try {
1219
+ const dispose = slots.subscribe(SLOT_SETTINGS, () => scheduleBridgeSync());
1220
+ bridgeState.unsub = typeof dispose === 'function' ? dispose : () => {};
1221
+ } catch {
1222
+ /* not declared yet; the next scan or declaration retries */
1223
+ }
1224
+ }
1225
+
1226
+ function unwatchSettings() {
1227
+ if (!bridgeState.unsub) return;
1228
+ try {
1229
+ bridgeState.unsub();
1230
+ } catch {
1231
+ /* the slot is already gone */
1232
+ }
1233
+ bridgeState.unsub = null;
1234
+ }
1235
+
1236
+ function scheduleBridgeRetry(delay) {
1237
+ const id = setTimeout(() => {
1238
+ bridgeState.retries.delete(id);
1239
+ if (ctxRef) syncBridges();
1240
+ }, delay);
1241
+ // Browsers return a number; Node returns an object. Letting the harness
1242
+ // exit without waiting out the last boot scan keeps `npm run check` quick.
1243
+ if (id && typeof id.unref === 'function') id.unref();
1244
+ bridgeState.retries.add(id);
1245
+ }
1246
+
1247
+ function scheduleBridgeRetries() {
1248
+ if (bridgeState.retriesStarted) return;
1249
+ bridgeState.retriesStarted = true;
1250
+ for (const delay of BRIDGE_RETRY_MS) scheduleBridgeRetry(delay);
1251
+ }
1252
+
1253
+ function disposeBridges() {
1254
+ if (bridgeState.timer) {
1255
+ clearTimeout(bridgeState.timer);
1256
+ bridgeState.timer = null;
1257
+ }
1258
+ for (const id of Array.from(bridgeState.retries)) clearTimeout(id);
1259
+ bridgeState.retries.clear();
1260
+ unwatchSettings();
1261
+ for (const [id, record] of Array.from(bridgeState.entries)) disposeBridge(id, record);
1262
+ }
1263
+
903
1264
  /** The stored order, extended with every entry that is new since it was saved. */
904
1265
  function normalize(prefs, entries) {
905
1266
  const known = new Set(entries.map((entry) => entry.id));
@@ -1447,6 +1808,9 @@ function build(require) {
1447
1808
  scheduleSaveToHost();
1448
1809
  emit();
1449
1810
  if (engineRef) engineRef.schedule();
1811
+ // The bridge switch lives here too; toggling it must add or drop the rows
1812
+ // right away rather than on the next unrelated registration.
1813
+ scheduleBridgeSync();
1450
1814
  }
1451
1815
 
1452
1816
  function setDiag(patch) {
@@ -1477,6 +1841,8 @@ function build(require) {
1477
1841
  if (!stored || !hasContent(stored)) await hostCall('save', { prefs: chosen });
1478
1842
  }
1479
1843
  setDiag({ prefsSource: prefsSource.value, hostFile });
1844
+ // The Host copy may carry the bridge switch with it.
1845
+ scheduleBridgeSync();
1480
1846
  }
1481
1847
 
1482
1848
  const prefsNow = () => store.state.prefs;
@@ -1747,6 +2113,7 @@ function build(require) {
1747
2113
  plus: 'M12 6v12M6 12h12',
1748
2114
  eye: 'M2.5 12S6 6.5 12 6.5 21.5 12 21.5 12 18 17.5 12 17.5 2.5 12 2.5 12Z',
1749
2115
  eyeOff: 'M2.5 12S6 6.5 12 6.5c1.6 0 3 .45 4.2 1.1M21.5 12S18 17.5 12 17.5c-1.6 0-3-.45-4.2-1.1M4 4l16 16',
2116
+ back: 'M14.5 6.5 9 12l5.5 5.5',
1750
2117
  };
1751
2118
 
1752
2119
  /** One 16px line glyph, one 1.5 stroke, colours inherited from the text role. */
@@ -1807,6 +2174,105 @@ function build(require) {
1807
2174
 
1808
2175
  const PanelIcon = SlidersIcon;
1809
2176
 
2177
+ /**
2178
+ * A page glyph with text lines: the settings page, not the plugin's own icon
2179
+ * (that one belongs to the plugin and is not available to the bridge).
2180
+ */
2181
+ function BridgeIcon(props) {
2182
+ const size = props && typeof props.size === 'number' ? props.size : 18;
2183
+ const active = Boolean(props && props.active);
2184
+ return h(
2185
+ 'svg',
2186
+ {
2187
+ width: size,
2188
+ height: size,
2189
+ viewBox: '0 0 24 24',
2190
+ fill: 'none',
2191
+ 'aria-hidden': true,
2192
+ style: { display: 'block', opacity: active ? 1 : 0.78 },
2193
+ },
2194
+ h('rect', {
2195
+ x: 3.5,
2196
+ y: 4.5,
2197
+ width: 17,
2198
+ height: 15,
2199
+ rx: 2.5,
2200
+ stroke: 'currentColor',
2201
+ strokeWidth: 1.6,
2202
+ }),
2203
+ h('path', {
2204
+ d: 'M8 9.5h8M8 12h5M8 14.5h8',
2205
+ stroke: 'currentColor',
2206
+ strokeWidth: 1.5,
2207
+ strokeLinecap: 'round',
2208
+ }),
2209
+ );
2210
+ }
2211
+
2212
+ /**
2213
+ * One bridged page: a back bar, then the host's renderer for the section.
2214
+ *
2215
+ * The settings shell owns the `settings.section` declaration, so the ctx-level
2216
+ * `slots.renderSlot` cannot reach it: that entry point only renders `root`, and
2217
+ * a child slot is renderable only through the props face of the entry that
2218
+ * declared it. Each bridge therefore declares its own single-child slot and
2219
+ * re-registers the section component into it (see `syncBridges`), and renders
2220
+ * that slot through the `renderSlot` prop the host hands this component. The
2221
+ * host still builds the section's kit — inject, locale, store, boundary and
2222
+ * hooks all stay its own. The owner prop is the same `close` the settings shell
2223
+ * passes, wired to the Conversation because this page has no dialog to close.
2224
+ */
2225
+ function makeBridgePanel(candidate, slotName) {
2226
+ const back = () => {
2227
+ try {
2228
+ const layout = optionalService(ctxRef, 'layout');
2229
+ if (layout && typeof layout.selectPanel === 'function') layout.selectPanel(null);
2230
+ } catch {
2231
+ /* the Conversation is already showing */
2232
+ }
2233
+ };
2234
+ return function BridgePanel(props) {
2235
+ const renderSlot = props && typeof props.renderSlot === 'function' ? props.renderSlot : null;
2236
+ let node = null;
2237
+ try {
2238
+ if (renderSlot) node = renderSlot(slotName, { close: back }, {});
2239
+ } catch (error) {
2240
+ console.warn(`[${PACKAGE}] could not render settings page "${candidate.sectionId}"`, error);
2241
+ }
2242
+ return h(
2243
+ 'div',
2244
+ { className: 'dpc-root dpc-bridge' },
2245
+ h(
2246
+ 'div',
2247
+ { className: 'dpc-page dpc-bridgePage' },
2248
+ h(
2249
+ 'div',
2250
+ { className: 'dpc-bridgeBar' },
2251
+ h(
2252
+ 'button',
2253
+ { type: 'button', className: 'dpc-link dpc-bridgeBack', onClick: back },
2254
+ h(Icon, { name: 'back' }),
2255
+ h('span', null, t('bridgeBack')),
2256
+ ),
2257
+ h(
2258
+ 'span',
2259
+ { className: 'dpc-bridgeTags' },
2260
+ h('span', { className: 'dpc-bridgeTag' }, t('bridgeTag')),
2261
+ h('span', { className: 'dpc-bridgeFrom' }, fill(t('bridgeFrom'), { p: candidate.sectionId })),
2262
+ ),
2263
+ ),
2264
+ node
2265
+ ? h('div', { className: 'dpc-bridgeBody' }, node)
2266
+ : h(
2267
+ 'section',
2268
+ { className: 'dpc-card dpc-empty' },
2269
+ h('p', { className: 'dpc-emptyTitle' }, t('bridgeMissing')),
2270
+ ),
2271
+ ),
2272
+ );
2273
+ };
2274
+ }
2275
+
1810
2276
  /** The entry's own glyph, borrowed from the slot it registered. */
1811
2277
  function Glyph(props) {
1812
2278
  const [node, setNode] = React.useState(null);
@@ -2209,6 +2675,7 @@ function build(require) {
2209
2675
  // is the only place the guard, the cap and the fold switch are explained.
2210
2676
  const layoutOn = prefs.guard !== false;
2211
2677
  const foldingOn = prefs.collapse !== false;
2678
+ const bridgeOn = prefs.bridge !== false;
2212
2679
  const capNow = typeof prefs.panelMax === 'number' ? prefs.panelMax : 45;
2213
2680
  const setLayout = (patch) => commit({ ...prefsNow(), ...patch });
2214
2681
  const layoutSwitch = (on, label, onClick) =>
@@ -2251,6 +2718,14 @@ function build(require) {
2251
2718
  t('layoutCollapseHint'),
2252
2719
  layoutSwitch(foldingOn, t('layoutCollapse'), () => setLayout({ collapse: !foldingOn })),
2253
2720
  ),
2721
+ // The switch is the only way back for a reader who does not want the
2722
+ // extra rows: turning it off also disposes the registrations, not just
2723
+ // the display, so the host list and this page agree.
2724
+ layoutRow(
2725
+ t('layoutBridge'),
2726
+ t('layoutBridgeHint'),
2727
+ layoutSwitch(bridgeOn, t('layoutBridge'), () => setLayout({ bridge: !bridgeOn })),
2728
+ ),
2254
2729
  layoutRow(
2255
2730
  t('layoutMax'),
2256
2731
  null,
@@ -2350,6 +2825,7 @@ function build(require) {
2350
2825
  state.inventory && state.inventory.repeated ? t('diagYes') : t('diagNo'),
2351
2826
  true,
2352
2827
  ),
2828
+ diagRow(t('diagBridge'), String(bridgeState.entries.size), true),
2353
2829
  diagnostics && diagnostics.error ? h('dt', { key: 'et' }, 'error') : null,
2354
2830
  diagnostics && diagnostics.error
2355
2831
  ? h('dd', { key: 'ed', className: 'dpc-error' }, diagnostics.error)
@@ -2525,6 +3001,16 @@ function build(require) {
2525
3001
  .dpc-segmentBtn--on{background:var(--dsw-alias-settings-card-fill,rgba(127,127,127,.22));color:var(--dsw-alias-label-primary,#8a8a8a)}
2526
3002
  .dpc-segmentBtn:disabled{opacity:.4;cursor:default}
2527
3003
 
3004
+ /* A bridged settings page: its own bar, then the host-rendered section. */
3005
+ .dpc-bridgePage{gap:16px}
3006
+ .dpc-bridgeBar{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:40px;padding-bottom:8px;border-bottom:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.14))}
3007
+ .dpc-bridgeBack{display:inline-flex;align-items:center;gap:6px;margin-left:-8px}
3008
+ .dpc-bridgeBack .dpc-ico{width:14px;height:14px}
3009
+ .dpc-bridgeTags{display:flex;align-items:center;gap:8px;min-width:0}
3010
+ .dpc-bridgeTag{flex:none;padding:2px 8px;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.24));border-radius:999px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.95))}
3011
+ .dpc-bridgeFrom{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-family-code,ui-monospace,monospace);font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.8))}
3012
+ .dpc-bridgeBody{min-width:0}
3013
+
2528
3014
  @media (max-width:1000px){
2529
3015
  /* Below the column width the frame becomes the gutter instead of pushing
2530
3016
  content past the window edge. */
@@ -2534,6 +3020,7 @@ function build(require) {
2534
3020
  .dpc-row{flex-wrap:wrap;gap:8px}
2535
3021
  .dpc-rowText{flex:1 1 120px}
2536
3022
  .dpc-tags{order:8}
3023
+ .dpc-bridgeFrom{display:none}
2537
3024
  }
2538
3025
  /* The settings column is narrower than the page column: drop what is only
2539
3026
  nice-to-have there instead of letting the row overflow. */
@@ -2575,7 +3062,7 @@ function build(require) {
2575
3062
  function applyInner(ctx) {
2576
3063
  try { traceBoot('apply'); } catch { /* ignore */ }
2577
3064
  try { if (document.title.startsWith('DPC ')) document.title = 'DPC ok'; } catch { /* ignore */ }
2578
- ctxRef = ctx;
3065
+ ctxRef = ctx;
2579
3066
 
2580
3067
  // A hot-swapped Client half re-runs apply() without reloading the page, so
2581
3068
  // the previous sheet is still in <head>: drop it, or two revisions of the
@@ -2668,14 +3155,28 @@ function build(require) {
2668
3155
  }
2669
3156
 
2670
3157
 
2671
- try { traceBoot('ready'); } catch { /* ignore */ }
3158
+ try { traceBoot('ready'); } catch { /* ignore */ }
2672
3159
  // A registration, a rename or a locale switch changes the list this plugin
2673
3160
  // lays out, so every one of them re-runs the pass.
2674
3161
  if (typeof ctx.slots.subscribe === 'function') {
2675
- ctx.effect(() => ctx.slots.subscribe(SLOT_PANELS, () => engineRef && engineRef.schedule()), `${PACKAGE}: panel entries`);
3162
+ ctx.effect(
3163
+ () =>
3164
+ ctx.slots.subscribe(SLOT_PANELS, () => {
3165
+ if (engineRef) engineRef.schedule();
3166
+ scheduleBridgeSync();
3167
+ }),
3168
+ `${PACKAGE}: panel entries`,
3169
+ );
2676
3170
  }
2677
3171
  if (locale && typeof locale.subscribe === 'function') {
2678
- ctx.effect(() => locale.subscribe(() => engineRef && engineRef.schedule()), `${PACKAGE}: labels`);
3172
+ ctx.effect(
3173
+ () =>
3174
+ locale.subscribe(() => {
3175
+ if (engineRef) engineRef.schedule();
3176
+ scheduleBridgeSync();
3177
+ }),
3178
+ `${PACKAGE}: labels`,
3179
+ );
2679
3180
  }
2680
3181
 
2681
3182
  ctx.slots.inject(SLOT_MAIN, () => {
@@ -2714,6 +3215,35 @@ function build(require) {
2714
3215
  return undefined;
2715
3216
  }
2716
3217
  });
3218
+
3219
+ // A bridged page follows three registries: the settings sections it
3220
+ // mirrors, the panels it must not duplicate, and the locale that names
3221
+ // both. Each declaration is its own readiness signal, so the sync runs
3222
+ // once the slots exist and again on every later change.
3223
+ const kickBridge = () => {
3224
+ try {
3225
+ watchSettings();
3226
+ scheduleBridgeSync();
3227
+ scheduleBridgeRetries();
3228
+ } catch (error) {
3229
+ console.warn(`[${PACKAGE}] bridge scheduling failed`, error);
3230
+ }
3231
+ };
3232
+ try {
3233
+ // Re-install the watch on every declaration: before the slot exists
3234
+ // `subscribe` cannot observe the section registry, and a stale
3235
+ // subscription would miss the pages that register after us.
3236
+ ctx.slots.inject(SLOT_SETTINGS, () => {
3237
+ unwatchSettings();
3238
+ watchSettings();
3239
+ kickBridge();
3240
+ });
3241
+ ctx.slots.inject(SLOT_PANELS, kickBridge);
3242
+ } catch (error) {
3243
+ console.warn(`[${PACKAGE}] bridge slots unavailable`, error);
3244
+ }
3245
+ ctx.effect(() => () => disposeBridges(), `${PACKAGE}: bridge cleanup`);
3246
+ kickBridge();
2717
3247
  try { window.__dpcApplied = true; } catch { /* ignore */ }
2718
3248
  }
2719
3249
 
@@ -2736,7 +3266,9 @@ function build(require) {
2736
3266
  }
2737
3267
 
2738
3268
  return {
2739
- inject: ['slots'],
3269
+ // `layout` is only read (never patched): the bridge's back button and its
3270
+ // selection fallback call the public selectPanel action.
3271
+ inject: ['slots', 'layout'],
2740
3272
  // Pure helpers the browser harness drives directly, without a Cordis context.
2741
3273
  testables: { createEngine, mapButtons, readEntries, normalize, sanitize, store, reorderList, applyInner, setCtx: (value) => { ctxRef = value; } },
2742
3274
  apply(ctx) {
@@ -2753,7 +3285,7 @@ function build(require) {
2753
3285
  }
2754
3286
  applyInner(ctx);
2755
3287
  } catch (error) {
2756
- console.error(`[${PACKAGE}] apply failed`, error);
3288
+ console.error(`[${PACKAGE}] apply failed`, error);
2757
3289
  reportHost({ kind: 'apply-failed', message: (error && error.message) || String(error) });
2758
3290
  }
2759
3291
  },