dsh-quick-toc 0.8.2 → 0.9.0

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/lib/client.js CHANGED
@@ -60,6 +60,10 @@ window.__ModuleLoader__.load({
60
60
  // panel's collapse takes 0.28s, and the host can re-measure the conversation area at
61
61
  // any point while it runs, so the freeze outlives the motion by a frame or two.
62
62
  var COLLAPSE_HOLD_MS = 420;
63
+ // how long the long-list virtualization stays held back after a surface
64
+ // (docked panel / curtain) starts animating: the open slide runs 0.6s and the
65
+ // curtain's drop 0.46s, so 780ms covers both with margin
66
+ var VIRT_HOLD_MS = 780;
63
67
  // The row-end control ("jump to the end of this section") lives in the last 24px
64
68
  // of a row (a 20px button inset 4px). Every box a row can paint — the hover
65
69
  // highlight, a group header's hover label, the right-aligned context text — stops
@@ -101,23 +105,22 @@ window.__ModuleLoader__.load({
101
105
  "a11y.results": "共 {n} 条结果",
102
106
  "a11y.loading": "正在检索",
103
107
  "a11y.crossEmpty": "没有结果",
104
- "sheet.open": "全屏大纲(从顶部展开一层幕布)",
105
108
  "sheet.openHandle": "展开幕布:全宽大纲从顶部落下",
106
109
  "sheet.close": "收起幕布",
107
110
  "sheet.noDrag": "幕布铺满对话区,不能拖动尺寸或位置",
108
- "levels.tip": "标题层级筛选",
111
+ "settings.open": "打开插件设置",
112
+ "settings.missing": "当前部署没有给本插件提供设置页面。",
109
113
  "levels.show": "显示 H",
110
114
  "levels.hide": "隐藏 H",
111
- "levels.offInQuestions": "只看提问时没有标题行,层级筛选不生效",
112
- "view.questions.tipOn": "当前:只看提问(每轮只显示时间与你的提问)。点击回到完整大纲",
113
- "view.questions.tipOff": "当前:完整大纲。点击只看提问(每轮只显示时间与你的提问)",
114
115
  "search.open": "搜索标题",
115
116
  "search.word": "搜索",
116
117
  "search.tail": ",回车定位…",
117
118
  "search.scope.title": "标题",
119
+ "search.scope.prompts": "提问",
118
120
  "search.scope.full": "全文",
119
121
  "search.scope.cross": "会话",
120
- "search.scope.tipTitle": "当前:仅搜索标题。点击切换为全文搜索",
122
+ "search.scope.tipTitle": "当前:仅搜索标题。点击切换为只看提问",
123
+ "search.scope.tipPrompts": "当前:只看提问(搜索只覆盖你的提问,搜索框留空即为提问列表)。点击切换为全文搜索",
121
124
  "search.scope.tipFull": "当前:全文搜索。点击切换为跨会话检索",
122
125
  "search.scope.tipCross": "当前:跨会话检索(用宿主的全文索引搜所有会话的消息正文)。点击回到仅搜索标题",
123
126
  "search.cross.loading": "检索中…",
@@ -135,6 +138,8 @@ window.__ModuleLoader__.load({
135
138
  "search.fuzzy.tipOn": "模糊匹配已开启:允许关键字中间夹少量其他文字,命中更多",
136
139
  "search.fuzzy.tipOff": "模糊匹配已关闭:只匹配连续的文字。点击开启",
137
140
  "search.empty": "没有匹配",
141
+ "search.history": "最近搜索",
142
+ "search.history.clear": "清空",
138
143
  "search.hintMore": "向上滚动可加载更早的消息",
139
144
  "search.hintOldest": "已经是最早的消息",
140
145
  "hint.autoOff": "上面还有更早的历史(自动加载已关闭,点「未加载」的条目才会加载)",
@@ -171,6 +176,9 @@ window.__ModuleLoader__.load({
171
176
  "settings.dock.left": "左侧",
172
177
  "settings.dock.right": "右侧",
173
178
  "settings.levels": "显示的标题层级",
179
+ "settings.density": "大纲密度",
180
+ "settings.density.comfy": "标准",
181
+ "settings.density.compact": "紧凑",
174
182
  "settings.zoom": "面板缩放",
175
183
  "settings.zoom.tip": "文字、图标与按钮按比例缩放,面板本身的尺寸不变",
176
184
  "settings.sheetZoom": "幕布缩放",
@@ -202,23 +210,22 @@ window.__ModuleLoader__.load({
202
210
  "a11y.results": "{n} result(s)",
203
211
  "a11y.loading": "Searching",
204
212
  "a11y.crossEmpty": "No results",
205
- "sheet.open": "Full-width outline (a sheet that drops from the top)",
206
213
  "sheet.openHandle": "Open the curtain: a full-width outline drops from the top",
207
214
  "sheet.close": "Close the sheet",
208
215
  "sheet.noDrag": "The sheet spans the conversation area, so it cannot be dragged or resized",
209
- "levels.tip": "Heading level filter",
216
+ "settings.open": "Open the plugin settings",
217
+ "settings.missing": "This deployment exposes no settings screen for the plugin.",
210
218
  "levels.show": "Show H",
211
219
  "levels.hide": "Hide H",
212
- "levels.offInQuestions": "Questions-only view has no heading rows, so the level filter does not apply",
213
- "view.questions.tipOn": "Questions only: each turn shows just its time and your prompt. Click for the full outline",
214
- "view.questions.tipOff": "Full outline. Click to show each turn's time and your prompt only",
215
220
  "search.open": "Search headings",
216
221
  "search.word": "Search",
217
222
  "search.tail": ", Enter to jump…",
218
223
  "search.scope.title": "Title",
224
+ "search.scope.prompts": "Prompts",
219
225
  "search.scope.full": "Full text",
220
226
  "search.scope.cross": "Session",
221
- "search.scope.tipTitle": "Titles only. Click for full-text search",
227
+ "search.scope.tipTitle": "Titles only. Click for prompts only",
228
+ "search.scope.tipPrompts": "Prompts only: the search covers your own questions, and an empty box lists them. Click for full-text search",
222
229
  "search.scope.tipFull": "Full text. Click for cross-session search",
223
230
  "search.scope.tipCross": "Cross-session search (the host's full-text index over every session's message text). Click to go back to titles only",
224
231
  "search.cross.loading": "Searching…",
@@ -236,6 +243,8 @@ window.__ModuleLoader__.load({
236
243
  "search.fuzzy.tipOn": "Fuzzy matching is on: a few other characters may sit between the keywords, so more matches hit",
237
244
  "search.fuzzy.tipOff": "Fuzzy matching is off: only contiguous text matches. Click to turn it on",
238
245
  "search.empty": "No matches",
246
+ "search.history": "Recent searches",
247
+ "search.history.clear": "Clear",
239
248
  "search.hintMore": "Scroll up to load earlier messages",
240
249
  "search.hintOldest": "This is the earliest message",
241
250
  "hint.autoOff": "There is older history above (auto-loading is off — click an unloaded row to load it)",
@@ -274,6 +283,9 @@ window.__ModuleLoader__.load({
274
283
  "settings.dock.left": "Left",
275
284
  "settings.dock.right": "Right",
276
285
  "settings.levels": "Heading levels shown",
286
+ "settings.density": "Outline density",
287
+ "settings.density.comfy": "Comfy",
288
+ "settings.density.compact": "Compact",
277
289
  "settings.zoom": "Panel scale",
278
290
  "settings.zoom.tip": "Scales the text, icons and buttons; the panel's own size is unchanged",
279
291
  "settings.sheetZoom": "Curtain scale",
@@ -358,12 +370,12 @@ window.__ModuleLoader__.load({
358
370
  // purpose — they describe THIS screen, not the account.
359
371
  var NAMESPACE = "dsh-quick-toc";
360
372
  // Fields backed by the Host settings document; everything else is localStorage.
361
- var HOST_FIELDS = { dock: 1, lang: 1, levels: 1, zoom: 1, sheetZoom: 1, handle: 1, fuzzy: 1, hover: 1, debug: 1, remember: 1, autoLoad: 1 };
373
+ var HOST_FIELDS = { dock: 1, lang: 1, levels: 1, density: 1, zoom: 1, sheetZoom: 1, handle: 1, fuzzy: 1, hover: 1, debug: 1, remember: 1, autoLoad: 1 };
362
374
  // The schema defaults (mirrored here for the card's per-field reset and for the
363
375
  // one-time import of a 0.5.x install's stored values).
364
376
  var DEFAULTS = {
365
377
  dock: "left", y: 0, w: 0, h: 0,
366
- levels: [1, 2, 3, 4, 5, 6],
378
+ levels: [1, 2, 3, 4, 5, 6], density: "comfy",
367
379
  zoom: 1, sheetZoom: 1, handle: 0.5,
368
380
  fuzzy: false, hover: true, lang: "auto", debug: false,
369
381
  remember: true, autoLoad: true
@@ -374,6 +386,7 @@ window.__ModuleLoader__.load({
374
386
  w: "dsh-quick-toc.panelW.v1",
375
387
  h: "dsh-quick-toc.panelH.v1",
376
388
  levels: "dsh-quick-toc.levels.v1",
389
+ density: "dsh-quick-toc.density.v1",
377
390
  zoom: "dsh-quick-toc.zoom.v1",
378
391
  sheetZoom: "dsh-quick-toc.sheetZoom.v1",
379
392
  handle: "dsh-quick-toc.handle.v1",
@@ -448,6 +461,47 @@ window.__ModuleLoader__.load({
448
461
  localStorage.setItem(READ_POS_KEY, JSON.stringify(map));
449
462
  } catch (e) {}
450
463
  };
464
+ // ---- recent search history (browser-local, like the reading position) --
465
+ // Most-recent-first [{q, scope}]: filled when a search is committed (Enter in
466
+ // the box, a result click), offered back as one-tap chips while the box is
467
+ // empty. Plain data in, plain data out — the component owns the state.
468
+ var SEARCH_HIST_KEY = "dsh-quick-toc.searchHist.v1";
469
+ var SEARCH_HIST_MAX = 10;
470
+ var searchHistLoad = function () {
471
+ try {
472
+ var raw = localStorage.getItem(SEARCH_HIST_KEY);
473
+ var parsed = raw ? JSON.parse(raw) : null;
474
+ if (!Array.isArray(parsed)) return [];
475
+ var out = [];
476
+ for (var i = 0; i < parsed.length && out.length < SEARCH_HIST_MAX; i++) {
477
+ var e = parsed[i];
478
+ if (e && typeof e.q === "string" && e.q.trim() !== "") {
479
+ out.push({
480
+ q: e.q.slice(0, 80),
481
+ scope: e.scope === "full" || e.scope === "cross" ? e.scope : "title"
482
+ });
483
+ }
484
+ }
485
+ return out;
486
+ } catch (e) { return []; }
487
+ };
488
+ var searchHistRemember = function (list, q, scope) {
489
+ var text = typeof q === "string" ? q.trim().slice(0, 80) : "";
490
+ if (text === "") return Array.isArray(list) ? list : [];
491
+ var s = scope === "full" || scope === "cross" ? scope : "title";
492
+ var out = [{ q: text, scope: s }];
493
+ if (Array.isArray(list)) {
494
+ for (var i = 0; i < list.length && out.length < SEARCH_HIST_MAX; i++) {
495
+ if (list[i] && list[i].q !== text) out.push(list[i]);
496
+ }
497
+ }
498
+ try { localStorage.setItem(SEARCH_HIST_KEY, JSON.stringify(out)); } catch (e) {}
499
+ return out;
500
+ };
501
+ var searchHistClear = function () {
502
+ try { localStorage.removeItem(SEARCH_HIST_KEY); } catch (e) {}
503
+ return [];
504
+ };
451
505
  var prefsStore = (function () {
452
506
  var hasOwn = function (obj, key) { return Object.prototype.hasOwnProperty.call(obj, key); };
453
507
  var read = function (key, legacy) {
@@ -507,6 +561,7 @@ window.__ModuleLoader__.load({
507
561
  w: wRaw !== null && isFinite(Number(wRaw)) && Number(wRaw) >= 120 ? Number(wRaw) : 0,
508
562
  h: hRaw !== null && isFinite(Number(hRaw)) && Number(hRaw) >= 60 ? Number(hRaw) : 0,
509
563
  levels: parseLevels(rawLevels) || LEVELS_ALL.slice(),
564
+ density: read(PREF_KEYS.density) === "compact" ? "compact" : "comfy",
510
565
  zoom: clampRatio(zoomRaw, 1),
511
566
  sheetZoom: clampRatio(sheetZoomRaw, 1),
512
567
  handle: clampRatio(handleRaw, 0.5),
@@ -528,6 +583,7 @@ window.__ModuleLoader__.load({
528
583
  w: function (v) { return String(v); },
529
584
  h: function (v) { return String(v); },
530
585
  levels: function (v) { return JSON.stringify(v); },
586
+ density: function (v) { return v === "compact" ? "compact" : "comfy"; },
531
587
  zoom: function (v) { return String(v); },
532
588
  sheetZoom: function (v) { return String(v); },
533
589
  handle: function (v) { return String(v); },
@@ -562,6 +618,7 @@ window.__ModuleLoader__.load({
562
618
  dock: v.dock === "right" ? "right" : "left",
563
619
  lang: v.lang === "zh" || v.lang === "en" ? v.lang : "auto",
564
620
  levels: levels || LEVELS_ALL.slice(),
621
+ density: v.density === "compact" ? "compact" : "comfy",
565
622
  zoom: isFinite(zoomNum) ? Math.min(2, Math.max(0.5, Math.round(zoomNum * 100) / 100)) : 1,
566
623
  sheetZoom: isFinite(sheetZoomNum) ? Math.min(2, Math.max(0.5, Math.round(sheetZoomNum * 100) / 100)) : 1,
567
624
  handle: isFinite(handleNum) ? Math.min(1, Math.max(0, Math.round(handleNum * 100) / 100)) : 0.5,
@@ -578,7 +635,7 @@ window.__ModuleLoader__.load({
578
635
  if (!over) return local;
579
636
  return {
580
637
  dock: over.dock, y: local.y, w: local.w, h: local.h,
581
- levels: over.levels, zoom: over.zoom, sheetZoom: over.sheetZoom, handle: over.handle, fuzzy: over.fuzzy, hover: over.hover,
638
+ levels: over.levels, density: over.density, zoom: over.zoom, sheetZoom: over.sheetZoom, handle: over.handle, fuzzy: over.fuzzy, hover: over.hover,
582
639
  lang: over.lang, remember: over.remember, debug: over.debug, autoLoad: over.autoLoad
583
640
  };
584
641
  };
@@ -859,12 +916,21 @@ window.__ModuleLoader__.load({
859
916
  return extractReplyText(node);
860
917
  }
861
918
 
862
- // first non-empty line of a message, truncated for the outline header
919
+ // first non-empty line of a message, truncated for the outline header.
920
+ // Scanning for the line instead of splitting the whole text matters: this is
921
+ // called once per match inside the search loops, and a full split per call
922
+ // made typing in the search box quadratic on long sessions.
863
923
  function previewText(text, max) {
864
924
  if (!text) return "";
865
- var first = text.split("\n").map(function (s) { return s.trim(); }).filter(Boolean)[0] || "";
866
- if (first.length > max) first = first.slice(0, max) + "…";
867
- return first;
925
+ var s = String(text);
926
+ var from = 0;
927
+ for (;;) {
928
+ var nl = s.indexOf("\n", from);
929
+ var line = (nl === -1 ? s.slice(from) : s.slice(from, nl)).trim();
930
+ if (line) return line.length > max ? line.slice(0, max) + "…" : line;
931
+ if (nl === -1) return "";
932
+ from = nl + 1;
933
+ }
868
934
  }
869
935
 
870
936
  // ---------- normalized matching (fold, plus an optional fuzzy pass) ----------
@@ -965,7 +1031,103 @@ window.__ModuleLoader__.load({
965
1031
  function countOccurrences(text, q, fuzzy) {
966
1032
  var fq = q && q.folded !== undefined ? q.folded : foldQuery(q);
967
1033
  if (!text || !fq) return 0;
968
- return findMatches(foldWithMap(text), fq, !!fuzzy).length;
1034
+ return countFolded(foldedText(text), fq, !!fuzzy);
1035
+ }
1036
+
1037
+ // ---------- cached folding for the search scan ----------
1038
+ // Search re-reads the SAME message texts on every keystroke — twice (the
1039
+ // match list and the result rows fold them independently) — so the folded
1040
+ // form is kept per text. Counting never needs the per-character index map,
1041
+ // so the cache stores the plain folded string plus, lazily, the flattened
1042
+ // one-line form the snippets are cut from.
1043
+ var searchFoldCache = new Map();
1044
+ // the fold itself (no cache): whitespace runs collapse to one space, and
1045
+ // full-width forms and case fold 1:1. Char-code tests instead of a regex per
1046
+ // character: this runs over every loaded message whenever a search starts.
1047
+ function foldString(src) {
1048
+ var out = "";
1049
+ var prevSpace = false;
1050
+ for (var i = 0; i < src.length; i++) {
1051
+ var code = src.charCodeAt(i);
1052
+ // whitespace of any kind (case folding never changes it)
1053
+ if (code === 32 || (code >= 9 && code <= 13) || code === 160 || code === 5760 ||
1054
+ (code >= 8192 && code <= 8202) || code === 8232 || code === 8233 || code === 8239 ||
1055
+ code === 8287 || code === 12288 || code === 65279) {
1056
+ if (prevSpace) continue; // collapse a whitespace run to one space
1057
+ prevSpace = true;
1058
+ out += " ";
1059
+ continue;
1060
+ }
1061
+ prevSpace = false;
1062
+ if (code >= 0xFF01 && code <= 0xFF5E) {
1063
+ // full-width ASCII to half-width, then case-fold the result
1064
+ var half = code - 0xFEE0;
1065
+ out += half >= 65 && half <= 90 ? String.fromCharCode(half + 32) : String.fromCharCode(half);
1066
+ continue;
1067
+ }
1068
+ if (code >= 65 && code <= 90) { out += String.fromCharCode(code + 32); continue; }
1069
+ if (code >= 97 && code <= 122 || code < 128) { out += src.charAt(i); continue; }
1070
+ // cased scripts outside ASCII (Greek, Cyrillic, …) fold via toLowerCase
1071
+ out += src.charAt(i).toLowerCase();
1072
+ }
1073
+ return out;
1074
+ }
1075
+ function foldWork(text) {
1076
+ var src = String(text === undefined || text === null ? "" : text);
1077
+ var w = searchFoldCache.get(src);
1078
+ if (w !== undefined) return w;
1079
+ w = { folded: foldString(src), flat: null, flatFolded: null };
1080
+ if (searchFoldCache.size > 1200) searchFoldCache.clear();
1081
+ searchFoldCache.set(src, w);
1082
+ return w;
1083
+ }
1084
+ function foldedText(text) {
1085
+ return foldWork(text).folded;
1086
+ }
1087
+ // the one-line form snippets are sliced from (whitespace collapsed). Its fold
1088
+ // lives on the same entry — a second cache key per message would evict twice
1089
+ // as fast and make every keystroke re-fold the session again.
1090
+ function flatWork(text) {
1091
+ var w = foldWork(text);
1092
+ if (w.flat === null) {
1093
+ w.flat = String(text === undefined || text === null ? "" : text).replace(/\s+/g, " ").trim();
1094
+ w.flatFolded = foldString(w.flat);
1095
+ }
1096
+ return w;
1097
+ }
1098
+
1099
+ // Count only: the match scan without hit objects or ranges. Same walk as
1100
+ // findMatches, so the numbers agree with the stepping list.
1101
+ function countFolded(folded, fq, fuzzy) {
1102
+ if (!folded || !fq) return 0;
1103
+ if (!fuzzy) {
1104
+ var n = 0;
1105
+ var at = 0;
1106
+ while ((at = folded.indexOf(fq, at)) !== -1) {
1107
+ n++;
1108
+ at += fq.length;
1109
+ }
1110
+ return n;
1111
+ }
1112
+ var span = fuzzySpan(fq);
1113
+ var from = 0;
1114
+ var count = 0;
1115
+ for (;;) {
1116
+ var start = -1;
1117
+ var pos = from;
1118
+ var ok = true;
1119
+ for (var i = 0; i < fq.length; i++) {
1120
+ var hit = folded.indexOf(fq.charAt(i), pos);
1121
+ if (hit === -1 || (start !== -1 && hit - start > span)) { ok = false; break; }
1122
+ if (start === -1) start = hit;
1123
+ pos = hit + 1;
1124
+ }
1125
+ if (!ok) break;
1126
+ count++;
1127
+ from = pos;
1128
+ if (from >= folded.length) break;
1129
+ }
1130
+ return count;
969
1131
  }
970
1132
 
971
1133
  // ---------- section bodies: subtitle + hover preview ----------
@@ -1132,17 +1294,408 @@ window.__ModuleLoader__.load({
1132
1294
  }
1133
1295
 
1134
1296
  // A one-line window around the first hit of `q` in `text` (for search results).
1297
+ // Cuts from the cached flattened form: folding it is index-stable (the
1298
+ // whitespace is already collapsed), so the folded hit position slices it
1299
+ // directly — no per-call re-fold of the whole message.
1135
1300
  function snippetAround(text, q, span, fuzzy) {
1136
1301
  if (!text) return "";
1137
- var flat = text.replace(/\s+/g, " ").trim();
1302
+ var w = flatWork(text);
1138
1303
  var fq = foldQuery(q);
1139
- var folded = fq ? foldWithMap(flat) : null;
1140
- var hits = folded ? findMatches(folded, fq, !!fuzzy) : [];
1141
- if (hits.length === 0) return previewText(flat, span * 2);
1142
- var at = folded.map[hits[0].ranges[0][0]];
1304
+ if (!fq) return previewText(w.flat, span * 2);
1305
+ var at = firstHitIndex(w.flatFolded, fq, !!fuzzy);
1306
+ if (at < 0) return previewText(w.flat, span * 2);
1143
1307
  var start = Math.max(0, at - span);
1144
- var end = Math.min(flat.length, at + span * 2);
1145
- return (start > 0 ? "…" : "") + flat.slice(start, end) + (end < flat.length ? "…" : "");
1308
+ var end = Math.min(w.flat.length, at + span * 2);
1309
+ return (start > 0 ? "…" : "") + w.flat.slice(start, end) + (end < w.flat.length ? "…" : "");
1310
+ }
1311
+
1312
+ // folded-space index of the first hit's first character (-1 when there is none)
1313
+ function firstHitIndex(folded, fq, fuzzy) {
1314
+ if (!folded || !fq) return -1;
1315
+ if (!fuzzy) return folded.indexOf(fq);
1316
+ var span = fuzzySpan(fq);
1317
+ var start = -1;
1318
+ var at = 0;
1319
+ for (var i = 0; i < fq.length; i++) {
1320
+ var hit = folded.indexOf(fq.charAt(i), at);
1321
+ if (hit === -1 || (start !== -1 && hit - start > span)) return -1;
1322
+ if (start === -1) start = hit;
1323
+ at = hit + 1;
1324
+ }
1325
+ return start;
1326
+ }
1327
+
1328
+ // ---------- reaching this plugin's own settings screen ----------
1329
+ // The host's own navigation first (see `openPluginSettingsPage`), then the DOM
1330
+ // chain below, and nothing silent on the way: a click that cannot land warns.
1331
+ //
1332
+ // Measured on 0.2.0-rc.2: the Plugins page publishes every entry as
1333
+ // `data-plugin-item="<id>"` — this plugin's id is `quick-toc` — and renders the
1334
+ // card's title as a button, which is the host's own open gesture. There is no
1335
+ // `data-section` anywhere in this host line, so the navigation-cell route below
1336
+ // is a legacy fallback, not the way in.
1337
+ var SETTINGS_NAV_ITEM_ATTR = "data-section";
1338
+ var SETTINGS_NAV_ITEM_VALUE = "plugins";
1339
+ var SETTINGS_NAV_CURRENT_ATTR = "aria-current";
1340
+ var SETTINGS_LAUNCHER_HASPOPUP = "dialog";
1341
+ var PLUGIN_ITEM_ATTR = "data-plugin-item";
1342
+ var PLUGIN_ITEM_VALUE = "quick-toc";
1343
+ // Labels are compared for EQUALITY, never as substrings: a sidebar session
1344
+ // titled "quick-toc 插件开发" is not this plugin's entry, and treating it as one
1345
+ // is exactly the bug that sent this button to the wrong place.
1346
+ var PLUGIN_TEXTS = ["对话大纲", "quick toc", "quick-toc", "dsh-quick-toc", "conversation outline"];
1347
+ var PLUGIN_CONFIG_TEXTS = ["插件配置", "plugin configuration", "plugin config"];
1348
+ var PLUGIN_CARD_CANDIDATES = "[data-plugin-item], button, a, [role=button], [role=tab], [role=menuitem], [role=link]";
1349
+ var PLUGIN_TAB_CANDIDATES = "button, a, [role=tab], [role=button]";
1350
+ var SETTINGS_NAV_TEXTS = ["插件", "Plugins", "Plugin", "プラグイン", "플러그인"];
1351
+ var SETTINGS_LAUNCHER_TEXTS = ["设置", "Settings", "設定", "설정", "Einstellungen"];
1352
+ var SETTINGS_PANEL_ID = "plugins";
1353
+ var SETTINGS_BUNDLE_NAME = "dsh-quick-toc";
1354
+ function readNodeText(node) {
1355
+ if (node === null || node === undefined) return "";
1356
+ var text = node.textContent;
1357
+ return typeof text === "string" ? text : "";
1358
+ }
1359
+ function readNodeAttr(node, name) {
1360
+ if (node === null || node === undefined || typeof node.getAttribute !== "function") return "";
1361
+ var value = node.getAttribute(name);
1362
+ return value === null || value === undefined ? "" : String(value);
1363
+ }
1364
+ // A node's own label: whitespace collapsed, case folded — what an exact match
1365
+ // is compared against.
1366
+ function nodeLabel(node) {
1367
+ return readNodeText(node).replace(/\s+/g, " ").trim().toLowerCase();
1368
+ }
1369
+ // Whether the node itself is the thing a user clicks (not a list item that
1370
+ // merely CONTAINS the clickable).
1371
+ function isClickableSelf(node) {
1372
+ var tag = typeof node.tagName === "string" ? node.tagName.toLowerCase() : "";
1373
+ if (tag === "button" || tag === "a") return true;
1374
+ var role = readNodeAttr(node, "role").toLowerCase();
1375
+ return role === "button" || role === "tab" || role === "menuitem" || role === "link" || role === "option";
1376
+ }
1377
+ // Whether one element is a settings navigation cell that opens the Plugins
1378
+ // screen: the entry's own id as a data attribute first (the one form that does
1379
+ // not depend on a label's language), then an exactly labelled current cell.
1380
+ function isSettingsNavItem(node) {
1381
+ if (node === null || node === undefined || typeof node !== "object") return false;
1382
+ if (readNodeAttr(node, SETTINGS_NAV_ITEM_ATTR) === SETTINGS_NAV_ITEM_VALUE) return true;
1383
+ if (readNodeAttr(node, SETTINGS_NAV_CURRENT_ATTR) === "") return false;
1384
+ var label = nodeLabel(node);
1385
+ if (label === "") return false;
1386
+ for (var i = 0; i < SETTINGS_NAV_TEXTS.length; i++) {
1387
+ if (label === SETTINGS_NAV_TEXTS[i].toLowerCase()) return true;
1388
+ }
1389
+ return false;
1390
+ }
1391
+ // Whether one element is THIS plugin's own row in the Plugins list.
1392
+ //
1393
+ // The shipped id is the only evidence trusted on its own. The label route needs
1394
+ // a node that is clickable ITSELF — a container whose text happens to mention
1395
+ // the plugin (a session row, a message that names it) is never the entry, and
1396
+ // clicking "the first button inside it" is how the jump used to land somewhere
1397
+ // else entirely.
1398
+ function isPluginEntry(node) {
1399
+ if (node === null || node === undefined || typeof node !== "object") return false;
1400
+ if (readNodeAttr(node, PLUGIN_ITEM_ATTR) === PLUGIN_ITEM_VALUE) return true;
1401
+ if (!isClickableSelf(node)) return false;
1402
+ var label = nodeLabel(node);
1403
+ if (label === "") return false;
1404
+ for (var i = 0; i < PLUGIN_TEXTS.length; i++) {
1405
+ if (label === PLUGIN_TEXTS[i].toLowerCase()) return true;
1406
+ }
1407
+ return false;
1408
+ }
1409
+ function isPluginConfigTab(node) {
1410
+ if (node === null || node === undefined || typeof node !== "object") return false;
1411
+ if (!isClickableSelf(node)) return false;
1412
+ var label = nodeLabel(node);
1413
+ if (label === "") return false;
1414
+ for (var i = 0; i < PLUGIN_CONFIG_TEXTS.length; i++) {
1415
+ if (label === PLUGIN_CONFIG_TEXTS[i].toLowerCase()) return true;
1416
+ }
1417
+ return false;
1418
+ }
1419
+ function isSettingsLauncher(node) {
1420
+ if (node === null || node === undefined || typeof node !== "object") return false;
1421
+ if (readNodeAttr(node, "aria-haspopup") !== SETTINGS_LAUNCHER_HASPOPUP) return false;
1422
+ var label = (readNodeAttr(node, "aria-label") || readNodeAttr(node, "title") || readNodeText(node))
1423
+ .trim().toLowerCase();
1424
+ if (label === "") return false;
1425
+ for (var i = 0; i < SETTINGS_LAUNCHER_TEXTS.length; i++) {
1426
+ if (label.indexOf(SETTINGS_LAUNCHER_TEXTS[i].toLowerCase()) >= 0) return true;
1427
+ }
1428
+ return false;
1429
+ }
1430
+ function dispatchSynthetic(node, type) {
1431
+ if (node === null || node === undefined || typeof node.dispatchEvent !== "function") return false;
1432
+ var win = typeof globalThis !== "undefined" ? globalThis : null;
1433
+ var View = win !== null && win.MouseEvent !== undefined ? win.MouseEvent : null;
1434
+ var event = null;
1435
+ try {
1436
+ event = View !== null
1437
+ ? new View(type, { bubbles: true, cancelable: true, view: win })
1438
+ : { type: type, bubbles: true, cancelable: true, defaultPrevented: false, preventDefault: function () {} };
1439
+ } catch (e) {
1440
+ event = { type: type, bubbles: true, cancelable: true, defaultPrevented: false, preventDefault: function () {} };
1441
+ }
1442
+ try {
1443
+ node.dispatchEvent(event);
1444
+ return true;
1445
+ } catch (e) {
1446
+ return false;
1447
+ }
1448
+ }
1449
+ // Whether a node sits somewhere whose text names this plugin without being its
1450
+ // settings entry: our OWN panel, or the sidebar's session list (a conversation
1451
+ // titled "quick-toc 插件开发" is not the plugin's page).
1452
+ function isForeignNode(node) {
1453
+ if (node === null || node === undefined) return true;
1454
+ try {
1455
+ if (typeof node.closest === "function") {
1456
+ if (node.closest(".dqt-list, .dqt-header, .dqt-sheet-chrome") !== null) return true;
1457
+ if (node.closest('[role="treeview"], [role="tree"], [data-row-key]') !== null) return true;
1458
+ }
1459
+ } catch (e) { return false; }
1460
+ return false;
1461
+ }
1462
+ // The element that opens a plugin's page from its entry card: the card itself is
1463
+ // a list item with no handler of its own, and the host renders the title button
1464
+ // inside it as the open gesture.
1465
+ function pluginCardTarget(card) {
1466
+ if (isClickableSelf(card)) return card;
1467
+ if (typeof card.querySelector === "function") {
1468
+ var button = null;
1469
+ try {
1470
+ button = card.querySelector("button");
1471
+ } catch (e) { button = null; }
1472
+ if (button !== null && button !== undefined) return button;
1473
+ }
1474
+ return card;
1475
+ }
1476
+ // THIS plugin's own row in the Plugins list, or null.
1477
+ //
1478
+ // Two shapes, in the order they can be trusted: the host-published id (the
1479
+ // shipped `data-plugin-item`, whose card carries the open gesture), then a node
1480
+ // that is clickable ITSELF and labelled exactly with one of this plugin's names
1481
+ // (a plugin that ships its own sidebar entry). Nothing else — a loose label
1482
+ // match on a container, then a click on whatever button sat inside it, is what
1483
+ // used to send this button somewhere else entirely.
1484
+ function settingsPluginEntry(doc) {
1485
+ if (doc === null || doc === undefined || typeof doc.querySelectorAll !== "function") return null;
1486
+ var card = null;
1487
+ try {
1488
+ if (typeof doc.querySelector === "function") {
1489
+ card = doc.querySelector("[" + PLUGIN_ITEM_ATTR + '="' + PLUGIN_ITEM_VALUE + '"]');
1490
+ }
1491
+ } catch (e) { card = null; }
1492
+ if (card !== null && card !== undefined && !isForeignNode(card)) return pluginCardTarget(card);
1493
+ var candidates = null;
1494
+ try {
1495
+ candidates = doc.querySelectorAll(PLUGIN_CARD_CANDIDATES);
1496
+ } catch (e) {
1497
+ return null;
1498
+ }
1499
+ for (var i = 0; i < candidates.length; i++) {
1500
+ var node = candidates[i];
1501
+ if (isForeignNode(node)) continue;
1502
+ if (!isClickableSelf(node)) continue;
1503
+ if (!isPluginEntry(node)) continue;
1504
+ return node;
1505
+ }
1506
+ return null;
1507
+ }
1508
+ // The shortest route to the Plugins screen, or null. Plans are plain clicks —
1509
+ // nothing here pokes at a framework's internals.
1510
+ function findSettingsEntry(doc, options) {
1511
+ if (doc === null || doc === undefined || typeof doc !== "object") return null;
1512
+ var opts = options !== null && typeof options === "object" ? options : {};
1513
+ var all = function () {
1514
+ var found = typeof doc.querySelectorAll === "function" ? doc.querySelectorAll("button") : null;
1515
+ return found === null || found === undefined ? [] : found;
1516
+ };
1517
+ var first = function (predicate) {
1518
+ var nodes = all();
1519
+ for (var i = 0; i < nodes.length; i++) {
1520
+ if (predicate(nodes[i])) return nodes[i];
1521
+ }
1522
+ return null;
1523
+ };
1524
+ var cellWithAttr = function () {
1525
+ return first(function (node) {
1526
+ return readNodeAttr(node, SETTINGS_NAV_ITEM_ATTR) === SETTINGS_NAV_ITEM_VALUE;
1527
+ });
1528
+ };
1529
+ var cellByText = function () {
1530
+ return first(function (node) {
1531
+ return readNodeAttr(node, SETTINGS_NAV_CURRENT_ATTR) !== "" && isSettingsNavItem(node);
1532
+ });
1533
+ };
1534
+ var launcher = function () { return first(isSettingsLauncher); };
1535
+ var firstIn = function (selector, predicate) {
1536
+ var nodes = typeof doc.querySelectorAll === "function" ? doc.querySelectorAll(selector) : [];
1537
+ for (var i = 0; i < nodes.length; i++) {
1538
+ if (predicate(nodes[i])) return nodes[i];
1539
+ }
1540
+ return null;
1541
+ };
1542
+ var pluginConfigTab = function () { return firstIn(PLUGIN_TAB_CANDIDATES, isPluginConfigTab); };
1543
+ var sectionPlan = function (kind, why, node) {
1544
+ return {
1545
+ kind: kind,
1546
+ why: why,
1547
+ node: node,
1548
+ run: function () { return dispatchSynthetic(node, "click"); }
1549
+ };
1550
+ };
1551
+ // every route shares the tail: the Plugins cell, then THIS plugin's row
1552
+ // inside it, then its configuration tab when the host has one (a host that
1553
+ // shows the configuration inline has no such tab — not a failure)
1554
+ var withPluginEntry = function (plan) {
1555
+ var opened = plan.run;
1556
+ plan.run = function () {
1557
+ var reached = opened();
1558
+ if (typeof opts.retry !== "function") return reached;
1559
+ var entry = opts.retry(function () { return settingsPluginEntry(doc); });
1560
+ if (entry === null) return reached;
1561
+ return opts.retry(pluginConfigTab) || entry;
1562
+ };
1563
+ return plan;
1564
+ };
1565
+ // the plugin may ship its own SIDEBAR entry — clicking it opens the
1566
+ // configuration directly and there is no settings route to walk
1567
+ var sidebarEntry = settingsPluginEntry(doc);
1568
+ if (sidebarEntry !== null) {
1569
+ return {
1570
+ kind: "sidebar-entry",
1571
+ why: "the plugin ships its own entry; it opens the configuration directly",
1572
+ node: sidebarEntry,
1573
+ run: function () { return dispatchSynthetic(sidebarEntry, "click"); }
1574
+ };
1575
+ }
1576
+ var canonical = cellWithAttr();
1577
+ if (canonical !== null) {
1578
+ return withPluginEntry(sectionPlan("section-attr", "the Plugins entry ships its own id as a data attribute", canonical));
1579
+ }
1580
+ var byText = cellByText();
1581
+ if (byText !== null) {
1582
+ return withPluginEntry(sectionPlan("section-text", "the Plugins entry was matched by its translated label", byText));
1583
+ }
1584
+ var opener = launcher();
1585
+ if (opener === null) return null;
1586
+ return withPluginEntry({
1587
+ kind: "launcher",
1588
+ why: "only the sidebar's settings launcher is on screen; the entry is picked after it opens",
1589
+ node: opener,
1590
+ run: function () {
1591
+ var reached = dispatchSynthetic(opener, "click");
1592
+ if (typeof opts.retry !== "function") return reached;
1593
+ return opts.retry(cellWithAttr) || opts.retry(cellByText) || reached;
1594
+ }
1595
+ });
1596
+ }
1597
+
1598
+ // The host context `apply` received: the settings button's navigation
1599
+ // services are looked up through it, never declared as dependencies (a
1600
+ // required inject would park this whole package on a host without them).
1601
+ var settingsHostCtx = null;
1602
+ function settingsService(name) {
1603
+ var ctx = settingsHostCtx;
1604
+ if (ctx === null || ctx === undefined) return null;
1605
+ try {
1606
+ if (typeof ctx.get === "function") {
1607
+ var found = ctx.get(name);
1608
+ if (found) return found;
1609
+ }
1610
+ } catch (e) { /* fall through to the property read */ }
1611
+ try {
1612
+ return ctx[name] || null;
1613
+ } catch (e) {
1614
+ return null;
1615
+ }
1616
+ }
1617
+ // Click THIS plugin's entry on the Plugins page once the page has mounted it.
1618
+ //
1619
+ // The card appears a commit or two after the panel switch, and the inventory
1620
+ // itself may still be loading, so the lookup polls briefly instead of giving up
1621
+ // on the first frame; twenty tries at 200 ms is well past a page paint and still
1622
+ // reads as one gesture.
1623
+ function clickSettingsEntry(done) {
1624
+ var tries = 0;
1625
+ var step = function () {
1626
+ tries += 1;
1627
+ var target = settingsPluginEntry(document);
1628
+ if (target !== null) {
1629
+ done(dispatchSynthetic(target, "click") === true);
1630
+ return;
1631
+ }
1632
+ if (tries >= 20) { done(false); return; }
1633
+ setTimeout(step, 200);
1634
+ };
1635
+ step();
1636
+ }
1637
+ // The host's own navigation first: the Plugins page is a main panel the layout
1638
+ // service selects by id, and the entry inside it is the host's own card — the
1639
+ // same two steps a user performs by hand. Measured on 0.2.0-rc.2: both services
1640
+ // are published, and this plugin's card is `[data-plugin-item="quick-toc"]`.
1641
+ //
1642
+ // Order: select the Plugins panel and click THIS plugin's card; if the card never
1643
+ // mounts, ask `pluginNavigation.openBundle` for the package's own page; failing
1644
+ // both, the caller keeps the DOM chain.
1645
+ function openPluginSettingsPage(warn) {
1646
+ var nav = settingsService("pluginNavigation");
1647
+ var layout = settingsService("layout");
1648
+ var canBundle = nav !== null && nav !== undefined && typeof nav.openBundle === "function";
1649
+ var canPanel = layout !== null && layout !== undefined && typeof layout.selectPanel === "function";
1650
+ var openBundle = function () {
1651
+ if (!canBundle) return false;
1652
+ try {
1653
+ nav.openBundle(SETTINGS_BUNDLE_NAME);
1654
+ return true;
1655
+ } catch (e) {
1656
+ warn("the plugin navigation service refused the package");
1657
+ return false;
1658
+ }
1659
+ };
1660
+ if (!canPanel) return openBundle();
1661
+ try {
1662
+ layout.selectPanel(SETTINGS_PANEL_ID);
1663
+ } catch (e) {
1664
+ return openBundle();
1665
+ }
1666
+ // the page mounts a commit after the panel switch: the card click waits for it
1667
+ clickSettingsEntry(function (clicked) {
1668
+ if (clicked) return;
1669
+ if (openBundle()) return;
1670
+ warn("the Plugins page never mounted this plugin's entry");
1671
+ });
1672
+ return true;
1673
+ }
1674
+ // Go to this plugin's own settings screen, with every route the host offers;
1675
+ // nothing is silent — a click that cannot land reports why.
1676
+ function openSettingsScreen(reason, report) {
1677
+ var tag = typeof reason === "string" && reason !== "" ? reason : "settings button";
1678
+ var warn = typeof report === "function" ? report : function () {};
1679
+ if (openPluginSettingsPage(function (detail) { warn(tag, detail); })) return true;
1680
+ var attempts = 0;
1681
+ var retry = function (select) {
1682
+ // the dialog mounts in one commit, and the row mounts one commit after
1683
+ // the cell; six frames is far more than that and still short enough that
1684
+ // a miss reads as "nothing happened"
1685
+ attempts += 1;
1686
+ if (attempts > 6) return null;
1687
+ var found = select();
1688
+ if (found === null) return null;
1689
+ dispatchSynthetic(found, "click");
1690
+ return found;
1691
+ };
1692
+ var plan = findSettingsEntry(document, { retry: retry });
1693
+ if (plan === null) {
1694
+ warn(tag, "no settings entry and no settings launcher on screen");
1695
+ return false;
1696
+ }
1697
+ var taken = plan.run();
1698
+ return taken !== false && taken !== null;
1146
1699
  }
1147
1700
 
1148
1701
  // strict lookup: exact dataset match or CSS-escaped selector only —
@@ -1383,7 +1936,15 @@ window.__ModuleLoader__.load({
1383
1936
  // closing outline + accent bar so the block reads as a finished shape.
1384
1937
  groupTint: "rgba(79, 140, 255, 0.10)",
1385
1938
  groupEdge: "rgba(79, 140, 255, 0.85)",
1386
- groupEdgeSoft: "rgba(79, 140, 255, 0.35)"
1939
+ groupEdgeSoft: "rgba(79, 140, 255, 0.35)",
1940
+ // the curtain's bottom edge: a fixed mid-grey line that reads on both themes
1941
+ // (the l2 token is too faint to see against the conversation below)
1942
+ sheetEdge: "rgba(120, 120, 120, 0.5)",
1943
+ // the curtain's outer shadow: bottom only. The curtain meets its box edge to
1944
+ // edge at the left and right, so the negative spreads keep the vertical seams
1945
+ // free of shadow while the bottom — rounded corners included — lifts off the
1946
+ // conversation below.
1947
+ sheetShadow: "0 14px 22px -12px rgba(0, 0, 0, 0.35), 0 6px 12px -10px rgba(0, 0, 0, 0.25)"
1387
1948
  };
1388
1949
 
1389
1950
  // ---- theme scheme detection (for the panel's 3D inner shadow) ----
@@ -1400,27 +1961,27 @@ window.__ModuleLoader__.load({
1400
1961
  if (body.hasAttribute && body.hasAttribute("data-ds-dark-theme")) return true;
1401
1962
  var cls = body.className || "";
1402
1963
  if (/dark|theme-dark/i.test(cls)) return true;
1403
- var attr = (body.getAttribute("data-theme") || body.getAttribute("data-color-scheme") || "").toLowerCase();
1404
- if (attr === "dark" || /dark/.test(attr)) return true;
1964
+ // each probe is guarded on its own: a stub or a host DOM without
1965
+ // getAttribute must not abort the whole read and force the light answer
1966
+ if (typeof body.getAttribute === "function") {
1967
+ var attr = (body.getAttribute("data-theme") || body.getAttribute("data-color-scheme") || "").toLowerCase();
1968
+ if (attr === "dark" || /dark/.test(attr)) return true;
1969
+ }
1405
1970
  }
1406
1971
  if (typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: dark)").matches) return true;
1407
- var root = body || document.documentElement;
1408
- var cs = getComputedStyle(root).colorScheme;
1409
- if (typeof cs === "string" && /dark/i.test(cs)) return true;
1410
- var v = getComputedStyle(root).getPropertyValue("--dsw-alias-bg-base");
1411
- var m = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(v);
1412
- if (m) return (Number(m[1]) + Number(m[2]) + Number(m[3])) < 384;
1972
+ if (typeof getComputedStyle === "function") {
1973
+ var root = body || document.documentElement;
1974
+ var cs = getComputedStyle(root).colorScheme;
1975
+ if (typeof cs === "string" && /dark/i.test(cs)) return true;
1976
+ var v = getComputedStyle(root).getPropertyValue("--dsw-alias-bg-base");
1977
+ var m = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(v);
1978
+ if (m) return (Number(m[1]) + Number(m[2]) + Number(m[3])) < 384;
1979
+ }
1413
1980
  } catch (e) {}
1414
1981
  return false;
1415
1982
  }
1416
- // Inner shadow: computed per-render (not once) so it follows the live theme.
1417
- // Dark scheme -> soft WHITE bevel (recessed card highlight); light -> soft
1418
- // dark shade. Outer shadow stays off (clip-path would clip it).
1419
- function innerShadow() {
1420
- return isDarkScheme()
1421
- ? "inset 0 0 0 1px rgba(255,255,255,0.16), inset 0 1px 0 rgba(255,255,255,0.12), inset 0 -10px 16px rgba(255,255,255,0.08)"
1422
- : "inset 0 0 0 1px rgba(0,0,0,0.08), inset 0 2px 10px rgba(0,0,0,0.18)";
1423
- }
1983
+ // (the inner bevel is gone with the outer shadow's return: the panel now lifts
1984
+ // off the conversation with a real shadow of its own — see the panel's style.)
1424
1985
 
1425
1986
  // ---------- error boundary ----------
1426
1987
  var ErrorBoundary = (function () {
@@ -1724,42 +2285,8 @@ window.__ModuleLoader__.load({
1724
2285
  setLevels(next);
1725
2286
  };
1726
2287
 
1727
- // ---- heading-level picker popup (transient): opens from the round button
1728
- // next to the search button; closes on an outside pointerdown. Closing
1729
- // plays a short shrink-back animation before unmounting. ----
1730
- var _sLo = react.useState(false);
1731
- var levelsOpen = _sLo[0];
1732
- var setLevelsOpen = _sLo[1];
1733
- var _sLc = react.useState(false);
1734
- var levelsClosing = _sLc[0];
1735
- var setLevelsClosing = _sLc[1];
1736
- var closeLevels = function () {
1737
- if (!levelsOpen || levelsClosing) return;
1738
- setLevelsClosing(true);
1739
- // unmount when the exit animation actually finishes (fallback timer in
1740
- // case animationend never fires, e.g. display:none ancestors)
1741
- var popped = document.querySelector(".dqt-levels-pop");
1742
- var done = false;
1743
- var finish = function () {
1744
- if (done) return;
1745
- done = true;
1746
- if (popped && popped.removeEventListener) popped.removeEventListener("animationend", finish);
1747
- setLevelsOpen(false);
1748
- setLevelsClosing(false);
1749
- };
1750
- if (popped && popped.addEventListener) popped.addEventListener("animationend", finish);
1751
- setTimeout(finish, 220);
1752
- };
1753
- react.useEffect(function () {
1754
- if (!levelsOpen) return;
1755
- var close = function (e) {
1756
- var t = e.target;
1757
- if (t && t.closest && (t.closest(".dqt-levels-pop") || t.closest(".dqt-levels-btn"))) return;
1758
- closeLevels();
1759
- };
1760
- document.addEventListener("pointerdown", close);
1761
- return function () { document.removeEventListener("pointerdown", close); };
1762
- }, [levelsOpen, levelsClosing]);
2288
+ // (the heading-level picker popup is gone with the toolbar's level button:
2289
+ // the levels are a setting, and the settings button lands on that card row)
1763
2290
 
1764
2291
 
1765
2292
  // ---- pagination: show the latest N groups; scrolling to the top loads older ----
@@ -1771,6 +2298,13 @@ window.__ModuleLoader__.load({
1771
2298
  var panelRootRef = react.useRef(null); // the panel box itself (for its live height)
1772
2299
  var didInitScroll = react.useRef(false);
1773
2300
  var outlineTouchRef = react.useRef(0); // last time the user touched the outline
2301
+ // A scroll the panel performs itself (follow, reveal, open landing) stamps
2302
+ // this first, so the scroll event it causes is not mistaken for a reader
2303
+ // gesture (which would quiet the follow for 1.5s after every step it takes).
2304
+ var programScrollRef = react.useRef(0);
2305
+ var markProgramScroll = function (ms) {
2306
+ programScrollRef.current = Date.now() + (ms || 250);
2307
+ };
1774
2308
  var lastScrollTopRef = react.useRef(0);
1775
2309
  var lastHostClickRef = react.useRef(0); // throttle for the "load earlier" click
1776
2310
  // Auto-load bookkeeping (see the paging block): at most one pull in flight, a beat
@@ -1878,8 +2412,10 @@ window.__ModuleLoader__.load({
1878
2412
  var el = listRef.current;
1879
2413
  if (!el) return;
1880
2414
  var a = anchorAt(el); // the row at the top of the list right now
2415
+ // `a.top` is already relative to the list box (see anchorAt); `off` is kept
2416
+ // in that same frame so `restoreDrift` compares like with like
1881
2417
  closedPosRef.current[which] = a
1882
- ? { idx: a.idx, off: a.top - el.getBoundingClientRect().top }
2418
+ ? { idx: a.idx, off: a.top }
1883
2419
  : null;
1884
2420
  };
1885
2421
  var armRestore = function (which) {
@@ -2226,34 +2762,6 @@ window.__ModuleLoader__.load({
2226
2762
  var listFade = _sQF[0];
2227
2763
  var setListFade = _sQF[1];
2228
2764
  var listFadeTimers = react.useRef({ out: null, in: null });
2229
- var toggleQuestions = function () {
2230
- if (listFadeTimers.current.out) clearTimeout(listFadeTimers.current.out);
2231
- if (listFadeTimers.current.in) clearTimeout(listFadeTimers.current.in);
2232
- // reduce motion is a real preference: swap without the two-phase fade
2233
- var calm = false;
2234
- try {
2235
- calm = typeof window !== "undefined" && typeof window.matchMedia === "function"
2236
- && window.matchMedia("(prefers-reduced-motion: reduce)").matches === true;
2237
- } catch (e) { calm = false; }
2238
- if (calm) {
2239
- setQuestionsOnly(function (cur) { return !cur; });
2240
- setListFade("idle");
2241
- return;
2242
- }
2243
- setListFade("out");
2244
- listFadeTimers.current.out = setTimeout(function () {
2245
- listFadeTimers.current.out = null;
2246
- setQuestionsOnly(function (cur) { return !cur; });
2247
- // entering questions-only makes the level filter meaningless: close its
2248
- // popup rather than leave a disabled control's menu open over the list
2249
- if (!questionsOnly && typeof closeLevels === "function") closeLevels();
2250
- setListFade("in");
2251
- listFadeTimers.current.in = setTimeout(function () {
2252
- listFadeTimers.current.in = null;
2253
- setListFade("idle");
2254
- }, 260);
2255
- }, 150);
2256
- };
2257
2765
  react.useEffect(function () {
2258
2766
  return function () {
2259
2767
  if (listFadeTimers.current.out) clearTimeout(listFadeTimers.current.out);
@@ -2455,21 +2963,68 @@ window.__ModuleLoader__.load({
2455
2963
  var prevScope = _s19[0];
2456
2964
  var setPrevScope = _s19[1];
2457
2965
  var scopeTimerRef = react.useRef(null);
2458
- // search scope: "title" (default) / "full" (user messages + AI replies in this
2459
- // session) / "cross" (the HOST's full-text index across every visible session).
2460
- // ONE pill cycles the three, so a third scope costs no extra button.
2966
+ // recent searches, most-recent-first (browser-local; committing a search
2967
+ // fills it, an empty box offers it back as one-tap chips)
2968
+ var _sHist = react.useState(searchHistLoad);
2969
+ var searchHist = _sHist[0];
2970
+ var setSearchHist = _sHist[1];
2971
+ var commitSearchHist = function (q, scope) {
2972
+ // functional update: two commits in one batch must not drop each other
2973
+ setSearchHist(function (prev) { return searchHistRemember(prev, q, scope); });
2974
+ };
2975
+ // search scope: "title" (default) / "prompts" (only your own questions; an
2976
+ // empty box shows the questions view) / "full" (user messages + AI replies
2977
+ // in this session) / "cross" (the HOST's full-text index across every
2978
+ // visible session). ONE pill cycles the four, so a new scope costs no extra
2979
+ // button.
2980
+ var promptsView = searchScope === "prompts";
2461
2981
  var scopeLabel = function (scope) {
2982
+ if (scope === "prompts") return T("search.scope.prompts");
2462
2983
  if (scope === "full") return T("search.scope.full");
2463
2984
  if (scope === "cross") return T("search.scope.cross");
2464
2985
  return T("search.scope.title");
2465
2986
  };
2987
+ // Entering or leaving the 提问 scope swaps the list between the outline and
2988
+ // the questions view — behind the same two-phase fade as of old: the list
2989
+ // fades out, the mode changes while it is invisible, then it fades back in,
2990
+ // so the two lists never blend into each other mid-swap.
2991
+ var swapQuestionsView = function (want) {
2992
+ if (listFadeTimers.current.out) clearTimeout(listFadeTimers.current.out);
2993
+ if (listFadeTimers.current.in) clearTimeout(listFadeTimers.current.in);
2994
+ listFadeTimers.current.out = null;
2995
+ listFadeTimers.current.in = null;
2996
+ if (want === questionsOnly && listFade === "idle") return;
2997
+ // reduce motion is a real preference: swap without the two-phase fade
2998
+ var calm = false;
2999
+ try {
3000
+ calm = typeof window !== "undefined" && typeof window.matchMedia === "function"
3001
+ && window.matchMedia("(prefers-reduced-motion: reduce)").matches === true;
3002
+ } catch (e) { calm = false; }
3003
+ if (calm) {
3004
+ setQuestionsOnly(want);
3005
+ setListFade("idle");
3006
+ return;
3007
+ }
3008
+ setListFade("out");
3009
+ listFadeTimers.current.out = setTimeout(function () {
3010
+ listFadeTimers.current.out = null;
3011
+ setQuestionsOnly(want);
3012
+ setListFade("in");
3013
+ listFadeTimers.current.in = setTimeout(function () {
3014
+ listFadeTimers.current.in = null;
3015
+ setListFade("idle");
3016
+ }, 260);
3017
+ }, 150);
3018
+ };
2466
3019
  var toggleScope = function () {
2467
- var next = searchScope === "title" ? "full" : (searchScope === "full" ? "cross" : "title");
3020
+ var next = searchScope === "title" ? "prompts"
3021
+ : (searchScope === "prompts" ? "full" : (searchScope === "full" ? "cross" : "title"));
2468
3022
  setPrevScope(searchScope);
2469
3023
  setSearchScope(next);
2470
3024
  setMatchIdx(0);
2471
3025
  if (scopeTimerRef.current) clearTimeout(scopeTimerRef.current);
2472
3026
  scopeTimerRef.current = setTimeout(function () { setPrevScope(null); }, 300);
3027
+ swapQuestionsView(next === "prompts");
2473
3028
  };
2474
3029
  // ---- fuzzy matching: an independent switch beside the scope pill --------
2475
3030
  // Off (default) = the folded CONTIGUOUS match. On = subsequence matching, so
@@ -2834,7 +3389,13 @@ window.__ModuleLoader__.load({
2834
3389
  if (isGhost && rk.top >= limit && !seenFull) { pick = k; pickTop = rk.top; seenFull = true; }
2835
3390
  }
2836
3391
  anchorHintRef.current = pick;
2837
- return { idx: boxes[pick].getAttribute("data-group-idx"), top: pickTop };
3392
+ // RELATIVE to the list's own top edge — never a viewport-absolute value.
3393
+ // The panel and the curtain travel on `transform` (dock slides, the drop),
3394
+ // which moves every row and the list box by the same amount: a relative
3395
+ // offset cancels it out, while an absolute one reads the animation as
3396
+ // "content grew" and the compensation drags the list away from where the
3397
+ // follow just placed it.
3398
+ return { idx: boxes[pick].getAttribute("data-group-idx"), top: pickTop - lr.top };
2838
3399
  };
2839
3400
  // What the reader is looking at, and where it sits. Sampled by the scroll handler —
2840
3401
  // so a host-side change landing mid-scroll is still caught — and right before any
@@ -2849,7 +3410,9 @@ window.__ModuleLoader__.load({
2849
3410
  if (!a || !el || !el.isConnected) return false;
2850
3411
  var node = el.querySelector('[data-group-idx="' + a.idx + '"]');
2851
3412
  if (!node) { sampleAnchor(el); return false; }
2852
- var top = node.getBoundingClientRect().top;
3413
+ // relative to the list box, the same frame the anchor was sampled in (see
3414
+ // anchorAt): viewport motion of the whole panel cancels out here
3415
+ var top = node.getBoundingClientRect().top - el.getBoundingClientRect().top;
2853
3416
  var moved = el.scrollTop - a.scrollTop; // what the reader (or a jump) scrolled
2854
3417
  var zf = liveZoom();
2855
3418
  // Growth above the anchor moves it DOWN by exactly as much as the content grew; the
@@ -2869,6 +3432,7 @@ window.__ModuleLoader__.load({
2869
3432
  return false;
2870
3433
  }
2871
3434
  var before = el.scrollTop;
3435
+ markProgramScroll(); // this write is ours, not the reader's
2872
3436
  el.scrollTop = before + grew;
2873
3437
  if (Math.abs(el.scrollTop - before) < 0.5) {
2874
3438
  // clamped (the reader is already at one end): nothing to hold on to
@@ -3180,7 +3744,12 @@ window.__ModuleLoader__.load({
3180
3744
 
3181
3745
  // scroll to the top edge also loads older groups (keeps the visual position)
3182
3746
  var onListScroll = function (e) {
3183
- outlineTouchRef.current = Date.now();
3747
+ // A scroll the panel caused itself (see markProgramScroll) carries no
3748
+ // reader intent: it still updates the bottom state and the paging anchor,
3749
+ // but it must not refresh the follow's quiet window or spend the
3750
+ // "back to where I left" memory.
3751
+ var programmatic = Date.now() < programScrollRef.current;
3752
+ if (!programmatic) outlineTouchRef.current = Date.now();
3184
3753
  // the scroll container itself: a real scroll event's `currentTarget` IS the list
3185
3754
  // element, which is also what the paging logic reads geometry from
3186
3755
  var el = e.currentTarget;
@@ -3191,7 +3760,7 @@ window.__ModuleLoader__.load({
3191
3760
  if (hoverCard) hoverEnd(true);
3192
3761
  }
3193
3762
  lastScrollTopRef.current = el.scrollTop;
3194
- syncBackBtn(el); // reached the remembered place by hand? the button is done
3763
+ if (!programmatic) syncBackBtn(el); // reached the remembered place by hand? the button is done
3195
3764
  // Remember what the reader is looking at. Sampling on every scroll event is what
3196
3765
  // lets a change the reader did NOT start — the host extending the window by itself
3197
3766
  // — be compensated too: the layout effect knows where the anchored row was before
@@ -3612,7 +4181,7 @@ window.__ModuleLoader__.load({
3612
4181
  // a turn the paged window has not loaded: searchable through the host
3613
4182
  // outline's bounded previews, and clicking it pages the turn in first
3614
4183
  if (g.ghost) {
3615
- if (searchScope === "full") {
4184
+ if (searchScope === "full" || promptsView) {
3616
4185
  var gtext = (g.userText || "") + "\n" + (g.preview || "");
3617
4186
  var ng = countOccurrences(gtext, needle, fuzzy);
3618
4187
  if (ng > 0) {
@@ -3621,13 +4190,15 @@ window.__ModuleLoader__.load({
3621
4190
  title: previewText(g.userText || g.preview || "", 40),
3622
4191
  level: 0, path: "", time: "",
3623
4192
  snippet: snippetAround(g.preview || g.userText || "", fq, 36, fuzzy),
3624
- ghost: true, turn: g.turn, seq: g.seq
4193
+ ghost: true, turn: g.turn, seq: g.seq, prompt: true
3625
4194
  }, ng);
3626
4195
  }
3627
4196
  }
3628
4197
  continue;
3629
4198
  }
3630
- for (var j = 0; j < g.headings.length; j++) {
4199
+ // the 提问 filter: only prompt rows survive (the old questions-only
4200
+ // toggle's job, now as a search filter)
4201
+ for (var j = 0; j < (promptsView ? 0 : g.headings.length); j++) {
3631
4202
  var h = g.headings[j];
3632
4203
  var nh = countOccurrences(h.title, needle, fuzzy);
3633
4204
  if (nh > 0) {
@@ -3639,15 +4210,16 @@ window.__ModuleLoader__.load({
3639
4210
  }, nh);
3640
4211
  }
3641
4212
  }
3642
- if (searchScope === "full") {
4213
+ if (searchScope === "full" || promptsView) {
3643
4214
  var nu = g.userKey && g.userFull ? countOccurrences(g.userFull, needle, fuzzy) : 0;
3644
4215
  if (nu > 0) {
3645
4216
  push({
3646
4217
  gi: gi, key: g.userKey, idx: undefined, title: previewText(g.userFull, 40),
3647
- level: 0, path: "", time: g.time, snippet: snippetAround(g.userFull, fq, 36, fuzzy)
4218
+ level: 0, path: "", time: g.time, snippet: snippetAround(g.userFull, fq, 36, fuzzy),
4219
+ prompt: true
3648
4220
  }, nu);
3649
4221
  }
3650
- for (var m = 0; m < g.msgs.length; m++) {
4222
+ for (var m = 0; m < (promptsView ? 0 : g.msgs.length); m++) {
3651
4223
  var msg = g.msgs[m];
3652
4224
  if (!msg.text) continue;
3653
4225
  var nm = countOccurrences(msg.text, needle, fuzzy);
@@ -3659,7 +4231,7 @@ window.__ModuleLoader__.load({
3659
4231
  }
3660
4232
  // a failed turn's provider message is searchable too — "timed out" must
3661
4233
  // find the turns it killed
3662
- if (g.failure && g.failure.message) {
4234
+ if (g.failure && g.failure.message && !promptsView) {
3663
4235
  var nf = countOccurrences(g.failure.message, needle, fuzzy);
3664
4236
  if (nf > 0) {
3665
4237
  push({
@@ -3672,7 +4244,7 @@ window.__ModuleLoader__.load({
3672
4244
  }
3673
4245
  }
3674
4246
  return rows;
3675
- }, [groups, query, searchScope, headingPaths, fuzzy]);
4247
+ }, [groups, query, searchScope, headingPaths, fuzzy, promptsView]);
3676
4248
  // ---- auto-follow: which turn is the reader looking at -------------------
3677
4249
  // This used to hang off ONE scroll listener bound to the scrollport element
3678
4250
  // captured when the effect ran. DSH can replace that element (a jump that
@@ -3759,7 +4331,10 @@ window.__ModuleLoader__.load({
3759
4331
  var giSave = actives[0];
3760
4332
  if (groups[giSave]) savePosition(groups[giSave]);
3761
4333
  // pause right after the reader touched the outline themselves —
3762
- // otherwise paging older turns gets yanked back to the newest group
4334
+ // otherwise paging older turns gets yanked back to the newest group.
4335
+ // (A scroll the follow performs itself must not count as a touch: it
4336
+ // stamps programScrollRef first, and the scroll handler lets a
4337
+ // programmatic scroll through without refreshing the quiet window.)
3763
4338
  if (Date.now() - outlineTouchRef.current <= 1500) return;
3764
4339
  var gi0 = actives[0];
3765
4340
  setVisibleCount(function (prev) {
@@ -3779,7 +4354,13 @@ window.__ModuleLoader__.load({
3779
4354
  // Aligning to the top needs no clamp at all — the list's own end does the work.
3780
4355
  // (Rects are visual; the factor turns the delta back into the scroller's units.)
3781
4356
  var delta = (node.getBoundingClientRect().top - el.getBoundingClientRect().top) / liveZoom();
3782
- if (Math.abs(delta) > 1) el.scrollTop = el.scrollTop + delta;
4357
+ if (Math.abs(delta) > 1) {
4358
+ markProgramScroll();
4359
+ el.scrollTop = el.scrollTop + delta;
4360
+ // the anchor must now describe THIS position, or the height-change
4361
+ // compensation re-aligns to where the list was before the follow moved it
4362
+ sampleAnchor(el);
4363
+ }
3783
4364
  };
3784
4365
  followFrame = requestAnimationFrame(function () { followFrame = requestAnimationFrame(place); });
3785
4366
  followTimer = setTimeout(place, 90); // the row may only exist after the expansion commits
@@ -3811,41 +4392,50 @@ window.__ModuleLoader__.load({
3811
4392
  for (var gi = 0; gi < groups.length; gi++) {
3812
4393
  var g = groups[gi];
3813
4394
  if (g.ghost) {
3814
- if (searchScope === "full") {
4395
+ if (searchScope === "full" || promptsView) {
3815
4396
  var gtext = (g.userText || "") + "\n" + (g.preview || "");
3816
4397
  var ng = countOccurrences(gtext, needle, fuzzy);
3817
4398
  for (var cg = 0; cg < ng; cg++) {
3818
- out.push({ gi: gi, title: g.userText || "", key: "", idx: undefined, ghost: true, turn: g.turn, seq: g.seq });
4399
+ out.push({ gi: gi, title: g.userText || "", key: "", idx: undefined, ghost: true, turn: g.turn, seq: g.seq, prompt: true });
3819
4400
  }
3820
4401
  }
3821
4402
  continue;
3822
4403
  }
3823
- for (var j = 0; j < g.headings.length; j++) {
4404
+ // the 提问 filter keeps only the prompts: heading and reply hits stay
4405
+ // out of the stepping list, so n/N counts what the results show
4406
+ for (var j = 0; j < (promptsView ? 0 : g.headings.length); j++) {
3824
4407
  var h = g.headings[j];
3825
4408
  var n = countOccurrences(h.title, needle, fuzzy);
3826
4409
  for (var c = 0; c < n; c++) {
3827
4410
  out.push({ gi: gi, title: h.title, key: h.key, idx: h.idx });
3828
4411
  }
3829
4412
  }
3830
- if (searchScope === "full") {
4413
+ if (searchScope === "full" || promptsView) {
3831
4414
  if (g.userKey && g.userFull) {
3832
4415
  var nu = countOccurrences(g.userFull, needle, fuzzy);
3833
- for (var cu = 0; cu < nu; cu++) {
3834
- out.push({ gi: gi, title: previewText(g.userFull, 30), key: g.userKey, idx: undefined });
4416
+ if (nu > 0) {
4417
+ // one preview per message, not one per occurrence
4418
+ var uTitle = previewText(g.userFull, 30);
4419
+ for (var cu = 0; cu < nu; cu++) {
4420
+ out.push({ gi: gi, title: uTitle, key: g.userKey, idx: undefined, prompt: true });
4421
+ }
3835
4422
  }
3836
4423
  }
3837
- for (var m = 0; m < g.msgs.length; m++) {
4424
+ for (var m = 0; m < (promptsView ? 0 : g.msgs.length); m++) {
3838
4425
  var msg = g.msgs[m];
3839
4426
  if (!msg.text) continue;
3840
4427
  var nm = countOccurrences(msg.text, needle, fuzzy);
3841
- for (var cm = 0; cm < nm; cm++) {
3842
- out.push({ gi: gi, title: previewText(msg.text, 30), key: msg.key, idx: undefined });
4428
+ if (nm > 0) {
4429
+ var mTitle = previewText(msg.text, 30);
4430
+ for (var cm = 0; cm < nm; cm++) {
4431
+ out.push({ gi: gi, title: mTitle, key: msg.key, idx: undefined });
4432
+ }
3843
4433
  }
3844
4434
  }
3845
4435
  }
3846
4436
  }
3847
4437
  return out;
3848
- }, [groups, query, searchScope, fuzzy]);
4438
+ }, [groups, query, searchScope, fuzzy, promptsView]);
3849
4439
 
3850
4440
  // which result row holds the current (n/N) occurrence (needs `matches`)
3851
4441
  var activeResultRow = -1;
@@ -3974,6 +4564,7 @@ window.__ModuleLoader__.load({
3974
4564
  return;
3975
4565
  }
3976
4566
  pendingLocate = { sessionId: hitSessionId, query: query.trim(), at: Date.now() };
4567
+ if (query.trim() !== "") commitSearchHist(query.trim(), searchScope);
3977
4568
  try {
3978
4569
  openSessionById(hitSessionId);
3979
4570
  } catch (e) {
@@ -4030,7 +4621,7 @@ window.__ModuleLoader__.load({
4030
4621
  searchPosRef.current = q;
4031
4622
  if (!q) { setHint(""); return; }
4032
4623
  var el = listRef.current;
4033
- if (el) el.scrollTop = el.scrollHeight;
4624
+ if (el) { markProgramScroll(); el.scrollTop = el.scrollHeight; }
4034
4625
  // cross-session scope: the list is host hits, so neither the n/N cursor nor
4035
4626
  // the "scroll up for older messages" hint applies
4036
4627
  if (searchScope === "cross") { setHint(""); return; }
@@ -4088,7 +4679,7 @@ window.__ModuleLoader__.load({
4088
4679
  if (!el) return;
4089
4680
  var tight = el.scrollWidth > el.clientWidth + 1;
4090
4681
  setCrowded(function (prev) { return prev === tight ? prev : tight; });
4091
- }, [open, panelW, zoom, dock, levelsOpen, searchOpen, groups.length]);
4682
+ }, [open, panelW, zoom, dock, searchOpen, groups.length]);
4092
4683
  react.useEffect(function () {
4093
4684
  var el = headerRef.current;
4094
4685
  if (!el) return;
@@ -4266,11 +4857,13 @@ window.__ModuleLoader__.load({
4266
4857
  if (atBottomRef.current) {
4267
4858
  if (!sheetOpen) return undefined;
4268
4859
  anchorRef.current = null; // no compensation against a stale anchor
4860
+ markProgramScroll();
4269
4861
  el.scrollTop = el.scrollHeight;
4270
4862
  syncAtBottom(el);
4271
4863
  var again = requestAnimationFrame(function () { // ...and once more, after layout
4272
4864
  var el2 = listRef.current;
4273
4865
  if (!el2 || !atBottomRef.current) return;
4866
+ markProgramScroll();
4274
4867
  el2.scrollTop = el2.scrollHeight;
4275
4868
  syncAtBottom(el2);
4276
4869
  sampleAnchor(el2);
@@ -4295,6 +4888,32 @@ window.__ModuleLoader__.load({
4295
4888
  return landOnReadingRow();
4296
4889
  }, [open]);
4297
4890
 
4891
+ // Long-list virtualization must not run while a surface is ANIMATING. The
4892
+ // skip state of `content-visibility` is re-evaluated on the browser's own
4893
+ // layout passes, and the panel's open slide is a compositor-driven
4894
+ // transform — so a skipped list can stay blank through the whole slide and
4895
+ // only paint when it stops ("opens white, the text pops in when it lands").
4896
+ // The hold arms the moment the surface state flips and is released after
4897
+ // the animation window; the release's own re-render re-arms the skip.
4898
+ var virtReleaseRef = react.useRef(null);
4899
+ var virtHoldUntilRef = react.useRef(0);
4900
+ var virtSlot = react.useState(0);
4901
+ // the tick's VALUE is never read: the release's setter exists for its
4902
+ // re-render, which re-arms the skip after the animation window
4903
+ var setVirtTick = virtSlot[1];
4904
+ var surfaceKey = (open && !panelAside ? 1 : 0) + (sheetOpen ? 2 : 0);
4905
+ if (virtReleaseRef.current === null) {
4906
+ virtReleaseRef.current = surfaceKey; // the first paint is not an animation
4907
+ } else if (virtReleaseRef.current !== surfaceKey) {
4908
+ virtReleaseRef.current = surfaceKey;
4909
+ virtHoldUntilRef.current = Date.now() + VIRT_HOLD_MS;
4910
+ setTimeout(function () {
4911
+ virtHoldUntilRef.current = 0;
4912
+ setVirtTick(function (v) { return v + 1; });
4913
+ }, VIRT_HOLD_MS + 40);
4914
+ }
4915
+ var virtHold = Date.now() < virtHoldUntilRef.current;
4916
+
4298
4917
  // ---- nothing to show without headings ----
4299
4918
  if (groups.length === 0) return null;
4300
4919
 
@@ -4810,7 +5429,14 @@ window.__ModuleLoader__.load({
4810
5429
  if (!node) return false;
4811
5430
  // the rects are visual: divide by the live factor before writing a scroll offset
4812
5431
  var delta = (node.getBoundingClientRect().top - el.getBoundingClientRect().top) / liveZoom();
4813
- if (Math.abs(delta) > 0.5) el.scrollTop = el.scrollTop + delta;
5432
+ if (Math.abs(delta) > 0.5) {
5433
+ markProgramScroll();
5434
+ el.scrollTop = el.scrollTop + delta;
5435
+ }
5436
+ // Either way this placement is the reader's new reality: the anchor must not
5437
+ // drag the list back to where it sat before (the curtain's re-layout makes the
5438
+ // old anchor look "pushed" and the compensation would undo the landing).
5439
+ sampleAnchor(el);
4814
5440
  return true;
4815
5441
  };
4816
5442
  var revealGroupInOutline = function (gi) {
@@ -4873,6 +5499,8 @@ window.__ModuleLoader__.load({
4873
5499
  var goToRow = function (i) {
4874
5500
  var start = 0;
4875
5501
  for (var k = 0; k < i && k < resultRows.length; k++) start += resultRows[k].count;
5502
+ // picking a result is a committed search too
5503
+ if (query.trim() !== "") commitSearchHist(query.trim(), searchScope);
4876
5504
  goToMatch(start);
4877
5505
  };
4878
5506
 
@@ -4934,7 +5562,13 @@ window.__ModuleLoader__.load({
4934
5562
  // the curtain is wide: rows switch to a one-line, multi-part layout that
4935
5563
  // puts the section's opening text out to the right instead of under the
4936
5564
  // title (see renderItem / renderResultRow)
4937
- wide: sheetOpen
5565
+ wide: sheetOpen,
5566
+ // compact density: tighten the row rhythm so more of the outline is on
5567
+ // screen (see renderGroups / renderGroupHeader / renderItem)
5568
+ dense: prefs.density === "compact",
5569
+ // the virtualization hold: while a surface animates, group boxes must not
5570
+ // skip their content (see the hold block above the early return)
5571
+ virtHold: virtHold
4938
5572
  };
4939
5573
 
4940
5574
  // ---- keyboard cursor plumbing -------------------------------------------
@@ -5117,7 +5751,12 @@ window.__ModuleLoader__.load({
5117
5751
  // reader reported. A transform is handed to the compositor, so the animation no longer
5118
5752
  // depends on how big the list is.
5119
5753
  var panelTransition = (zoomJustChanged ? "" : "transform " + slide + ", ")
5120
- + "clip-path " + slide + ", opacity " + (panelAway ? "0.14s ease 0.26s" : "0.45s ease");
5754
+ + "clip-path " + slide + ", box-shadow " + slide + ", opacity "
5755
+ // the fade is short on both legs: the tuck line and the emerge line read
5756
+ // as the SAME hard line — a slow fade-in made the panel hover out of thin
5757
+ // air instead of appearing from behind that line (0.45s was the softer
5758
+ // entrance, and the one thing still different between the two directions)
5759
+ + (panelAway ? "0.14s ease 0.26s" : "0.16s ease");
5121
5760
 
5122
5761
  // clip the panel at the dock edge while collapsed, so sliding away looks
5123
5762
  // like being covered by the sidebar (the sidebar stays untouched):
@@ -5134,6 +5773,11 @@ window.__ModuleLoader__.load({
5134
5773
  // against a `panelW + 16` travel the edge drifted 8px past that line, which the
5135
5774
  // reader saw as the panel tucking in slightly left of where it comes back out.
5136
5775
  var travelPx = Math.abs(openOffset - parkedOffset);
5776
+ // The wipe inset is the TRAVEL distance (see below): its edge must sit exactly
5777
+ // on the panel's open line through the whole slide. It stays a zero inset while
5778
+ // open — the outer shadow does NOT ride this clip (a negative bleed here made
5779
+ // the wipe edge drift 24px through the slide); the shadow lives on the panel's
5780
+ // twin element instead.
5137
5781
  var panelClip = "inset(0 0 0 0px)";
5138
5782
  if (!geoOpen) {
5139
5783
  if (dockRight) {
@@ -5149,7 +5793,13 @@ window.__ModuleLoader__.load({
5149
5793
  // readable whenever the pointer was elsewhere. Readability now comes from
5150
5794
  // CONTRAST on the group being read (tinted row + accent bar, see
5151
5795
  // renderGroups) instead of from fading everything else away.
5152
- var panelOpacity = open && chatViewActive ? (hovered ? 1 : 0.72) : 0;
5796
+ //
5797
+ // NOTE the open/close legs do NOT fade: visibility past the tuck line is the
5798
+ // clip's call alone ("once it crosses the line it is gone"), and an opacity
5799
+ // ramp over the slide only made the two directions read different lines.
5800
+ // While parked the wipe covers the box entirely, so the resting opacity can
5801
+ // simply stay up.
5802
+ var panelOpacity = chatViewActive ? (hovered ? 1 : 0.72) : 0;
5153
5803
 
5154
5804
  // ---- toolbar buttons -----------------------------------------------------
5155
5805
  // ONE instance of each, placed either in the docked header row or in the
@@ -5205,55 +5855,40 @@ window.__ModuleLoader__.load({
5205
5855
  }
5206
5856
  return react_jsx_runtime.jsx("button", props);
5207
5857
  };
5208
- // heading-level filter: a round icon button that pops down the H1–H6 picker.
5209
- // Icon: three lines of decreasing width = outline levels.
5210
- var levelsBtnEl = toolBtn("levels", "dqt-levels-btn",
5211
- function () { if (questionsOnly) return; levelsOpen ? closeLevels() : setLevelsOpen(true); },
5212
- questionsOnly ? T("levels.offInQuestions") : T("levels.tip"),
5213
- (levelsOpen || levelsClosing), questionsOnly,
5858
+ // This plugin's own settings: the Host's own Plugins-entry pinwheel —
5859
+ // copied path for path from dsh-fonttune's settings button (its 16-unit
5860
+ // box, 1.8 stroke, currentColor) — and the SAME jump it performs: open the
5861
+ // host's settings on THIS plugin's page (see openSettingsScreen).
5862
+ var settingsBtnEl = toolBtn("settings", "dqt-settings-btn",
5863
+ function () {
5864
+ openSettingsScreen("panel head", function (tag, detail) {
5865
+ try {
5866
+ if (typeof console !== "undefined" && typeof console.warn === "function") {
5867
+ console.warn("[dsh-quick-toc] could not open the plugin settings (" + tag + ": " + detail + ")");
5868
+ }
5869
+ } catch (e) { /* a host without a console still gets the banner below */ }
5870
+ showBanner(T("settings.missing"));
5871
+ });
5872
+ },
5873
+ T("settings.open"), false, false,
5214
5874
  react_jsx_runtime.jsx("svg", {
5215
- width: tbIcon,
5216
- height: tbIcon,
5217
- viewBox: "0 0 14 14",
5875
+ width: tbIconLg,
5876
+ height: tbIconLg,
5877
+ viewBox: "0 0 16 16",
5218
5878
  fill: "none",
5219
5879
  stroke: "currentColor",
5220
- strokeWidth: 2.2,
5221
- strokeLinecap: "round",
5880
+ strokeWidth: 1.8,
5222
5881
  style: { display: "block" },
5223
5882
  children: [
5224
- react_jsx_runtime.jsx("line", { x1: 1.5, y1: 3, x2: 12.5, y2: 3 }),
5225
- react_jsx_runtime.jsx("line", { x1: 1.5, y1: 7, x2: 9, y2: 7 }),
5226
- react_jsx_runtime.jsx("line", { x1: 1.5, y1: 11, x2: 5.5, y2: 11 })
5883
+ react_jsx_runtime.jsx("path", { d: "M7.84457 5.06199C11.6605 4.93876 14.7962 6.14848 14.8484 7.76397C14.8875 8.97461 13.1838 10.0696 10.7215 10.5942" }),
5884
+ react_jsx_runtime.jsx("path", { d: "M5.12742 8.07731C5.00419 4.26138 6.21391 1.12568 7.8294 1.07351C9.04004 1.03441 10.135 2.73808 10.6596 5.20037" }),
5885
+ react_jsx_runtime.jsx("path", { d: "M8.02457 10.6802C4.20865 10.8034 1.07294 9.5937 1.02077 7.97821C0.981678 6.76758 2.68535 5.67262 5.14763 5.14798" }),
5886
+ react_jsx_runtime.jsx("path", { d: "M10.7476 7.89535C10.8708 11.7113 9.66109 14.847 8.0456 14.8991C6.83496 14.9382 5.74 13.2346 5.21536 10.7723" })
5227
5887
  ]
5228
5888
  })
5229
5889
  );
5230
- // questions-only toggle: icon = a speech bubble (the prompt), which is exactly
5231
- // what that view keeps
5232
- var questionsBtnEl = toolBtn("questions", "dqt-questions-btn", toggleQuestions,
5233
- questionsOnly ? T("view.questions.tipOn") : T("view.questions.tipOff"),
5234
- questionsOnly, false,
5235
- react_jsx_runtime.jsx("svg", {
5236
- width: tbIcon,
5237
- height: tbIcon,
5238
- viewBox: "0 0 14 14",
5239
- fill: "none",
5240
- stroke: "currentColor",
5241
- strokeWidth: 2.2,
5242
- strokeLinecap: "round",
5243
- strokeLinejoin: "round",
5244
- style: { display: "block" },
5245
- children: [
5246
- // A slightly LARGER bubble (11.4 x 7.7), and the whole glyph sits a touch BELOW the
5247
- // box centre on purpose: the bubble body is the visual mass while the tail hangs
5248
- // off the bottom-left, so a geometrically centred bubble reads as top-heavy. The
5249
- // stroke bbox is 1.4..13.8 (centre 7.6, i.e. 0.6 low) and the horizontal centre is
5250
- // still exactly 7.
5251
- react_jsx_runtime.jsx("rect", { x: 1.3, y: 2.5, width: 11.4, height: 7.7, rx: 1.7 }),
5252
- react_jsx_runtime.jsx("path", { d: "M4.5 10.2v2.5l2.9-2.5" })
5253
- ]
5254
- }),
5255
- { "data-questions": questionsOnly ? "on" : "off" }
5256
- );
5890
+ // questions-only lives in the SEARCH ROW now (its 提问 filter pill): the
5891
+ // toolbar lost the standalone toggle when the search grew the same job.
5257
5892
  // magnifier (SVG, matches the other buttons' style)
5258
5893
  var searchBtnEl = toolBtn("search", "dqt-search-btn",
5259
5894
  function () { searchOpen ? closeSearch() : openSearch(); },
@@ -5297,69 +5932,8 @@ window.__ModuleLoader__.load({
5297
5932
  ]
5298
5933
  })
5299
5934
  );
5300
- // heading-level picker: pops down from the round toolbar button. It is anchored
5301
- // to the toolbar itself on the curtain (no overflow-clipped row there) and to
5302
- // the panel box in the docked layout (the header row scrolls sideways, so a
5303
- // child of it would be clipped). Open/close animate from the button's center;
5304
- // the animation lives in the injected stylesheet (classes, not inline
5305
- // animation) so React re-renders never restart or cancel it.
5306
- var buildLevelsPop = function (inSheet) {
5307
- // on the curtain the six levels are ONE spread-out row (there is room);
5308
- // in the narrow docked panel they have to wrap, and they are capped by the
5309
- // panel's own width rather than by the shrink-to-fit toolbar wrapper
5310
- var popW = inSheet ? "min(336px, 74vw)" : undefined;
5311
- return react_jsx_runtime.jsx("div", {
5312
- className: "dqt-levels-pop" + (levelsClosing ? " dqt-levels-pop-closing" : ""),
5313
- style: {
5314
- position: "absolute",
5315
- top: inSheet ? "calc(100% + 8px)" : 40,
5316
- left: inSheet ? 0 : 10,
5317
- zIndex: 30,
5318
- display: "flex",
5319
- alignItems: "center",
5320
- flexWrap: inSheet ? "nowrap" : "wrap",
5321
- gap: 4,
5322
- padding: 8,
5323
- background: C.panelBg,
5324
- border: "1px solid " + C.panelBorder,
5325
- borderRadius: 10,
5326
- cornerShape: "round",
5327
- boxShadow: "0 6px 20px rgba(0, 0, 0, 0.28), 0 2px 6px rgba(0, 0, 0, 0.18)",
5328
- width: popW,
5329
- maxWidth: inSheet ? popW : "calc(100% - 20px)",
5330
- // the button's center sits 12px (dock) / 16px (curtain) inside the popup
5331
- transformOrigin: (inSheet ? tbSize / 2 : 12) + "px top"
5332
- },
5333
- children: [1, 2, 3, 4, 5, 6].map(function (lv) {
5334
- var active = !!levelSet[lv];
5335
- return react_jsx_runtime.jsx("button", {
5336
- onClick: function () { toggleLevel(lv); },
5337
- title: active ? T("levels.hide") + lv : T("levels.show") + lv,
5338
- style: {
5339
- height: inSheet ? 32 : 22,
5340
- flex: inSheet ? "1 1 0" : "0 0 auto",
5341
- minWidth: 0,
5342
- padding: inSheet ? 0 : "0 7px",
5343
- border: "none",
5344
- borderRadius: 6,
5345
- cornerShape: "round",
5346
- cursor: "pointer",
5347
- fontSize: inSheet ? 14.5 : 11,
5348
- lineHeight: (inSheet ? 32 : 22) + "px",
5349
- fontFamily: "inherit",
5350
- background: active
5351
- ? C.chip
5352
- : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
5353
- color: active ? C.accent : C.muted,
5354
- fontWeight: active ? 600 : 400
5355
- },
5356
- children: "H" + lv
5357
- }, "lv" + lv);
5358
- })
5359
- });
5360
- };
5361
- var levelsPopDockEl = (!sheetOpen && (levelsOpen || levelsClosing)) ? buildLevelsPop(false) : null;
5362
- var levelsPopSheetEl = (sheetOpen && (levelsOpen || levelsClosing)) ? buildLevelsPop(true) : null;
5935
+ // (the heading-level picker popup went with the toolbar's level button: the
5936
+ // levels are a setting now, and the settings button lands on that row)
5363
5937
  // dock toggle: the triangle tips toward the side it will move TO. Drawn as a path rather
5364
5938
  // than the ◀/▶ character — a font's triangles have sharp corners and a weight that
5365
5939
  // differs per platform, while this one carries the same little rounding as the two
@@ -5381,57 +5955,9 @@ window.__ModuleLoader__.load({
5381
5955
  // is what makes it read centred (the old character needed the same, plus a y nudge for
5382
5956
  // its line box, which a path does not have)
5383
5957
  var dockBtnEl = iconBtn(toggleDock, dockRight ? T("handle.dockLeft") : T("handle.dockRight"), dockTriEl, 12, dockRight ? { x: -1, y: 0 } : { x: 1, y: 0 });
5384
- // the curtain button: a surface with the arrow dropping out of its TOP edge
5385
- var sheetBtnEl = react_jsx_runtime.jsx("button", {
5386
- className: "dqt-sheet-btn",
5387
- onClick: openSheet,
5388
- title: T("sheet.open"),
5389
- style: {
5390
- width: 24,
5391
- height: 24,
5392
- padding: 0,
5393
- display: "flex",
5394
- alignItems: "center",
5395
- justifyContent: "center",
5396
- borderRadius: "50%",
5397
- cornerShape: "round",
5398
- border: "none",
5399
- flex: "none",
5400
- cursor: "pointer",
5401
- background: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
5402
- color: C.muted,
5403
- transition: "background 0.15s ease, color 0.15s ease, opacity 0.2s ease"
5404
- },
5405
- onMouseEnter: function (e) {
5406
- e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-active, rgba(79,140,255,0.24))";
5407
- e.currentTarget.style.color = "var(--dsw-alias-brand-primary, #4f8cff)";
5408
- },
5409
- onMouseLeave: function (e) {
5410
- e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
5411
- e.currentTarget.style.color = C.muted;
5412
- },
5413
- children: react_jsx_runtime.jsx("svg", {
5414
- width: 14,
5415
- height: 14,
5416
- viewBox: "0 0 14 14",
5417
- fill: "none",
5418
- stroke: "currentColor",
5419
- strokeWidth: 1.8,
5420
- strokeLinecap: "round",
5421
- strokeLinejoin: "round",
5422
- style: { display: "block", overflow: "visible" },
5423
- children: [
5424
- // A landscape surface 12.0 x 9.0 (centred in the 14 box: 1.0..13.0 / 2.5..11.5) —
5425
- // neither the portrait slab nor the flat strip the earlier attempts were. The
5426
- // rectangle itself carries the SAME border weight as the questions bubble (2.2,
5427
- // via its own strokeWidth) while the arrow keeps the lighter inherited 1.8, so the
5428
- // frame reads as the frame and the arrow stays a mark inside it. `overflow:
5429
- // visible` because the 2.2 border on a 12-wide rect reaches x = -0.1.
5430
- react_jsx_runtime.jsx("rect", { x: 1.0, y: 2.5, width: 12.0, height: 9.0, rx: 1.6, strokeWidth: 2.2 }),
5431
- react_jsx_runtime.jsx("path", { d: "M7 2.5V9.1M5.2 7.3L7 9.1l1.8-1.8" })
5432
- ]
5433
- })
5434
- });
5958
+ // the curtain keeps its own entry point: the water-drop handle on the top
5959
+ // edge (sheet.openHandle). The toolbar's curtain button is gone — the header
5960
+ // is four controls now.
5435
5961
  // collapse (docked panel only): thick SVG cross, nudged slightly down
5436
5962
  var headerCloseBtnEl = react_jsx_runtime.jsx("button", {
5437
5963
  onClick: function () { noteClosed("dock"); setOpen(false); },
@@ -5522,20 +6048,22 @@ window.__ModuleLoader__.load({
5522
6048
  scrollbarWidth: "none"
5523
6049
  },
5524
6050
  children: [
5525
- // LEFT pair: the heading-level filter and the dock toggle take the
5526
- // left end — so the header reads two controls per side (search +
5527
- // collapse sit at the other end).
6051
+ // LEFT pair: this plugin's settings jump and the dock toggle take
6052
+ // the left end (search + collapse sit at the other end). Four
6053
+ // controls on this bar now: the questions-only toggle moved into
6054
+ // the search row's scope and the curtain button left to the top
6055
+ // handle.
5528
6056
  react_jsx_runtime.jsx("div", {
5529
6057
  style: { display: "flex", alignItems: "center", gap: 6, flex: "none" },
5530
- children: [levelsBtnEl, questionsBtnEl, dockBtnEl]
6058
+ children: [settingsBtnEl, dockBtnEl]
5531
6059
  }),
5532
6060
  // spacer: nothing here, it just pins the two pairs to the two ends —
5533
6061
  // and it is the first thing to give way when the panel narrows
5534
6062
  react_jsx_runtime.jsx("div", { style: { flex: "1 1 auto", minWidth: 0 } }),
5535
- // RIGHT pair: search + curtain + collapse
6063
+ // RIGHT pair: search + collapse
5536
6064
  react_jsx_runtime.jsx("div", {
5537
6065
  style: { display: "flex", alignItems: "center", gap: 6, flex: "none" },
5538
- children: [searchBtnEl, sheetBtnEl, headerCloseBtnEl]
6066
+ children: [searchBtnEl, headerCloseBtnEl]
5539
6067
  })
5540
6068
  ]
5541
6069
  })
@@ -5543,10 +6071,11 @@ window.__ModuleLoader__.load({
5543
6071
  });
5544
6072
 
5545
6073
  // ---- curtain chrome: the controls that still apply here (the level filter,
5546
- // questions-only, search) plus the way out. There is no title — the surface is
6074
+ // search) plus the way out. There is no title — the surface is
5547
6075
  // self-evident — and no hint text: ✕ and Esc are the obvious exits. The dock /
5548
6076
  // curtain / collapse buttons are absent on purpose: there is no docked panel to
5549
- // move and no second curtain to open.
6077
+ // move and no second curtain to open. The questions-only toggle lives in the
6078
+ // search row's 提问 filter now.
5550
6079
  var sheetChromeEl = react_jsx_runtime.jsx("div", {
5551
6080
  className: "dqt-sheet-chrome",
5552
6081
  title: T("sheet.noDrag"),
@@ -5561,7 +6090,7 @@ window.__ModuleLoader__.load({
5561
6090
  children: [
5562
6091
  react_jsx_runtime.jsx("div", {
5563
6092
  style: { position: "relative", display: "flex", alignItems: "center", gap: 8, flex: "none" },
5564
- children: [levelsBtnEl, questionsBtnEl, levelsPopSheetEl]
6093
+ children: [settingsBtnEl]
5565
6094
  }),
5566
6095
  react_jsx_runtime.jsx("div", { style: { flex: "1 1 auto", minWidth: 0 } }),
5567
6096
  searchBtnEl,
@@ -5572,6 +6101,77 @@ window.__ModuleLoader__.load({
5572
6101
  // ---- search row: the input plus the scope pill, the fuzzy switch and the
5573
6102
  // match counter. In the docked layout it is a row under the header; on the
5574
6103
  // curtain it is the head of the search column on the right.
6104
+ // recent searches, offered back while the box is empty: one tap fills the
6105
+ // box (and restores the scope it was searched in); Enter still jumps.
6106
+ // mousedown is swallowed so the input keeps its focus.
6107
+ var searchHistStripEl = (searchOpen && query.trim() === "" && searchHist.length > 0) ? react_jsx_runtime.jsx("div", {
6108
+ "data-dqt-hist": "1",
6109
+ style: {
6110
+ display: "flex",
6111
+ alignItems: "center",
6112
+ gap: 6,
6113
+ // even vertical breathing room on both sides: the text used to hug the
6114
+ // search row above it, and the divider below the strip is the search
6115
+ // block's closing line (NOT a line between the row and its own strip)
6116
+ padding: sheetOpen ? "7px 10px 10px" : "6px 8px 8px",
6117
+ borderBottom: "1px solid " + C.panelBorder,
6118
+ minWidth: 0
6119
+ },
6120
+ children: [
6121
+ react_jsx_runtime.jsx("span", {
6122
+ style: { flex: "none", fontSize: 11, color: C.muted },
6123
+ children: T("search.history")
6124
+ }, "hh-label"),
6125
+ react_jsx_runtime.jsx("div", {
6126
+ style: { flex: "1 1 auto", minWidth: 0, display: "flex", flexWrap: "wrap", gap: 4 },
6127
+ children: searchHist.map(function (entry, hi) {
6128
+ return react_jsx_runtime.jsx("button", {
6129
+ type: "button",
6130
+ "data-dqt-hist-item": hi,
6131
+ title: entry.q,
6132
+ onMouseDown: function (e) { e.preventDefault(); },
6133
+ onClick: function () {
6134
+ if (entry.scope !== searchScope) { setPrevScope(searchScope); setSearchScope(entry.scope); }
6135
+ onQueryChange(entry.q);
6136
+ },
6137
+ style: {
6138
+ flex: "none",
6139
+ maxWidth: 140,
6140
+ overflow: "hidden",
6141
+ textOverflow: "ellipsis",
6142
+ whiteSpace: "nowrap",
6143
+ fontSize: 11,
6144
+ padding: "2px 8px",
6145
+ borderRadius: 999,
6146
+ cornerShape: "round",
6147
+ background: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
6148
+ color: C.text,
6149
+ border: "none",
6150
+ cursor: "pointer"
6151
+ },
6152
+ children: entry.q
6153
+ }, "hh-" + hi);
6154
+ })
6155
+ }, "hh-chips"),
6156
+ react_jsx_runtime.jsx("button", {
6157
+ type: "button",
6158
+ "data-dqt-hist-clear": "1",
6159
+ title: T("search.history.clear"),
6160
+ onMouseDown: function (e) { e.preventDefault(); },
6161
+ onClick: function () { setSearchHist(searchHistClear()); },
6162
+ style: {
6163
+ flex: "none",
6164
+ fontSize: 11,
6165
+ padding: "2px 4px",
6166
+ background: "transparent",
6167
+ color: C.muted,
6168
+ border: "none",
6169
+ cursor: "pointer"
6170
+ },
6171
+ children: T("search.history.clear")
6172
+ }, "hh-clear")
6173
+ ]
6174
+ }) : null;
5575
6175
  var searchRowEl = (searchOpen || searchAnim === "out") ? react_jsx_runtime.jsx("div", {
5576
6176
  style: {
5577
6177
  display: "grid",
@@ -5581,13 +6181,15 @@ window.__ModuleLoader__.load({
5581
6181
  },
5582
6182
  children: react_jsx_runtime.jsx("div", {
5583
6183
  style: { overflow: "hidden", minHeight: 0 },
5584
- children: react_jsx_runtime.jsx("div", {
6184
+ children: [react_jsx_runtime.jsx("div", {
5585
6185
  style: {
5586
6186
  display: "flex",
5587
6187
  alignItems: "center",
5588
6188
  gap: sheetOpen ? 6 : 4,
5589
6189
  padding: sheetOpen ? "4px 10px 8px" : "2px 8px 4px",
5590
- borderBottom: "1px solid " + C.panelBorder,
6190
+ // the closing line belongs to the strip when the history strip is
6191
+ // out: no divider between the search row and its own strip
6192
+ borderBottom: searchHistStripEl ? "none" : ("1px solid " + C.panelBorder),
5591
6193
  opacity: (searchAnim === "enter" || searchAnim === "out") ? 0 : 1,
5592
6194
  transform: (searchAnim === "enter" || searchAnim === "out") ? "translateY(-6px)" : "none",
5593
6195
  transition: "opacity 0.2s ease, transform 0.2s ease"
@@ -5617,7 +6219,12 @@ window.__ModuleLoader__.load({
5617
6219
  // (arrows only mean something while a query is typed)
5618
6220
  if (e.key === "ArrowDown") { goToMatch(matchIdx + 1); swallow(e); return; }
5619
6221
  if (e.key === "ArrowUp") { goToMatch(matchIdx - 1); swallow(e); return; }
5620
- if (e.key === "Enter") { goToMatch(matchIdx + 1); swallow(e); return; }
6222
+ // Enter = the reader committed this search: it joins the
6223
+ // recent-search history even when nothing matched
6224
+ if (e.key === "Enter") {
6225
+ if (query.trim() !== "") commitSearchHist(query.trim(), searchScope);
6226
+ goToMatch(matchIdx + 1); swallow(e); return;
6227
+ }
5621
6228
  if (e.key === "Escape") { closeSearch(); swallow(e); }
5622
6229
  },
5623
6230
  style: {
@@ -5691,7 +6298,8 @@ window.__ModuleLoader__.load({
5691
6298
  onClick: toggleScope,
5692
6299
  "data-scope": searchScope,
5693
6300
  title: searchScope === "title" ? T("search.scope.tipTitle")
5694
- : (searchScope === "full" ? T("search.scope.tipFull") : T("search.scope.tipCross")),
6301
+ : (searchScope === "prompts" ? T("search.scope.tipPrompts")
6302
+ : (searchScope === "full" ? T("search.scope.tipFull") : T("search.scope.tipCross"))),
5695
6303
  style: {
5696
6304
  flex: "none",
5697
6305
  width: sheetOpen ? 40 : 34,
@@ -5755,7 +6363,7 @@ window.__ModuleLoader__.load({
5755
6363
  children: T("search.fuzzy")
5756
6364
  })
5757
6365
  ]
5758
- })
6366
+ }), searchHistStripEl]
5759
6367
  })
5760
6368
  }) : null;
5761
6369
 
@@ -5888,6 +6496,66 @@ window.__ModuleLoader__.load({
5888
6496
  style: { flex: "1 1 auto", minHeight: 0, display: "flex", flexDirection: "row", alignItems: "stretch" },
5889
6497
  children: [listEl, sheetSearchColEl]
5890
6498
  });
6499
+ // one geometry for the panel and its shadow twin, merged (never duplicated)
6500
+ var mergeStyle = function (base, extra) {
6501
+ var out = {};
6502
+ var k;
6503
+ for (k in base) if (Object.prototype.hasOwnProperty.call(base, k)) out[k] = base[k];
6504
+ for (k in extra) if (Object.prototype.hasOwnProperty.call(extra, k)) out[k] = extra[k];
6505
+ return out;
6506
+ };
6507
+ var panelGeoStyle = {
6508
+ position: "fixed",
6509
+ top: zLen(baseTopPx),
6510
+ left: zLen(panelLeft),
6511
+ width: zLen(panelW),
6512
+ maxHeight: zLen(vMaxH),
6513
+ height: panelH > 0 ? zLen(panelH) : undefined,
6514
+ zoom: zoom === 1 ? undefined : zoom,
6515
+ borderRadius: 12,
6516
+ transform: panelTransform,
6517
+ transition: viewFading ? "opacity 0.3s ease" : panelTransition,
6518
+ // held back while the edge handle slips home (see edgeLeaving): the panel is
6519
+ // already at its open geometry here, only the animation waits
6520
+ transitionDelay: edgeLeaving ? EDGE_OUT_MS + "ms" : undefined
6521
+ };
6522
+ // The outer shadow lives on a transparent twin BEHIND the panel. On the panel
6523
+ // itself it could not survive the collapse clip, and the clip's inset must be
6524
+ // the TRAVEL distance for the visible edge to keep its line. The twin is
6525
+ // clipped by the SAME wipe — its cut sitting 8px out (that side's own reach),
6526
+ // so the halo spills exactly as far as it would around the visible shape and
6527
+ // follows that shape through the slide: no shadow left standing past the tuck
6528
+ // line while the panel has gone, and no cut at the edges.
6529
+ //
6530
+ // The three sides the wipe never touches bleed far out instead (24px covers
6531
+ // the deepest reach — 15px below with the offset, more at the bottom corners),
6532
+ // so nothing of the halo is sliced at rest either.
6533
+ //
6534
+ // One halo on ALL four sides — evenly, corners included (a card lifted off
6535
+ // the conversation; no downward bias, or the top corners stay bare). Dark
6536
+ // scheme gets a WHITE one — the theme's own raised language.
6537
+ // the wipe side's cut sits exactly on the seam the panel tucks behind: past
6538
+ // that line there is nothing to see — panel or halo — the clip decides,
6539
+ // never a fade
6540
+ var SHADOW_REACH = 8;
6541
+ var SHADOW_BLEED = 24;
6542
+ var shadowWipe = geoOpen ? 0 : travelPx;
6543
+ var shadowClip = dockRight
6544
+ ? "inset(" + zLen(-SHADOW_BLEED) + " " + zLen(shadowWipe - SHADOW_REACH) + " " + zLen(-SHADOW_BLEED) + " " + zLen(-SHADOW_BLEED) + ")"
6545
+ : "inset(" + zLen(-SHADOW_BLEED) + " " + zLen(-SHADOW_BLEED) + " " + zLen(-SHADOW_BLEED) + " " + zLen(shadowWipe - SHADOW_REACH) + ")";
6546
+ var panelShadowEl = react_jsx_runtime.jsx("div", {
6547
+ "data-dqt-panel-shadow": "1",
6548
+ style: mergeStyle(panelGeoStyle, {
6549
+ zIndex: Z_PANEL - 1,
6550
+ background: "transparent",
6551
+ opacity: panelOpacity,
6552
+ pointerEvents: "none",
6553
+ clipPath: shadowClip,
6554
+ boxShadow: isDarkScheme()
6555
+ ? "0 0 24px -4px rgba(255, 255, 255, 0.16), 0 0 10px -2px rgba(255, 255, 255, 0.10)"
6556
+ : "0 0 24px -4px rgba(0, 0, 0, 0.38), 0 0 10px -2px rgba(0, 0, 0, 0.26)"
6557
+ })
6558
+ });
5891
6559
  var panelEl = react_jsx_runtime.jsx("div", {
5892
6560
  ref: panelRootRef,
5893
6561
  // the panel takes focus on any click inside it (except into a text field), so
@@ -5922,37 +6590,18 @@ window.__ModuleLoader__.load({
5922
6590
  // the curtain's own scale (the docked panel's does not apply here)
5923
6591
  zoom: sheetZoom === 1 ? undefined : sheetZoom,
5924
6592
  pointerEvents: chatViewActive ? "auto" : "none"
5925
- } : {
5926
- position: "fixed",
5927
- top: zLen(baseTopPx),
5928
- left: zLen(panelLeft),
5929
- width: zLen(panelW),
5930
- maxHeight: zLen(vMaxH),
5931
- height: panelH > 0 ? zLen(panelH) : undefined,
5932
- zoom: zoom === 1 ? undefined : zoom,
6593
+ } : mergeStyle(panelGeoStyle, {
5933
6594
  display: "flex",
5934
6595
  flexDirection: "column",
5935
6596
  background: C.panelBg,
5936
6597
  border: "none",
5937
- borderRadius: 12,
5938
6598
  zIndex: Z_PANEL,
5939
6599
  overflow: "hidden",
5940
6600
  color: C.text,
5941
6601
  opacity: panelOpacity,
5942
6602
  clipPath: panelClip,
5943
- transform: panelTransform,
5944
- // inner shadow only (no outer): theme-aware (white bevel in dark mode,
5945
- // soft shade in light) — outer shadow would be clipped by the collapse
5946
- // clip-path and the panel edges.
5947
- boxShadow: innerShadow(),
5948
- // A view switch swaps in a short, delay-free opacity fade; the dock and
5949
- // collapse animations keep their own (slower) timings.
5950
- transition: viewFading ? "opacity 0.3s ease" : panelTransition,
5951
- // held back while the edge handle slips home (see edgeLeaving): the panel is
5952
- // already at its open geometry here, only the animation waits
5953
- transitionDelay: edgeLeaving ? EDGE_OUT_MS + "ms" : undefined,
5954
6603
  pointerEvents: chatViewActive ? "auto" : "none"
5955
- },
6604
+ }),
5956
6605
  onMouseEnter: function () { setHovered(true); },
5957
6606
  onMouseLeave: function () { setHovered(false); },
5958
6607
  onClickCapture: function (e) {
@@ -6180,8 +6829,7 @@ window.__ModuleLoader__.load({
6180
6829
  },
6181
6830
  onPointerDown: onResizeCornerDown,
6182
6831
  title: T("resize.wh")
6183
- }),
6184
- levelsPopDockEl
6832
+ })
6185
6833
  ]
6186
6834
  });
6187
6835
 
@@ -6285,6 +6933,14 @@ window.__ModuleLoader__.load({
6285
6933
  var handleH = sheetStrip ? Math.max(16, Math.min(HANDLE_BOX_MAX, stripH)) : HANDLE_BOX_MAX;
6286
6934
  var handleTopPx = sheetStrip ? sheetStrip.bottom - handleH : sheetTopPx;
6287
6935
 
6936
+ // The curtain runs edge to edge of its area: the conversation column sits a
6937
+ // couple of pixels inside the window (the host's own outer padding), and that
6938
+ // sliver used to read as a gap beside the curtain. An inset this small IS the
6939
+ // padding — it bleeds under the curtain on either side; a real column (the
6940
+ // sidebar, the docked panel) stays respected.
6941
+ var SHEET_EDGE_PAD_MAX = 12;
6942
+ var sheetInset = function (px) { return px <= SHEET_EDGE_PAD_MAX ? 0 : px; };
6943
+
6288
6944
  // ---- curtain handle resting on the view tab strip's line -----------------
6289
6945
  // A droplet held to the line by surface tension rather than a floating pill: the
6290
6946
  // silhouette flares out into two concave fillets that meet the strip's lower line
@@ -6323,7 +6979,7 @@ window.__ModuleLoader__.load({
6323
6979
  // if the stored area rect is one layout generation behind
6324
6980
  left: Math.max(8, Math.min(Math.max(8, window.innerWidth - DROP_W - 8),
6325
6981
  viewport
6326
- ? viewport.left + (Math.max(320, window.innerWidth - viewport.left - viewport.right) - DROP_W) / 2
6982
+ ? sheetInset(viewport.left) + (Math.max(320, window.innerWidth - sheetInset(viewport.left) - sheetInset(viewport.right)) - DROP_W) / 2
6327
6983
  : (window.innerWidth - DROP_W) / 2)) + "px"
6328
6984
  },
6329
6985
  title: T("sheet.openHandle"),
@@ -6610,6 +7266,9 @@ window.__ModuleLoader__.load({
6610
7266
  // The outer box never unmounts: while the curtain is down it is a zero-size,
6611
7267
  // non-clipping pass-through, so the panel (and the whole list DOM) is never
6612
7268
  // moved or rebuilt when the curtain opens or closes.
7269
+ // How far the clip box extends BELOW the curtain: that is where the curtain's
7270
+ // bottom shadow lives (the clip would otherwise cut it off).
7271
+ var SHEET_SHADOW_ROOM = 24;
6613
7272
  var sheetUp = sheetOpen && chatViewActive;
6614
7273
  var sheetShellEl = react_jsx_runtime.jsx("div", {
6615
7274
  key: "sheet-shell",
@@ -6619,15 +7278,16 @@ window.__ModuleLoader__.load({
6619
7278
  position: "fixed",
6620
7279
  // hangs from just below the 对话/轨迹/上下文 strip
6621
7280
  top: sheetTopPx,
6622
- left: viewport ? viewport.left : 0,
6623
- width: viewport ? Math.max(320, window.innerWidth - viewport.left - viewport.right) : "100%",
7281
+ left: viewport ? sheetInset(viewport.left) : 0,
7282
+ width: viewport ? Math.max(320, window.innerWidth - sheetInset(viewport.left) - sheetInset(viewport.right)) : "100%",
6624
7283
  // a curtain over the conversation, not the whole screen: the composer and
6625
7284
  // the newest message stay reachable below it. The height is measured from
6626
7285
  // the conversation box (not from the curtain's own top) so hanging it below
6627
- // the tabs does not push its bottom edge down over the composer.
7286
+ // the tabs does not push its bottom edge down over the composer. The clip
7287
+ // then grows by the shadow room below the curtain itself.
6628
7288
  height: viewport
6629
- ? Math.max(240, Math.min(760, (viewport.height - Math.max(0, sheetTopPx - viewport.top)) * 0.82))
6630
- : "70vh",
7289
+ ? Math.max(240, Math.min(760, (viewport.height - Math.max(0, sheetTopPx - viewport.top)) * 0.82)) + SHEET_SHADOW_ROOM
7290
+ : "calc(70vh + " + SHEET_SHADOW_ROOM + "px)",
6631
7291
  overflow: "hidden",
6632
7292
  zIndex: Z_SHEET
6633
7293
  } : {
@@ -6666,19 +7326,30 @@ window.__ModuleLoader__.load({
6666
7326
  "data-sheet": sheetUp ? "on" : undefined,
6667
7327
  style: {
6668
7328
  width: sheetUp ? "100%" : 0,
6669
- height: sheetUp ? "100%" : 0,
7329
+ // the bottom leaves the shadow room the clip box grew by
7330
+ height: sheetUp ? "calc(100% - " + SHEET_SHADOW_ROOM + "px)" : 0,
7331
+ // border-box so the 1px edges stay INSIDE the measured height: with the
7332
+ // old content-box the bottom pair sat below it and the clip cut them
7333
+ // away, which is why the bottom edge had no visible line
7334
+ boxSizing: "border-box",
6670
7335
  display: "flex",
6671
7336
  flexDirection: "column",
6672
7337
  background: C.panelBg,
6673
7338
  color: C.text,
6674
- border: sheetUp ? "1px solid " + C.panelBorder : "none",
7339
+ border: "none",
7340
+ borderTop: sheetUp ? "1px solid " + C.panelBorder : "none",
7341
+ borderLeft: sheetUp ? "1px solid " + C.panelBorder : "none",
7342
+ borderRight: sheetUp ? "1px solid " + C.panelBorder : "none",
7343
+ borderBottom: sheetUp ? "1px solid " + C.sheetEdge : "none",
6675
7344
  // square where it meets the top edge, since the curtain hangs from there;
6676
- // no dimming layer and no drop shadow either — the conversation below
6677
- // stays legible and usable, and the curtain announces itself by flying
7345
+ // no dimming layer — the conversation below stays legible and usable.
7346
+ // The outer shadow is the bottom edge's own: it follows the rounded
7347
+ // bottom corners and stops before the vertical seams.
6678
7348
  borderRadius: "0 0 12px 12px",
7349
+ boxShadow: sheetUp ? C.sheetShadow : "none",
6679
7350
  overflow: "hidden"
6680
7351
  },
6681
- children: panelEl
7352
+ children: sheetOpen ? panelEl : [panelShadowEl, panelEl]
6682
7353
  })
6683
7354
  });
6684
7355
 
@@ -6695,13 +7366,18 @@ window.__ModuleLoader__.load({
6695
7366
  // stays hidden until the row is hovered — the row's own mouseenter/leave
6696
7367
  // reveal it — and the click is handled once, in the panel's capture phase,
6697
7368
  // so the row's jump-to-start can never fire alongside it.
6698
- function renderEndBtn(C, key, idx, turn) {
7369
+ function renderEndBtn(C, key, idx, turn, small) {
6699
7370
  // These markers are not decoration: the row's hover handler looks for
6700
7371
  // [data-dqt-end] to reveal the control, and the panel's capture phase looks
6701
7372
  // for it to route the click to the end jump instead of the row's own
6702
7373
  // jump-to-start. They are deliberately NOT data-jump-key: that attribute
6703
7374
  // belongs to the rows themselves, and two kinds of element claiming it made
6704
7375
  // every "find the row" query ambiguous.
7376
+ // `small` = compact density: the tight rows are 16px tall and clip their
7377
+ // contents, so a 20px circle would have its backdrop cut top and bottom —
7378
+ // the control scales with the row it lives in.
7379
+ var size = small ? 16 : 20;
7380
+ var icon = small ? 10 : 12;
6705
7381
  var attrs = { "data-dqt-end": "1" };
6706
7382
  if (key) attrs["data-end-key"] = key;
6707
7383
  if (idx !== undefined && idx !== null) attrs["data-end-idx"] = String(idx);
@@ -6723,8 +7399,8 @@ window.__ModuleLoader__.load({
6723
7399
  right: 4,
6724
7400
  top: "50%",
6725
7401
  transform: "translateY(-50%)",
6726
- width: 20,
6727
- height: 20,
7402
+ width: size,
7403
+ height: size,
6728
7404
  display: "flex",
6729
7405
  alignItems: "center",
6730
7406
  justifyContent: "center",
@@ -6741,8 +7417,8 @@ window.__ModuleLoader__.load({
6741
7417
  transition: "opacity 0.22s ease, background 0.18s ease, color 0.18s ease"
6742
7418
  },
6743
7419
  children: react_jsx_runtime.jsx("svg", {
6744
- width: 12,
6745
- height: 12,
7420
+ width: icon,
7421
+ height: icon,
6746
7422
  viewBox: "0 0 12 12",
6747
7423
  style: { display: "block" },
6748
7424
  children: react_jsx_runtime.jsx("path", {
@@ -6765,6 +7441,7 @@ window.__ModuleLoader__.load({
6765
7441
  // the title, and the section's opening text out to the right — the space the
6766
7442
  // docked panel does not have. The narrow panel keeps title-over-subtitle.
6767
7443
  var wide = !!(extra && extra.wide);
7444
+ var dense = !!(extra && extra.dense);
6768
7445
  var hoverInfo = {
6769
7446
  title: n.title,
6770
7447
  sub: n.sub || "",
@@ -6821,8 +7498,8 @@ window.__ModuleLoader__.load({
6821
7498
  if (n.sub) {
6822
7499
  rows.push(react_jsx_runtime.jsx("div", {
6823
7500
  style: {
6824
- fontSize: 10,
6825
- lineHeight: "13px",
7501
+ fontSize: dense ? 9.5 : 10,
7502
+ lineHeight: dense ? "11px" : "13px",
6826
7503
  color: C.muted,
6827
7504
  opacity: 0.75,
6828
7505
  whiteSpace: "nowrap",
@@ -6836,7 +7513,7 @@ window.__ModuleLoader__.load({
6836
7513
  // the section's own "jump to its end" control, revealed while the row is
6837
7514
  // hovered; the reserved right padding keeps the title/subtitle/context
6838
7515
  // clear of it so nothing ever runs underneath the button
6839
- rows.push(renderEndBtn(C, n.key, n.idx));
7516
+ rows.push(renderEndBtn(C, n.key, n.idx, undefined, dense && !wide));
6840
7517
  // The hover highlight is its own layer rather than the row's own background:
6841
7518
  // the row box runs to its right edge, which is exactly where the end control's
6842
7519
  // round backdrop appears, so the two boxes used to sit flush against each other
@@ -6886,16 +7563,16 @@ window.__ModuleLoader__.load({
6886
7563
  // (narrow layout) — in wide mode it is one line by construction
6887
7564
  display: "block",
6888
7565
  position: "relative",
6889
- padding: wide ? "4px 8px" : "2px 6px",
7566
+ padding: dense ? (wide ? "1px 8px" : "0px 6px") : wide ? "4px 8px" : "2px 6px",
6890
7567
  paddingRight: END_ZONE,
6891
7568
  paddingLeft: (hasChildren ? (wide ? 4 : 2) : (wide ? 8 : 6)) + (n.level - 1) * (wide ? 16 : 12),
6892
- margin: "1px 0",
7569
+ margin: dense ? "0 0" : "1px 0",
6893
7570
  borderRadius: 6,
6894
7571
  cursor: "pointer",
6895
7572
  fontSize: n.level <= 2 ? (wide ? 16.5 : 13) : (wide ? 15.5 : 12),
6896
7573
  color: n.level <= 2 ? C.text : C.muted,
6897
7574
  fontWeight: n.level <= 2 ? 600 : 400,
6898
- lineHeight: wide ? "26px" : "18px",
7575
+ lineHeight: dense ? (wide ? "23px" : "16px") : wide ? "26px" : "18px",
6899
7576
  whiteSpace: "nowrap",
6900
7577
  overflow: "hidden"
6901
7578
  },
@@ -7150,6 +7827,8 @@ window.__ModuleLoader__.load({
7150
7827
  // either way, and every header in a given view still shares ONE style.
7151
7828
  var big = !!(extra && (extra.questionsOnly || extra.wide));
7152
7829
  var wideBand = !!(extra && extra.wide && !extra.questionsOnly);
7830
+ // compact density: the same one-line header, a couple of notches tighter
7831
+ var dense = !!(extra && extra.dense);
7153
7832
  // a turn that ended in a failure has no reply to land on: its terminal row IS
7154
7833
  // the interesting position, so the header lands there (and says so in the tip)
7155
7834
  var replyKey = (g.msgs && g.msgs.length > 0) ? g.msgs[0].key : (g.failure ? g.failure.key : "");
@@ -7179,14 +7858,15 @@ window.__ModuleLoader__.load({
7179
7858
  // headings: same box, same 18px rhythm, same left inset, so the time
7180
7859
  // column of a heading-less turn lines up with all the others. Only the
7181
7860
  // type emphasis differs (weight + colour, see the label below).
7182
- padding: big ? (wideBand ? "3px 4px 4px" : "2px 4px 3px") : "1px 4px 2px",
7861
+ padding: dense ? (big ? (wideBand ? "1px 4px 2px" : "0px 4px 1px") : "0px 4px 1px")
7862
+ : big ? (wideBand ? "3px 4px 4px" : "2px 4px 3px") : "1px 4px 2px",
7183
7863
  // the label is capped by this, not by a maxWidth guess: the row reserves the
7184
7864
  // end control's zone, so a long prompt's hover box ends END_ZONE from the row
7185
7865
  // edge — a real gap before the button's round backdrop, which sits 24px in.
7186
7866
  // (With `max-width: calc(100% - 32px)` the percentage resolved somewhere else
7187
7867
  // and a forced-long header still came within 2px of the circle.)
7188
7868
  paddingRight: END_ZONE,
7189
- height: big ? (wideBand ? 24 : 22) : 18,
7869
+ height: dense ? (big ? (wideBand ? 21 : 19) : 15) : big ? (wideBand ? 24 : 22) : 18,
7190
7870
  display: "flex",
7191
7871
  alignItems: "center",
7192
7872
  minWidth: 0,
@@ -7229,6 +7909,12 @@ window.__ModuleLoader__.load({
7229
7909
  // on the group box — never on the label.
7230
7910
  fontSize: big ? (wideBand ? 14 : 12.5) : 11,
7231
7911
  fontWeight: 400,
7912
+ // Compact density fixes the header's HEIGHT, so the label's line box
7913
+ // must fit inside it: the inherited line-height left the label taller
7914
+ // than its own row, and every background riding on it (the current
7915
+ // group's tint, the hover grey, the keyboard cursor) was cut to the
7916
+ // shorter band. Comfortable density keeps the inherited rhythm.
7917
+ lineHeight: dense ? (big ? (wideBand ? "17px" : "14px") : "13px") : undefined,
7232
7918
  color: big ? C.text : C.muted,
7233
7919
  cursor: "pointer",
7234
7920
  padding: big ? "1px 5px" : "1px 5px",
@@ -7259,7 +7945,7 @@ window.__ModuleLoader__.load({
7259
7945
  }) : null
7260
7946
  ]
7261
7947
  }),
7262
- renderEndBtn(C, lastKey, undefined, g.turn)
7948
+ renderEndBtn(C, lastKey, undefined, g.turn, dense && !wideBand)
7263
7949
  ]
7264
7950
  }, "g-h-" + gi);
7265
7951
  }
@@ -7316,8 +8002,8 @@ window.__ModuleLoader__.load({
7316
8002
  "data-nav-row": "1",
7317
8003
  title: T("turn.loadTip"),
7318
8004
  style: {
7319
- padding: "3px 6px",
7320
- margin: "1px 0",
8005
+ padding: extra.dense ? "1px 6px" : "3px 6px",
8006
+ margin: extra.dense ? "0 0" : "1px 0",
7321
8007
  borderRadius: 6,
7322
8008
  cursor: "pointer",
7323
8009
  minWidth: 0,
@@ -7332,8 +8018,9 @@ window.__ModuleLoader__.load({
7332
8018
  // outline lists it as a normal turn and adds this row, so a turn whose request
7333
8019
  // never came back reads as "请求失败 · <provider message>" instead of "未加载"
7334
8020
  // and never silently disappears from the outline.
7335
- function renderFailureRow(g, gi, jump, C) {
8021
+ function renderFailureRow(g, gi, jump, C, extra) {
7336
8022
  var f = g.failure;
8023
+ var dense = !!(extra && extra.dense);
7337
8024
  var message = f.message || f.code || "";
7338
8025
  var open = function (e) {
7339
8026
  e.stopPropagation();
@@ -7349,8 +8036,8 @@ window.__ModuleLoader__.load({
7349
8036
  alignItems: "center",
7350
8037
  gap: 5,
7351
8038
  minWidth: 0,
7352
- padding: "1px 6px",
7353
- margin: "1px 0",
8039
+ padding: dense ? "0px 6px" : "1px 6px",
8040
+ margin: dense ? "0 0" : "1px 0",
7354
8041
  borderRadius: 6,
7355
8042
  cursor: "pointer",
7356
8043
  transition: "background 0.15s ease"
@@ -7383,6 +8070,7 @@ window.__ModuleLoader__.load({
7383
8070
  function renderGroups(groups, trees, jump, C, offset, activeIdx, extra) {
7384
8071
  extra = extra || {};
7385
8072
  var base = offset || 0;
8073
+ var dense = !!extra.dense;
7386
8074
  var activeSet = {};
7387
8075
  if (Array.isArray(activeIdx)) {
7388
8076
  for (var a = 0; a < activeIdx.length; a++) activeSet[activeIdx[a]] = true;
@@ -7396,7 +8084,7 @@ window.__ModuleLoader__.load({
7396
8084
  // The divider between two groups stays OUTSIDE the highlight box: inside it
7397
8085
  // would draw a rule straight across the tinted block.
7398
8086
  out.push(react_jsx_runtime.jsx("div", {
7399
- style: { borderTop: "1px solid " + C.panelBorder, margin: "7px 2px 3px", height: 0 }
8087
+ style: { borderTop: "1px solid " + C.panelBorder, margin: dense ? "3px 2px 1px" : "7px 2px 3px", height: 0 }
7400
8088
  }, "g-sep-" + gi));
7401
8089
  var items = [];
7402
8090
  if (g.ghost) {
@@ -7410,7 +8098,7 @@ window.__ModuleLoader__.load({
7410
8098
  // entirely (the header itself grows and brightens for that job)
7411
8099
  if (!extra.questionsOnly) items.push(renderNodes(rows, 0, jump, C, "g" + gi, extra));
7412
8100
  // the failure is the turn's LAST event, so its row sits under the rows
7413
- if (g.failure) items.push(renderFailureRow(g, gi, jump, C));
8101
+ if (g.failure) items.push(renderFailureRow(g, gi, jump, C, extra));
7414
8102
  }
7415
8103
  // The group being read stays at full opacity and every other group is dimmed
7416
8104
  // to 0.85 — enough to mark the reading position without turning the list grey
@@ -7434,8 +8122,19 @@ window.__ModuleLoader__.load({
7434
8122
  borderBottom: "1px solid " + (isActive ? C.groupEdgeSoft : "transparent"),
7435
8123
  borderLeft: "3px solid " + (isActive ? C.groupEdge : "transparent"),
7436
8124
  borderRadius: 6,
7437
- margin: "2px -5px",
7438
- paddingLeft: 2
8125
+ margin: dense ? "0 -5px" : "2px -5px",
8126
+ paddingLeft: 2,
8127
+ // Long-session virtualization: every offscreen group is skipped by the
8128
+ // renderer (no layout, no paint) until it nears the viewport, while its
8129
+ // DOM stays in place — so the row queries the follow, the keyboard
8130
+ // cursor, the hover card and the jump targets run against are untouched.
8131
+ // `auto` keeps each group's real height after its first render; the
8132
+ // estimate below is only what a never-seen group stands in with.
8133
+ // While a surface is ANIMATING the hold lifts the skip entirely:
8134
+ // a compositor-driven slide does not re-evaluate the skip state, so
8135
+ // an opening surface would otherwise stay blank until it lands.
8136
+ contentVisibility: extra && extra.virtHold ? "visible" : "auto",
8137
+ containIntrinsicSize: "auto " + (dense ? 80 : 96) + "px"
7439
8138
  },
7440
8139
  children: items
7441
8140
  }, "g-" + gi));
@@ -7638,6 +8337,9 @@ window.__ModuleLoader__.load({
7638
8337
  seg([["left", T("settings.dock.left")], ["right", T("settings.dock.right")]], prefs.dock,
7639
8338
  function (v) { commit({ dock: v }); }), true),
7640
8339
  field("levels", T("settings.levels"), "", levelsControl(true), true),
8340
+ field("density", T("settings.density"), "",
8341
+ seg([["comfy", T("settings.density.comfy")], ["compact", T("settings.density.compact")]], prefs.density,
8342
+ function (v) { commit({ density: v }); }), true),
7641
8343
  field("zoom", T("settings.zoom"), T("settings.zoom.tip"), zoomControl, false),
7642
8344
  field("sheetZoom", T("settings.sheetZoom"), T("settings.sheetZoom.tip"), sheetZoomControl, false),
7643
8345
  field("handle", T("settings.handle"), T("settings.handle.tip"), handleControl, false),
@@ -7707,6 +8409,8 @@ window.__ModuleLoader__.load({
7707
8409
  var inject = ["slots", "locale"];
7708
8410
 
7709
8411
  function apply(ctx) {
8412
+ // the settings button's navigation looks host services up through this
8413
+ settingsHostCtx = ctx;
7710
8414
  ctx.effect(function () {
7711
8415
  return ctx.locale.register("dsh-quick-toc", { zh: zh, en: en });
7712
8416
  }, "dsh-quick-toc: dictionaries");