dsh-quick-toc 0.9.4 → 0.10.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
@@ -44,6 +44,11 @@ window.__ModuleLoader__.load({
44
44
  var Z_PANEL = 14; // the docked panel
45
45
  var Z_SHEET = 16; // the curtain, which replaces the docked panel while it is up
46
46
  var Z_HOVER = 18; // hover preview card: above both, still inside the free band
47
+ // Corner radius of the two surfaces. One number each so the panel, its glass
48
+ // twin and its shadow twin can never disagree, and so the curtain's bottom
49
+ // pair stays in step with the panel's rounded shape.
50
+ var RADIUS_PANEL = 18;
51
+ var RADIUS_SHEET = 18;
47
52
  // A narrow conversation column has no room for a handle floating over the text: below
48
53
  // NARROW_CONV_W the collapsed-panel handle slides back into the divider (its usual
49
54
  // motion) while the droplet above the conversation stays as the way in. Nothing opens
@@ -72,6 +77,16 @@ window.__ModuleLoader__.load({
72
77
  // two "fight" on long group headers, the label's box running right up to the
73
78
  // circle.
74
79
  var END_ZONE = 32;
80
+ // The bookmark ribbon's gutter at the left of every outline row. Reserved ALWAYS
81
+ // (whether or not anything is bookmarked) so that bookmarking a row never shifts
82
+ // the text: a ribbon that appeared by pushing content sideways would make the
83
+ // whole list twitch every time something is saved.
84
+ //
85
+ // 16px wide on purpose: at 9px the notch ate most of the shape and the ribbon read
86
+ // as a left-pointing arrow. At 16px the flat body dominates and the cut edge reads
87
+ // as a bookmark tab.
88
+ var MARK_W = 16;
89
+ var MARK_INSET = 20;
75
90
  // How long the collapsed edge handle takes to slip back into the edge when it is
76
91
  // the thing that was clicked. The panel's own slide waits exactly this long, so the
77
92
  // two motions are sequential instead of crossing over.
@@ -144,6 +159,7 @@ window.__ModuleLoader__.load({
144
159
  "search.hintOldest": "已经是最早的消息",
145
160
  "hint.autoOff": "上面还有更早的历史(自动加载已关闭,点「未加载」的条目才会加载)",
146
161
  "hint.bottom": "已经到底了",
162
+ "outline.empty": "这个会话还没有可跳转的标题。等对话里出现 Markdown 标题,这里就会列出来。",
147
163
  "outline.bottom": "回到底部",
148
164
  "outline.backToMine": "回到我滚到的位置",
149
165
  "turn.jumpReply": "跳转到该回合的模型回答开头",
@@ -160,6 +176,15 @@ window.__ModuleLoader__.load({
160
176
  "preview.truncated": "预览由宿主提供,可能被截断",
161
177
  "preview.pin": "钉住预览",
162
178
  "preview.unpin": "取消钉住",
179
+ "marks.tip.only": "只看书签({n})",
180
+ "marks.tip.all": "显示全部大纲(书签 {n})",
181
+ "marks.empty": "还没有书签。右键任意条目,选「加入书签」即可。",
182
+ "menu.goto": "跳到这一节",
183
+ "menu.mark": "加入书签",
184
+ "menu.unmark": "移出书签",
185
+ "menu.copyTitle": "复制标题",
186
+ "menu.copied": "已复制",
187
+ "menu.copySection": "复制本节大纲",
163
188
  "menu.pin": "钉住预览",
164
189
  "menu.unpin": "取消钉住",
165
190
  "menu.end": "跳到本节末尾",
@@ -179,6 +204,19 @@ window.__ModuleLoader__.load({
179
204
  "settings.density": "大纲密度",
180
205
  "settings.density.comfy": "标准",
181
206
  "settings.density.compact": "紧凑",
207
+ "sheet.resize": "拖动调整幕布高度",
208
+ "settings.glass": "半透明界面",
209
+ "settings.glass.autoOff": "已根据当前设备的帧率自动关闭,可手动重新开启。",
210
+ "settings.glass.tip": "开启后幕布会模糊它后面的对话,面板也能透出背景;关闭则改为不透明。设备性能不足时会自动关闭。",
211
+ "settings.glassStrength": "通透度",
212
+ "settings.glassStrength.tip": "数值越大越透。面板与幕布一起调整,面板会比幕布更实一些。",
213
+ "settings.accent": "主题色",
214
+ "settings.accent.blue": "蓝色",
215
+ "settings.accent.red": "红色",
216
+ "settings.accent.yellow": "黄色",
217
+ "settings.accent.green": "绿色",
218
+ "settings.accent.purple": "紫色",
219
+ "settings.accent.pink": "粉色",
182
220
  "settings.zoom": "面板缩放",
183
221
  "settings.zoom.tip": "文字、图标与按钮按比例缩放,面板本身的尺寸不变",
184
222
  "settings.sheetZoom": "幕布缩放",
@@ -249,6 +287,7 @@ window.__ModuleLoader__.load({
249
287
  "search.hintOldest": "This is the earliest message",
250
288
  "hint.autoOff": "There is older history above (auto-loading is off — click an unloaded row to load it)",
251
289
  "hint.bottom": "You have reached the end",
290
+ "outline.empty": "Nothing to jump to in this conversation yet. Markdown headings will be listed here as they appear.",
252
291
  "outline.bottom": "Back to the newest entry",
253
292
  "outline.backToMine": "Back to where I scrolled to",
254
293
  "turn.jumpReply": "Jump to the start of this turn's model reply",
@@ -267,6 +306,15 @@ window.__ModuleLoader__.load({
267
306
  "preview.truncated": "Preview comes from the host and may be truncated",
268
307
  "preview.pin": "Pin this preview",
269
308
  "preview.unpin": "Unpin",
309
+ "marks.tip.only": "Show bookmarks only ({n})",
310
+ "marks.tip.all": "Show the whole outline ({n} bookmarked)",
311
+ "marks.empty": "No bookmarks yet. Right-click any entry and choose \u201cAdd bookmark\u201d.",
312
+ "menu.goto": "Jump to this section",
313
+ "menu.mark": "Add bookmark",
314
+ "menu.unmark": "Remove bookmark",
315
+ "menu.copyTitle": "Copy heading",
316
+ "menu.copied": "Copied",
317
+ "menu.copySection": "Copy this section",
270
318
  "menu.pin": "Pin this preview",
271
319
  "menu.unpin": "Unpin this preview",
272
320
  "menu.end": "Jump to the end of this section",
@@ -286,6 +334,19 @@ window.__ModuleLoader__.load({
286
334
  "settings.density": "Outline density",
287
335
  "settings.density.comfy": "Comfy",
288
336
  "settings.density.compact": "Compact",
337
+ "sheet.resize": "Drag to resize the curtain",
338
+ "settings.glass": "Translucent surfaces",
339
+ "settings.glass.autoOff": "Switched off automatically for this device's frame rate; you can turn it back on.",
340
+ "settings.glass.tip": "The curtain blurs the conversation behind it and the panel lets the background show through; off makes both solid. Turned off automatically on a slow device.",
341
+ "settings.glassStrength": "See-through",
342
+ "settings.glassStrength.tip": "Higher is lighter. One setting for both surfaces; the panel always stays a little denser than the curtain.",
343
+ "settings.accent": "Theme colour",
344
+ "settings.accent.blue": "Blue",
345
+ "settings.accent.red": "Red",
346
+ "settings.accent.yellow": "Yellow",
347
+ "settings.accent.green": "Green",
348
+ "settings.accent.purple": "Purple",
349
+ "settings.accent.pink": "Pink",
289
350
  "settings.zoom": "Panel scale",
290
351
  "settings.zoom.tip": "Scales the text, icons and buttons; the panel's own size is unchanged",
291
352
  "settings.sheetZoom": "Curtain scale",
@@ -370,12 +431,14 @@ window.__ModuleLoader__.load({
370
431
  // purpose — they describe THIS screen, not the account.
371
432
  var NAMESPACE = "dsh-quick-toc";
372
433
  // Fields backed by the Host settings document; everything else is localStorage.
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 };
434
+ var HOST_FIELDS = { dock: 1, lang: 1, levels: 1, density: 1, accent: 1, glass: 1, glassStrength: 1, sheetHeight: 1, zoom: 1, sheetZoom: 1, handle: 1, fuzzy: 1, hover: 1, debug: 1, remember: 1, autoLoad: 1 };
374
435
  // The schema defaults (mirrored here for the card's per-field reset and for the
375
436
  // one-time import of a 0.5.x install's stored values).
376
437
  var DEFAULTS = {
377
438
  dock: "left", y: 0, w: 0, h: 0,
378
- levels: [1, 2, 3, 4, 5, 6], density: "comfy",
439
+ // "share" of see-through: 0 = nearly solid, 100 = the lightest material
440
+ levels: [1, 2, 3, 4, 5, 6], density: "comfy", accent: "blue",
441
+ glass: true, glassStrength: 60, sheetHeight: 1,
379
442
  zoom: 1, sheetZoom: 1, handle: 0.5,
380
443
  fuzzy: false, hover: true, lang: "auto", debug: false,
381
444
  remember: true, autoLoad: true
@@ -387,6 +450,10 @@ window.__ModuleLoader__.load({
387
450
  h: "dsh-quick-toc.panelH.v1",
388
451
  levels: "dsh-quick-toc.levels.v1",
389
452
  density: "dsh-quick-toc.density.v1",
453
+ accent: "dsh-quick-toc.accent.v1",
454
+ glass: "dsh-quick-toc.glass.v1",
455
+ glassStrength: "dsh-quick-toc.glassStrength.v1",
456
+ sheetHeight: "dsh-quick-toc.sheetHeight.v1",
390
457
  zoom: "dsh-quick-toc.zoom.v1",
391
458
  sheetZoom: "dsh-quick-toc.sheetZoom.v1",
392
459
  handle: "dsh-quick-toc.handle.v1",
@@ -397,6 +464,117 @@ window.__ModuleLoader__.load({
397
464
  autoLoad: "dsh-quick-toc.autoLoad.v1",
398
465
  debug: "dsh-quick-toc.debug"
399
466
  };
467
+ // Theme colours for every "selected / active / current" surface. Blue is the
468
+ // null entry: today's exact values (host brand text + fixed fills), so the
469
+ // default reproduces the current look pixel for pixel. The rest pair a fill
470
+ // triplet with a solid that stays legible on both light and dark surfaces
471
+ // (the dot shows the vivid hue; the solid is the readable one).
472
+ var ACCENTS = {
473
+ blue: { rgb: "79, 140, 255", solid: null, dot: "#4f8cff" },
474
+ red: { rgb: "245, 72, 77", solid: "#F5484D", dot: "#F5484D" },
475
+ yellow: { rgb: "232, 162, 12", solid: "#D99300", dot: "#FFB020" },
476
+ green: { rgb: "48, 164, 108", solid: "#2FA36B", dot: "#2FA36B" },
477
+ purple: { rgb: "142, 78, 198", solid: "#8E4EC6", dot: "#8E4EC6" },
478
+ pink: { rgb: "233, 61, 130", solid: "#E93D82", dot: "#E93D82" }
479
+ };
480
+ // self-contained on purpose: this runs in scopes where the store's own
481
+ // hasOwn helper is not visible, so it must not borrow it (borrowing it
482
+ // threw on every render and the error boundary ate the whole header)
483
+ var normAccent = function (v) { return (typeof v === "string" && Object.prototype.hasOwnProperty.call(ACCENTS, v)) ? v : "blue"; };
484
+ // 0..100 whole numbers — the "how see-through" slider
485
+ var clampPct = function (raw, fallback) {
486
+ return raw !== null && raw !== undefined && raw !== "" && isFinite(Number(raw))
487
+ ? Math.min(100, Math.max(0, Math.round(Number(raw)))) : fallback;
488
+ };
489
+ // 0.25..1, two decimals — the curtain's own height as a share of the area
490
+ var clampFrac = function (raw, fallback) {
491
+ return raw !== null && raw !== undefined && raw !== "" && isFinite(Number(raw))
492
+ ? Math.min(1, Math.max(0.25, Math.round(Number(raw) * 100) / 100)) : fallback;
493
+ };
494
+ // The three theme-dependent rules of the once-injected stylesheet, rebuilt
495
+ // whenever the theme colour changes. A later style element wins over the
496
+ // injected base, which keeps today's values as its fallback.
497
+ var accentCssText = function (id) {
498
+ var t = ACCENTS[normAccent(id)] || ACCENTS.blue;
499
+ var solid = t.solid || "var(--dsw-alias-brand-primary, #4f8cff)";
500
+ return ".dqt-nav{box-shadow:inset 0 0 0 1px " + solid + ";background:rgba(" + t.rgb + ",0.10)}"
501
+ + ".dqt-phead:focus-visible{outline-color:" + solid + "}"
502
+ + ".dqt-slider{accent-color:" + solid + "}";
503
+ };
504
+ var applyAccentCss = function (id) {
505
+ if (typeof document === "undefined") return;
506
+ var el = document.getElementById("dsh-quick-toc-accent");
507
+ if (!el) {
508
+ el = document.createElement("style");
509
+ el.id = "dsh-quick-toc-accent";
510
+ document.head.appendChild(el);
511
+ }
512
+ el.textContent = accentCssText(id);
513
+ };
514
+ // A caller-owned palette for one render: blue shares the module palette
515
+ // object itself (default path: zero behaviour change, same identity), any
516
+ // other choice merges the same keys with its own triplet.
517
+ var themeC = function (id) {
518
+ var key = normAccent(id);
519
+ if (key === "blue") return C;
520
+ var t = ACCENTS[key];
521
+ var out = {};
522
+ for (var k in C) if (Object.prototype.hasOwnProperty.call(C, k)) out[k] = C[k];
523
+ out.accent = t.solid;
524
+ out.accentRgb = t.rgb;
525
+ out.chip = "rgba(" + t.rgb + ", 0.18)";
526
+ out.groupTint = "rgba(" + t.rgb + ", 0.10)";
527
+ out.groupEdge = "rgba(" + t.rgb + ", 0.85)";
528
+ out.groupEdgeSoft = "rgba(" + t.rgb + ", 0.35)";
529
+ return out;
530
+ };
531
+ // How see-through the material is, from the reader's 0..100 preference.
532
+ // One slider drives both surfaces, each with its own range: the panel is read
533
+ // from, so it stays denser; the curtain is transient, so it goes lighter.
534
+ var glassPct = function (strength, forSheet) {
535
+ var s = Math.min(100, Math.max(0, Number(strength)));
536
+ if (!isFinite(s)) s = 60;
537
+ // lighter base than before: the panel has to look see-through again, and
538
+ // the card uses the same scale so the two always match
539
+ // panel/card 50 % and curtain 36 % at the default see-through of 60 — the
540
+ // densities the reader picked by eye
541
+ var pct = forSheet ? (62 - s * 0.433) : (62 - s * 0.2);
542
+ return Math.round(Math.min(96, Math.max(24, pct)));
543
+ };
544
+ // Applies the reader's glass choices to a palette, returning a fresh object.
545
+ // With glass off (or unsupported) every surface falls back to the solid panel
546
+ // colour, and the blur strings become undefined rather than empty.
547
+ // A device that cannot hold the frame rate loses the blur by itself. The
548
+ // verdict is remembered, so a slow machine pays for the probe once.
549
+ var AUTO_OFF_KEY = "dsh-quick-toc.glassAutoOff.v1";
550
+ var autoGlassOff = (function () {
551
+ try { return localStorage.getItem(AUTO_OFF_KEY) === "1"; } catch (e) { return false; }
552
+ })();
553
+ var glassC = function (base, prefs) {
554
+ var on = !!(prefs && prefs.glass) && !autoGlassOff && GLASS_OK;
555
+ var s = prefs && prefs.glassStrength !== undefined ? prefs.glassStrength : 60;
556
+ var out = {};
557
+ for (var k in base) if (Object.prototype.hasOwnProperty.call(base, k)) out[k] = base[k];
558
+ out.glassOn = on;
559
+ out.glassBg = on ? "color-mix(in srgb, var(--dsw-alias-bg-base, rgba(24, 28, 36, 0.96)) " + glassPct(s, false) + "%, transparent)" : C.panelBg;
560
+ out.sheetBg = on ? "color-mix(in srgb, var(--dsw-alias-bg-base, rgba(24, 28, 36, 0.96)) " + glassPct(s, true) + "%, transparent)" : C.panelBg;
561
+ // The preview card is a small floating layer, so it needs a lighter mix than
562
+ // the panel to show any blur at all at its size — at the panel's density the
563
+ // material reads as flat white.
564
+ out.cardBg = on
565
+ ? "color-mix(in srgb, var(--dsw-alias-bg-base, rgba(24, 28, 36, 0.96)) " + Math.max(30, glassPct(s, false) - 12) + "%, transparent)"
566
+ : C.panelBg;
567
+ // Small radius on purpose: the panel is a place you read, so the material
568
+ // should only soften the conversation, never push it toward "text smeared
569
+ // into a blur". The curtain can go a little further — it is transient.
570
+ // A very small radius on the panel on purpose: enough to soften the
571
+ // conversation, not enough to destroy the sense that there IS something
572
+ // behind it (a bigger blur made the surface read as solid). The curtain
573
+ // sits at 10px, which is the reader's own pick.
574
+ out.panelBlur = on ? "blur(3px) saturate(1.1)" : undefined;
575
+ out.sheetBlur = on ? "blur(5px) saturate(1.1)" : undefined;
576
+ return out;
577
+ };
400
578
  // Writing a field's schema default through the card IS its reset — a value that
401
579
  // equals the default carries no customization, so it must not pin an entry (and
402
580
  // the "customized" badge with it) into the user layer.
@@ -461,6 +639,44 @@ window.__ModuleLoader__.load({
461
639
  localStorage.setItem(READ_POS_KEY, JSON.stringify(map));
462
640
  } catch (e) {}
463
641
  };
642
+ // ---- bookmarks (browser-local, per session) -------------------------------
643
+ // Same shape as the reading position and for the same reason: a node key only
644
+ // means anything inside ONE session, so bookmarks are stored per session and
645
+ // never travel through the host's settings document. `{ [sessionId]: [entry] }`
646
+ // where an entry is { key, idx, title, level, at }.
647
+ var BOOKMARK_KEY = "dsh-quick-toc.bookmarks.v1";
648
+ var BOOKMARK_MAX = 300;
649
+ var bookmarkMap = function () {
650
+ try {
651
+ var raw = localStorage.getItem(BOOKMARK_KEY);
652
+ var parsed = raw ? JSON.parse(raw) : null;
653
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
654
+ } catch (e) { return {}; }
655
+ };
656
+ var bookmarksOf = function (sessionId) {
657
+ if (!sessionId) return [];
658
+ var list = bookmarkMap()[sessionId];
659
+ return Array.isArray(list) ? list : [];
660
+ };
661
+ var writeBookmarks = function (sessionId, list) {
662
+ if (!sessionId) return;
663
+ try {
664
+ var map = bookmarkMap();
665
+ if (list && list.length) map[sessionId] = list;
666
+ else delete map[sessionId];
667
+ // trim the OLDEST sessions first, so a long-lived install cannot grow forever
668
+ var ids = Object.keys(map);
669
+ if (ids.length > BOOKMARK_MAX) {
670
+ ids.sort(function (a, b) {
671
+ var aa = (map[a][0] && map[a][0].at) || 0;
672
+ var bb = (map[b][0] && map[b][0].at) || 0;
673
+ return aa - bb;
674
+ });
675
+ for (var i = 0; i < ids.length - BOOKMARK_MAX; i++) delete map[ids[i]];
676
+ }
677
+ localStorage.setItem(BOOKMARK_KEY, JSON.stringify(map));
678
+ } catch (e) { /* storage blocked: bookmarks are a convenience */ }
679
+ };
464
680
  // ---- recent search history (browser-local, like the reading position) --
465
681
  // Most-recent-first [{q, scope}]: filled when a search is committed (Enter in
466
682
  // the box, a result click), offered back as one-tap chips while the box is
@@ -554,6 +770,7 @@ window.__ModuleLoader__.load({
554
770
  var clampRatio = function (raw, fallback) {
555
771
  return raw !== null && isFinite(Number(raw)) ? Math.min(1, Math.max(0, Math.round(Number(raw) * 100) / 100)) : fallback;
556
772
  };
773
+
557
774
  return {
558
775
  dock: dockRaw === "right" ? "right" : "left",
559
776
  // 0 means "the panel's own default" (see PANEL_WIDTH / auto height)
@@ -562,6 +779,11 @@ window.__ModuleLoader__.load({
562
779
  h: hRaw !== null && isFinite(Number(hRaw)) && Number(hRaw) >= 60 ? Number(hRaw) : 0,
563
780
  levels: parseLevels(rawLevels) || LEVELS_ALL.slice(),
564
781
  density: read(PREF_KEYS.density) === "compact" ? "compact" : "comfy",
782
+ accent: normAccent(read(PREF_KEYS.accent)),
783
+ // absent means "on": the translucent look is what this version ships
784
+ glass: read(PREF_KEYS.glass) !== "0",
785
+ glassStrength: clampPct(read(PREF_KEYS.glassStrength), 60),
786
+ sheetHeight: clampFrac(read(PREF_KEYS.sheetHeight), 1),
565
787
  zoom: clampRatio(zoomRaw, 1),
566
788
  sheetZoom: clampRatio(sheetZoomRaw, 1),
567
789
  handle: clampRatio(handleRaw, 0.5),
@@ -584,6 +806,10 @@ window.__ModuleLoader__.load({
584
806
  h: function (v) { return String(v); },
585
807
  levels: function (v) { return JSON.stringify(v); },
586
808
  density: function (v) { return v === "compact" ? "compact" : "comfy"; },
809
+ accent: function (v) { return normAccent(v); },
810
+ glass: function (v) { return v ? "1" : "0"; },
811
+ glassStrength: function (v) { return String(v); },
812
+ sheetHeight: function (v) { return String(v); },
587
813
  zoom: function (v) { return String(v); },
588
814
  sheetZoom: function (v) { return String(v); },
589
815
  handle: function (v) { return String(v); },
@@ -619,6 +845,10 @@ window.__ModuleLoader__.load({
619
845
  lang: v.lang === "zh" || v.lang === "en" ? v.lang : "auto",
620
846
  levels: levels || LEVELS_ALL.slice(),
621
847
  density: v.density === "compact" ? "compact" : "comfy",
848
+ accent: normAccent(v.accent),
849
+ glass: v.glass !== false,
850
+ glassStrength: clampPct(v.glassStrength, 60),
851
+ sheetHeight: clampFrac(v.sheetHeight, 1),
622
852
  zoom: isFinite(zoomNum) ? Math.min(2, Math.max(0.5, Math.round(zoomNum * 100) / 100)) : 1,
623
853
  sheetZoom: isFinite(sheetZoomNum) ? Math.min(2, Math.max(0.5, Math.round(sheetZoomNum * 100) / 100)) : 1,
624
854
  handle: isFinite(handleNum) ? Math.min(1, Math.max(0, Math.round(handleNum * 100) / 100)) : 0.5,
@@ -635,7 +865,9 @@ window.__ModuleLoader__.load({
635
865
  if (!over) return local;
636
866
  return {
637
867
  dock: over.dock, y: local.y, w: local.w, h: local.h,
638
- levels: over.levels, density: over.density, zoom: over.zoom, sheetZoom: over.sheetZoom, handle: over.handle, fuzzy: over.fuzzy, hover: over.hover,
868
+ levels: over.levels, density: over.density, accent: over.accent,
869
+ glass: over.glass, glassStrength: over.glassStrength, sheetHeight: over.sheetHeight,
870
+ zoom: over.zoom, sheetZoom: over.sheetZoom, handle: over.handle, fuzzy: over.fuzzy, hover: over.hover,
639
871
  lang: over.lang, remember: over.remember, debug: over.debug, autoLoad: over.autoLoad
640
872
  };
641
873
  };
@@ -1875,7 +2107,7 @@ window.__ModuleLoader__.load({
1875
2107
  // NOTE: these fills sit ON TOP OF conversation text, so they stay deliberately
1876
2108
  // stronger than the panel's UI tints (C.chip / C.groupTint); only the current
1877
2109
  // hit's outline reuses the shared accent value.
1878
- function highlightRow(row, q, currentOcc, fuzzy) {
2110
+ function highlightRow(row, q, currentOcc, fuzzy, rgb) {
1879
2111
  if (!row || !q) return;
1880
2112
  var fq = foldQuery(q);
1881
2113
  if (!fq) return;
@@ -1906,12 +2138,13 @@ window.__ModuleLoader__.load({
1906
2138
  if (from < cursor || to <= from) continue;
1907
2139
  if (from > cursor) frag.appendChild(document.createTextNode(text.slice(cursor, from)));
1908
2140
  var mark = document.createElement("span");
2141
+ var hitRgb = rgb || "79,140,255";
1909
2142
  if (isCurrent) {
1910
2143
  mark.className = "dqt-current";
1911
- mark.style.background = "rgba(79,140,255,0.55)";
1912
- mark.style.boxShadow = "0 0 0 1px rgba(79,140,255,0.85)";
2144
+ mark.style.background = "rgba(" + hitRgb + ",0.55)";
2145
+ mark.style.boxShadow = "0 0 0 1px rgba(" + hitRgb + ",0.85)";
1913
2146
  } else {
1914
- mark.style.background = "rgba(79,140,255,0.32)";
2147
+ mark.style.background = "rgba(" + hitRgb + ",0.32)";
1915
2148
  }
1916
2149
  mark.style.borderRadius = "2px";
1917
2150
  mark.style.color = "inherit";
@@ -1933,6 +2166,9 @@ window.__ModuleLoader__.load({
1933
2166
  text: "var(--dsw-alias-label-primary, #e8eaee)",
1934
2167
  muted: "var(--dsw-alias-label-secondary, #9aa0ab)",
1935
2168
  accent: "var(--dsw-alias-brand-primary, #4f8cff)",
2169
+ // the selection triplet, for callers that paint their own alphas (search
2170
+ // hit marks): same hue as every tint above, in one place
2171
+ accentRgb: "79,140,255",
1936
2172
  // terminal turn failures (a reply that never arrived) get the host's own
1937
2173
  // error colour, so the outline speaks the same language as the transcript
1938
2174
  error: "var(--dsw-alias-state-error-primary, #e5534b)",
@@ -1952,8 +2188,59 @@ window.__ModuleLoader__.load({
1952
2188
  // edge at the left and right, so the negative spreads keep the vertical seams
1953
2189
  // free of shadow while the bottom — rounded corners included — lifts off the
1954
2190
  // conversation below.
1955
- sheetShadow: "0 14px 22px -12px rgba(0, 0, 0, 0.35), 0 6px 12px -10px rgba(0, 0, 0, 0.25)"
2191
+ sheetShadow: "0 14px 22px -12px rgba(0, 0, 0, 0.35), 0 6px 12px -10px rgba(0, 0, 0, 0.25)",
2192
+ // ---- glass surface (the 0.10.0 visual upgrade) -------------------------
2193
+ // The panel and the curtain are the same colour at GLASS_PCT plus a small
2194
+ // backdrop blur; the two 1px inset lines are the "light catching the edge"
2195
+ // that makes a translucent surface read as a material instead of a tint.
2196
+ // A small radius leaves the conversation TEXT recognisable-but-smudged, and
2197
+ // that fights the panel's own rows ("blurred and clear at once"). A wider
2198
+ // radius turns it into colour and light — the only thing that reads as a
2199
+ // surface rather than as a second layer of type.
2200
+ glassBlur: "blur(22px) saturate(1.25)",
2201
+ sheetBlur: "blur(20px) saturate(1.2)"
1956
2202
  };
2203
+ // Blur is painted by a CHILD layer, never by the surface itself: a
2204
+ // backdrop-filter on an element that also lives inside a clip-path container
2205
+ // gets promoted to its own compositing layer, and in the host's page that
2206
+ // promotion put the whole panel underneath the conversation. A child layer
2207
+ // keeps the surface's own stacking exactly as it was.
2208
+ var GLASS_OK = (function () {
2209
+ try {
2210
+ return typeof CSS !== "undefined" && CSS.supports
2211
+ && (CSS.supports("backdrop-filter", "blur(12px)") || CSS.supports("-webkit-backdrop-filter", "blur(12px)"));
2212
+ } catch (e) { return false; }
2213
+ })();
2214
+ // Two densities: the docked panel is a surface you read in, so it stays
2215
+ // denser (70 %); the curtain is a transient sheet over the conversation and
2216
+ // the reader is meant to see the page move behind it, so it goes lighter
2217
+ // (54 %). Below ~50 % the labels start to fight the blurred text underneath.
2218
+ C.glassBg = GLASS_OK
2219
+ ? "color-mix(in srgb, var(--dsw-alias-bg-base, rgba(24, 28, 36, 0.96)) 80%, transparent)"
2220
+ : C.panelBg;
2221
+ C.sheetBg = GLASS_OK
2222
+ ? "color-mix(in srgb, var(--dsw-alias-bg-base, rgba(24, 28, 36, 0.96)) 46%, transparent)"
2223
+ : C.panelBg;
2224
+ // The two 1px rim lines: a bright catch on the top edge and a whisper on the
2225
+ // bottom one. Dark surfaces need much less white to read the same way.
2226
+ var rimInset = function () {
2227
+ return isDarkScheme()
2228
+ ? "inset 0 1px 0 rgba(255,255,255,0.18), inset 0 -1px 0 rgba(255,255,255,0.05)"
2229
+ : "inset 0 1px 0 rgba(255,255,255,0.85), inset 0 -1px 0 rgba(255,255,255,0.35)";
2230
+ };
2231
+ // The material itself, as a layer that sits behind a surface's content
2232
+ // (z-index -1 inside the host surface's own stacking context).
2233
+ // Why the blur has to live on a SIBLING (docked panel) or on a surface with no
2234
+ // opacity of its own (the curtain):
2235
+ // · an element with `opacity` below 1 becomes a *backdrop root*, so a
2236
+ // backdrop-filter INSIDE it only ever samples that element's own content —
2237
+ // the blur comes out of nothing (that is why the child-layer attempt looked
2238
+ // exactly like no blur at all);
2239
+ // · putting it on the panel itself, inside the clip wrapper, got the panel
2240
+ // promoted to its own compositing layer and pushed under the conversation.
2241
+ // A sibling sits outside both problems: it samples the page, and the panel's
2242
+ // own translucent fill is painted over it.
2243
+
1957
2244
 
1958
2245
  // ---- theme scheme detection (for the panel's 3D inner shadow) ----
1959
2246
  // Prefer a positive dark signal from the OS preference, then DSH's theme
@@ -2073,6 +2360,43 @@ window.__ModuleLoader__.load({
2073
2360
  // leave a stale handle behind.
2074
2361
  var tocHost = props.tocHost;
2075
2362
  var sessionId = props.sessionId;
2363
+ // ---- bookmarks -----------------------------------------------------------
2364
+ // Read once per session switch; every mutation writes straight through, so
2365
+ // there is no second source of truth to reconcile.
2366
+ var _sMarks = react.useState(function () { return bookmarksOf(props.sessionId); });
2367
+ var marks = _sMarks[0];
2368
+ var setMarks = _sMarks[1];
2369
+ react.useEffect(function () {
2370
+ setMarks(bookmarksOf(props.sessionId));
2371
+ }, [props.sessionId]);
2372
+ var markSet = (function () {
2373
+ var m = {};
2374
+ for (var i = 0; i < marks.length; i++) if (marks[i] && marks[i].key) m[marks[i].key + "#" + (marks[i].idx === undefined ? 0 : marks[i].idx)] = true;
2375
+ return m;
2376
+ })();
2377
+ var isMarked = function (key, idx) { return !!markSet[key + "#" + (idx === undefined ? 0 : idx)]; };
2378
+ var toggleMark = function (key, idx, title, level) {
2379
+ if (!key) return;
2380
+ var id = key + "#" + (idx === undefined ? 0 : idx);
2381
+ var next = [];
2382
+ var found = false;
2383
+ for (var i = 0; i < marks.length; i++) {
2384
+ var e = marks[i];
2385
+ if (!e) continue;
2386
+ if (e.key + "#" + (e.idx === undefined ? 0 : e.idx) === id) { found = true; continue; }
2387
+ next.push(e);
2388
+ }
2389
+ if (!found) next.push({ key: key, idx: idx === undefined ? 0 : idx, title: title || "", level: level || 1, at: Date.now() });
2390
+ setMarks(next);
2391
+ writeBookmarks(props.sessionId, next);
2392
+ };
2393
+ // The bookmark list reuses the outline renderer: entries become ordinary rows
2394
+ // (same look, same jump, same hover/end control), so "only bookmarks" needs no
2395
+ // second view — just a different array of groups to draw.
2396
+ var _sOnlyMarks = react.useState(false);
2397
+ var onlyMarks = _sOnlyMarks[0];
2398
+ var setOnlyMarks = _sOnlyMarks[1];
2399
+ var toggleOnlyMarks = function () { setOnlyMarks(!onlyMarks); };
2076
2400
  var hostSessions = function () {
2077
2401
  if (!tocHost || typeof tocHost.sessions !== "function") return null;
2078
2402
  try {
@@ -2638,9 +2962,28 @@ window.__ModuleLoader__.load({
2638
2962
  var menuClosing = _sMenuClose[0];
2639
2963
  var setMenuClosing = _sMenuClose[1];
2640
2964
  var menuCloseTimerRef = react.useRef(null);
2965
+ // "已复制": the two copy entries write to the clipboard and used to close the
2966
+ // menu with no other trace, so there was no way to tell a copy from a misfire
2967
+ // (or the two entries apart). The label confirms it for a beat before the fade.
2968
+ var _sCopied = react.useState(null);
2969
+ var copiedId = _sCopied[0];
2970
+ var setCopiedId = _sCopied[1];
2971
+ var copyTimerRef = react.useRef(null);
2972
+ // Show the confirmation, then let the menu close on its own short fade.
2973
+ var flashCopied = function (id) {
2974
+ if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
2975
+ setCopiedId(id);
2976
+ copyTimerRef.current = setTimeout(function () {
2977
+ copyTimerRef.current = null;
2978
+ setCopiedId(null);
2979
+ closeRowMenu();
2980
+ }, 420);
2981
+ };
2641
2982
  var openRowMenu = function (e, info, key, idx) {
2642
2983
  if (e && e.preventDefault) e.preventDefault();
2643
2984
  if (e && e.stopPropagation) e.stopPropagation();
2985
+ if (copyTimerRef.current) { clearTimeout(copyTimerRef.current); copyTimerRef.current = null; }
2986
+ setCopiedId(null);
2644
2987
  var x = e && typeof e.clientX === "number" ? e.clientX : 0;
2645
2988
  var y = e && typeof e.clientY === "number" ? e.clientY : 0;
2646
2989
  if (menuCloseTimerRef.current) { clearTimeout(menuCloseTimerRef.current); menuCloseTimerRef.current = null; }
@@ -3113,6 +3456,132 @@ window.__ModuleLoader__.load({
3113
3456
  // two comparisons disagree forever — the panel then republishes its default on
3114
3457
  // every pass and stomps a fresh change with the value it had just read back.
3115
3458
  var prefs = usePrefs();
3459
+ // ---- the day strip (see its call site) -----------------------------------
3460
+ // Declared up here with the other hooks: an early "nothing to show yet"
3461
+ // return must not skip them, or the first empty render and every real one
3462
+ // disagree on the hook count (React #310). It reads the list's scroll
3463
+ // position directly — the labels are plain DOM and offsetTop is all the
3464
+ // answer needs — so the handler only ever sets one string.
3465
+ var _sDayStrip = react.useState("");
3466
+ var dayStripText = _sDayStrip[0];
3467
+ var setDayStripText = _sDayStrip[1];
3468
+ react.useEffect(function () {
3469
+ var el = listRef.current;
3470
+ if (!el || typeof el.addEventListener !== "function") return undefined;
3471
+ var sync = function () {
3472
+ var top = el.scrollTop || 0;
3473
+ if (top < 2) { setDayStripText(""); return; }
3474
+ var labels = typeof el.querySelectorAll === "function"
3475
+ ? el.querySelectorAll("[data-dqt-grouplabel]") : [];
3476
+ var cur = null;
3477
+ for (var i = 0; i < labels.length; i++) {
3478
+ if ((labels[i].offsetTop || 0) - 6 <= top) cur = labels[i];
3479
+ else break;
3480
+ }
3481
+ setDayStripText(cur ? String(cur.getAttribute("data-dqt-day") || "") : "");
3482
+ };
3483
+ el.addEventListener("scroll", sync);
3484
+ sync();
3485
+ return function () { el.removeEventListener("scroll", sync); };
3486
+ }, []);
3487
+ // ---- the curtain's own height (dragged from its bottom edge) -------------
3488
+ // The panel's blur twin needs the panel's REAL height: the panel's own height
3489
+ // is often auto (a stored 0), and a twin with no height blurs nothing — that
3490
+ // is what made the first twin invisible.
3491
+ //
3492
+ // Measured through a ResizeObserver, NOT on every render: reading offsetHeight
3493
+ // in a layout effect forces a synchronous layout of the whole (often huge)
3494
+ // outline on every single render, which is exactly the kind of thing that
3495
+ // turns a scroll or a jump into visible jank.
3496
+ var _sBlurH = react.useState(0);
3497
+ var panelBlurH = _sBlurH[0];
3498
+ var setPanelBlurH = _sBlurH[1];
3499
+ react.useEffect(function () {
3500
+ var el = panelRootRef.current;
3501
+ if (!el) return undefined;
3502
+ var measure = function () {
3503
+ var h = el.offsetHeight ? Math.round(el.offsetHeight) : 0;
3504
+ setPanelBlurH(function (prev) { return prev === h ? prev : h; });
3505
+ };
3506
+ if (typeof ResizeObserver === "function") {
3507
+ var ro = new ResizeObserver(measure);
3508
+ ro.observe(el);
3509
+ measure();
3510
+ return function () { ro.disconnect(); };
3511
+ }
3512
+ measure();
3513
+ return undefined;
3514
+ }, [open, sheetOpen]);
3515
+ var _sAutoGlass = react.useState(autoGlassOff);
3516
+ var autoGlassState = _sAutoGlass[0];
3517
+ var setAutoGlassOff = _sAutoGlass[1];
3518
+ var _sSheetFrac = react.useState(null);
3519
+ var sheetDragFrac = _sSheetFrac[0];
3520
+ var setSheetDragFrac = _sSheetFrac[1];
3521
+ var sheetDragRef = react.useRef(null);
3522
+ var sheetResizeStart = function (e) {
3523
+ if (!e || e.button === 2) return;
3524
+ var h = (sheetRef.current && sheetRef.current.clientHeight) || window.innerHeight || 600;
3525
+ sheetDragRef.current = { y: e.clientY, frac: prefs.sheetHeight, h: h };
3526
+ setSheetDragFrac(prefs.sheetHeight);
3527
+ try { if (e.currentTarget && e.currentTarget.setPointerCapture) e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
3528
+ };
3529
+ var sheetResizeMove = function (e) {
3530
+ var d = sheetDragRef.current;
3531
+ if (!d) return;
3532
+ // dragging DOWN grows the curtain: the free edge is its bottom
3533
+ setSheetDragFrac(clampFrac(d.frac + (e.clientY - d.y) / d.h, 1));
3534
+ };
3535
+ var sheetResizeEnd = function () {
3536
+ if (!sheetDragRef.current) return;
3537
+ sheetDragRef.current = null;
3538
+ if (sheetDragFrac !== null) prefsStore.set({ sheetHeight: sheetDragFrac });
3539
+ setSheetDragFrac(null);
3540
+ };
3541
+ // ---- slow-device probe (see AUTO_OFF_KEY) --------------------------------
3542
+ // Sampled once per curtain opening, and only while the material is on: the
3543
+ // first 700ms of animated curtain is the heaviest thing this plugin does, so
3544
+ // a p90 frame time over ~26ms there means the blur is costing more than the
3545
+ // look is worth on this machine.
3546
+ react.useEffect(function () {
3547
+ if (!sheetOpen || !prefs.glass || autoGlassOff) return undefined;
3548
+ if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") return undefined;
3549
+ var frames = [];
3550
+ var last = 0;
3551
+ var stop = false;
3552
+ var tick = function (t) {
3553
+ if (stop) return;
3554
+ if (last) frames.push(t - last);
3555
+ last = t;
3556
+ if (frames.length < 40) window.requestAnimationFrame(tick);
3557
+ else finish();
3558
+ };
3559
+ var finish = function () {
3560
+ stop = true;
3561
+ if (!frames.length) return;
3562
+ var sorted = frames.slice().sort(function (a, b) { return a - b; });
3563
+ var p90 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.9))];
3564
+ if (p90 > 26) {
3565
+ autoGlassOff = true;
3566
+ try { localStorage.setItem(AUTO_OFF_KEY, "1"); } catch (e) {}
3567
+ setAutoGlassOff(true);
3568
+ }
3569
+ };
3570
+ var id = window.requestAnimationFrame(tick);
3571
+ var guard = setTimeout(finish, 1200);
3572
+ return function () { stop = true; clearTimeout(guard); try { window.cancelAnimationFrame(id); } catch (e) {} };
3573
+ }, [sheetOpen]);
3574
+ // The render's own palette: blue shares the module object itself (default
3575
+ // path: zero behaviour change), any other theme merges the same keys.
3576
+ // Every C.* use below this line — rows, chips, the accent bar, hit marks —
3577
+ // follows the preference without touching a single call site.
3578
+ var C = glassC(themeC(prefs.accent), {
3579
+ glass: prefs.glass && !autoGlassState,
3580
+ glassStrength: prefs.glassStrength
3581
+ });
3582
+ // the once-injected stylesheet cannot see per-render values: repaint its
3583
+ // three theme-dependent rules whenever the theme colour changes
3584
+ react.useEffect(function () { applyAccentCss(prefs.accent); }, [prefs.accent]);
3116
3585
  // Resolve the language for this render FIRST: the group builder below bakes the
3117
3586
  // localised turn stamps (昨天 versus a numeric date) into its memo, and `langKey`
3118
3587
  // is what tells that memo a language switch has to rebuild it.
@@ -5091,11 +5560,23 @@ window.__ModuleLoader__.load({
5091
5560
  landed = true;
5092
5561
  if (glide === mine) glide = null;
5093
5562
  var ticks = 0;
5563
+ var lastSign = 0;
5094
5564
  var iv = setInterval(function () {
5095
- if (mine.done || (ticks += 1) > 5) { clearInterval(iv); return; }
5565
+ if (mine.done || (ticks += 1) > 3) { clearInterval(iv); return; }
5096
5566
  var next = getTarget(sp);
5097
- if (Math.abs(next - sp.scrollTop) > 2) sp.scrollTop = next;
5098
- }, 120);
5567
+ var delta = next - sp.scrollTop;
5568
+ // Only ever nudge a SMALL drift, and never twice in a row in opposite
5569
+ // directions. The re-check exists to absorb the last few pixels while
5570
+ // the host finishes measuring; if the measurement itself swings (a
5571
+ // virtualized turn settling under the target) an unbounded correction
5572
+ // yanks the reader away from where they just landed and then back —
5573
+ // "it arrives and flashes back". A jump that really moved is a fresh
5574
+ // glide, not this one's business.
5575
+ if (Math.abs(delta) > 60) { clearInterval(iv); return; }
5576
+ var sign = delta > 0 ? 1 : (delta < 0 ? -1 : 0);
5577
+ if (sign !== 0 && lastSign !== 0 && sign !== lastSign) { clearInterval(iv); return; }
5578
+ if (Math.abs(delta) > 8) { sp.scrollTop = next; lastSign = sign; }
5579
+ }, 140);
5099
5580
  };
5100
5581
 
5101
5582
  // fallback: draw the glide ourselves, in real time, decelerating to a stop
@@ -5289,7 +5770,7 @@ window.__ModuleLoader__.load({
5289
5770
  // a jump that started from a search hit keeps the hit highlighted
5290
5771
  if (query.trim()) {
5291
5772
  clearHighlights();
5292
- highlightRow(row, query.trim(), 0, fuzzy);
5773
+ highlightRow(row, query.trim(), 0, fuzzy, C.accentRgb);
5293
5774
  }
5294
5775
  return;
5295
5776
  }
@@ -5481,7 +5962,7 @@ window.__ModuleLoader__.load({
5481
5962
  // occurrence index within the target message (consecutive in matches)
5482
5963
  var occ = 0;
5483
5964
  for (var p = i - 1; p >= 0 && matches[p].key === m.key; p--) occ++;
5484
- highlightRow(r, q, occ, fuzzy);
5965
+ highlightRow(r, q, occ, fuzzy, C.accentRgb);
5485
5966
  var markEl = r.querySelector(".dqt-current");
5486
5967
  var sp = r.closest ? r.closest("[data-conversation-scroll]") : null;
5487
5968
  if (sp && markEl) {
@@ -5540,7 +6021,7 @@ window.__ModuleLoader__.load({
5540
6021
  title: tip,
5541
6022
  onMouseEnter: function (e) {
5542
6023
  e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-active, rgba(79,140,255,0.24))";
5543
- e.currentTarget.style.color = "var(--dsw-alias-brand-primary, #4f8cff)";
6024
+ e.currentTarget.style.color = C.accent;
5544
6025
  },
5545
6026
  onMouseLeave: function (e) {
5546
6027
  e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
@@ -5565,6 +6046,10 @@ window.__ModuleLoader__.load({
5565
6046
  onOpenTurn: openTurn,
5566
6047
  hoverStart: hoverStart,
5567
6048
  hoverEnd: hoverEnd,
6049
+ // B: which rows carry a bookmark (the flag layer reads this)
6050
+ isMarked: isMarked,
6051
+ // B: the ribbon's colour, resolved here to a concrete hex (see renderItem)
6052
+ markColor: (ACCENTS[normAccent(prefs.accent)] || ACCENTS.blue).dot,
5568
6053
  // right-click on a row: pin its preview / jump to the end of its section
5569
6054
  rowMenu: openRowMenu,
5570
6055
  // the curtain is wide: rows switch to a one-line, multi-part layout that
@@ -5807,7 +6292,11 @@ window.__ModuleLoader__.load({
5807
6292
  // ramp over the slide only made the two directions read different lines.
5808
6293
  // While parked the wipe covers the box entirely, so the resting opacity can
5809
6294
  // simply stay up.
5810
- var panelOpacity = chatViewActive ? (hovered ? 1 : 0.72) : 0;
6295
+ // Resting opacity used to be 0.72 so the panel stepped out of the way. The
6296
+ // surface is genuinely translucent now (74 % + blur), and dimming on top of
6297
+ // that would leave the labels at ~53 % — text has to stay crisp, so the
6298
+ // "inactive" hint is a much smaller step.
6299
+ var panelOpacity = chatViewActive ? (hovered ? 1 : 0.92) : 0;
5811
6300
 
5812
6301
  // ---- toolbar buttons -----------------------------------------------------
5813
6302
  // ONE instance of each, placed either in the docked header row or in the
@@ -5845,7 +6334,7 @@ window.__ModuleLoader__.load({
5845
6334
  onMouseEnter: function (e) {
5846
6335
  if (disabled) return;
5847
6336
  e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-active, rgba(79,140,255,0.24))";
5848
- e.currentTarget.style.color = "var(--dsw-alias-brand-primary, #4f8cff)";
6337
+ e.currentTarget.style.color = C.accent;
5849
6338
  },
5850
6339
  onMouseLeave: function (e) {
5851
6340
  e.currentTarget.style.background = active ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
@@ -5963,6 +6452,53 @@ window.__ModuleLoader__.load({
5963
6452
  // is what makes it read centred (the old character needed the same, plus a y nudge for
5964
6453
  // its line box, which a path does not have)
5965
6454
  var dockBtnEl = iconBtn(toggleDock, dockRight ? T("handle.dockLeft") : T("handle.dockRight"), dockTriEl, 12, dockRight ? { x: -1, y: 0 } : { x: 1, y: 0 });
6455
+ // B: the bookmark button. Always present, between the two left-hand controls,
6456
+ // and it is the way in and out of the bookmark view — a control that appeared
6457
+ // and vanished with the count left the reader looking for it.
6458
+ //
6459
+ // The star is the whole face: no number inside it (there is no room in a
6460
+ // twenty-pixel star, and a squeezed digit is worse than none). The count lives
6461
+ // in the tooltip a moment later, where it has a line of its own.
6462
+ var markCount = marks.length > 99 ? "99+" : String(marks.length);
6463
+ var marksTip = onlyMarks
6464
+ ? Tp("marks.tip.all", { n: markCount })
6465
+ : Tp("marks.tip.only", { n: markCount });
6466
+ var marksBtnEl = react_jsx_runtime.jsx("button", {
6467
+ onClick: toggleOnlyMarks,
6468
+ title: marksTip,
6469
+ "aria-label": marksTip,
6470
+ "data-dqt-marks": onlyMarks ? "only" : "all",
6471
+ style: {
6472
+ width: 24,
6473
+ height: 24,
6474
+ display: "flex",
6475
+ alignItems: "center",
6476
+ justifyContent: "center",
6477
+ borderRadius: "50%",
6478
+ cornerShape: "round",
6479
+ background: onlyMarks ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
6480
+ border: "none",
6481
+ color: C.muted,
6482
+ cursor: "pointer",
6483
+ flex: "none",
6484
+ padding: 0,
6485
+ transition: "background 0.18s ease, color 0.18s ease"
6486
+ },
6487
+ children: react_jsx_runtime.jsx("svg", {
6488
+ key: "star",
6489
+ width: 15, height: 15, viewBox: "0 0 24 24",
6490
+ style: { display: "block" },
6491
+ children: react_jsx_runtime.jsx("path", {
6492
+ // rounded joins (a stroke in the same colour as the fill) soften the
6493
+ // five points; without it the star reads as a spiky asterisk at this size
6494
+ d: "M12 3.1l2.72 5.52 6.09.89-4.4 4.29 1.04 6.06L12 16.9l-5.45 2.86 1.04-6.06-4.4-4.29 6.09-.89z",
6495
+ fill: "currentColor",
6496
+ stroke: "currentColor",
6497
+ strokeWidth: 2.4,
6498
+ strokeLinejoin: "round"
6499
+ })
6500
+ })
6501
+ }, "marks-btn");
5966
6502
  // the curtain keeps its own entry point: the water-drop handle on the top
5967
6503
  // edge (sheet.openHandle). The toolbar's curtain button is gone — the header
5968
6504
  // is four controls now.
@@ -6063,7 +6599,7 @@ window.__ModuleLoader__.load({
6063
6599
  // handle.
6064
6600
  react_jsx_runtime.jsx("div", {
6065
6601
  style: { display: "flex", alignItems: "center", gap: 6, flex: "none" },
6066
- children: [settingsBtnEl, dockBtnEl]
6602
+ children: [settingsBtnEl, marksBtnEl, dockBtnEl]
6067
6603
  }),
6068
6604
  // spacer: nothing here, it just pins the two pairs to the two ends —
6069
6605
  // and it is the first thing to give way when the panel narrows
@@ -6318,7 +6854,7 @@ window.__ModuleLoader__.load({
6318
6854
  borderRadius: "50%",
6319
6855
  cornerShape: "round",
6320
6856
  fontSize: sheetOpen ? 13 : 11,
6321
- color: searchScope !== "title" ? "var(--dsw-alias-brand-primary, #4f8cff)" : C.muted,
6857
+ color: searchScope !== "title" ? C.accent : C.muted,
6322
6858
  background: searchScope !== "title" ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
6323
6859
  border: "none",
6324
6860
  cursor: "pointer",
@@ -6360,7 +6896,7 @@ window.__ModuleLoader__.load({
6360
6896
  borderRadius: "50%",
6361
6897
  cornerShape: "round",
6362
6898
  fontSize: sheetOpen ? 13 : 11,
6363
- color: fuzzy ? "var(--dsw-alias-brand-primary, #4f8cff)" : C.muted,
6899
+ color: fuzzy ? C.accent : C.muted,
6364
6900
  background: fuzzy ? C.chip : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
6365
6901
  border: "none",
6366
6902
  cursor: searchScope === "cross" ? "default" : "pointer",
@@ -6405,14 +6941,136 @@ window.__ModuleLoader__.load({
6405
6941
  },
6406
6942
  children: hint === "oldest" ? T("search.hintOldest") : T("search.hintMore")
6407
6943
  }) : null;
6944
+ // B: "only bookmarks". The rows come from the stored entries rather than from
6945
+ // the outline tree — a bookmark may point at a turn that is not loaded right
6946
+ // now, and it still has to be listed (it is a place the reader saved).
6947
+ //
6948
+ // These are DELIBERATELY plain rows, not the outline's own row renderer. That
6949
+ // renderer registers data-jump-key / data-nav-row and wires the hover card,
6950
+ // the end control and the preview pinning; reusing it here put a second set of
6951
+ // rows into the DOM carrying the SAME keys as the live outline, which is a
6952
+ // collision waiting to happen for anything that queries rows by key (the jump
6953
+ // target lookup, the keyboard cursor, the follow). A saved place needs a label
6954
+ // and a click — nothing else.
6955
+ var markRowsEl = null;
6956
+ if (onlyMarks && !resultsEl) {
6957
+ // `dense` is a local of the row renderers, not of this component: read the
6958
+ // preference here instead (referencing the other one threw and took the
6959
+ // whole panel down with it).
6960
+ var markDense = prefs.density === "compact";
6961
+ markRowsEl = marks.length ? react_jsx_runtime.jsx("div", {
6962
+ key: "marks",
6963
+ style: { padding: "2px 0 8px" },
6964
+ children: marks.map(function (e, i) {
6965
+ var lvl = Math.min(6, Math.max(1, e.level || 1));
6966
+ return react_jsx_runtime.jsx("div", {
6967
+ "data-dqt-markrow": "1",
6968
+ onClick: function () { jump(e.key, e.idx); },
6969
+ title: e.title || "",
6970
+ style: {
6971
+ display: "flex", alignItems: "center", gap: 6,
6972
+ padding: markDense ? "2px 8px 2px 7px" : "4px 8px 4px 7px",
6973
+ borderRadius: 6, cursor: "pointer",
6974
+ fontSize: 12.5, color: C.text,
6975
+ transition: "background-color 0.15s ease"
6976
+ },
6977
+ onMouseEnter: function (ev) { ev.currentTarget.style.backgroundColor = C.hover; },
6978
+ onMouseLeave: function (ev) { ev.currentTarget.style.backgroundColor = "transparent"; },
6979
+ children: [
6980
+ react_jsx_runtime.jsx("span", {
6981
+ key: "f", style: { color: C.accent, display: "flex", flex: "none" },
6982
+ children: react_jsx_runtime.jsx("svg", {
6983
+ width: 7, height: 9, viewBox: "0 0 7 9", fill: "currentColor",
6984
+ style: { display: "block" },
6985
+ children: react_jsx_runtime.jsx("path", { d: "M0 0h5.6L4.1 2.2l1.5 2.2H1.1V9H0z" })
6986
+ })
6987
+ }),
6988
+ react_jsx_runtime.jsx("span", {
6989
+ key: "l", style: { flex: "none", fontSize: 10, color: C.muted, width: 16 },
6990
+ children: "H" + lvl
6991
+ }),
6992
+ react_jsx_runtime.jsx("span", {
6993
+ key: "t", style: { flex: "1 1 auto", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
6994
+ children: e.title || ""
6995
+ })
6996
+ ]
6997
+ }, "mark-row-" + i);
6998
+ })
6999
+ }) : react_jsx_runtime.jsx("div", {
7000
+ key: "marks-empty",
7001
+ style: {
7002
+ display: "flex", alignItems: "center", justifyContent: "center",
7003
+ padding: "30px 16px", color: C.muted, fontSize: 11.5, lineHeight: "17px",
7004
+ textAlign: "center"
7005
+ },
7006
+ children: T("marks.empty")
7007
+ });
7008
+ }
6408
7009
  var outlineEl = renderGroups(shownGroups, shownTrees, jump, C, Math.max(0, groups.length - visibleCount), activeGroup,
6409
7010
  questionsOnly ? Object.assign({}, renderExtra, { questionsOnly: true }) : renderExtra);
7011
+ // M: an empty outline used to be a blank box, which reads as "this is broken"
7012
+ // rather than "there is nothing here yet". One quiet line plus a faded glyph
7013
+ // says which it is. A turn with no headings still becomes a group, so this
7014
+ // only appears for a conversation that has nothing to outline AT ALL.
7015
+ var emptyEl = !resultsEl && shownGroups.length === 0 ? react_jsx_runtime.jsx("div", {
7016
+ key: "empty",
7017
+ style: {
7018
+ display: "flex", flexDirection: "column", alignItems: "center",
7019
+ justifyContent: "center", gap: 8, padding: "34px 18px",
7020
+ color: C.muted, textAlign: "center"
7021
+ },
7022
+ children: [
7023
+ react_jsx_runtime.jsx("svg", {
7024
+ key: "e-ico", width: 26, height: 26, viewBox: "0 0 24 24", fill: "none",
7025
+ stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round",
7026
+ style: { opacity: 0.5, display: "block" },
7027
+ children: [
7028
+ react_jsx_runtime.jsx("line", { key: "a", x1: 4, y1: 6, x2: 20, y2: 6 }),
7029
+ react_jsx_runtime.jsx("line", { key: "b", x1: 4, y1: 12, x2: 15, y2: 12 }),
7030
+ react_jsx_runtime.jsx("line", { key: "c", x1: 4, y1: 18, x2: 11, y2: 18 })
7031
+ ]
7032
+ }),
7033
+ react_jsx_runtime.jsx("div", {
7034
+ key: "e-txt",
7035
+ style: { fontSize: 11.5, lineHeight: "17px", opacity: 0.85, maxWidth: 210 },
7036
+ children: T("outline.empty")
7037
+ })
7038
+ ]
7039
+ }) : null;
6410
7040
  // The curtain keeps the outline in the middle of the surface (its own column,
6411
7041
  // centred with room to spare at both sides) and pushes the search in from the
6412
7042
  // right; the docked panel keeps its original "results replace the list".
7043
+ // The bookmark view rides ON TOP of the outline instead of replacing it, and
7044
+ // the outline is only hidden (visibility, not display): it keeps its height,
7045
+ // so the scroller keeps its offset and closing the view lands the reader
7046
+ // exactly where they were. Replacing the content clamped the scroll to the top
7047
+ // the moment the shorter list rendered, and putting the offset back afterwards
7048
+ // was a race the browser usually won.
7049
+ var marksOverlayEl = null;
7050
+ if (onlyMarks && !resultsEl) {
7051
+ marksOverlayEl = react_jsx_runtime.jsx("div", {
7052
+ className: "dqt-marks-overlay",
7053
+ style: {
7054
+ position: "absolute",
7055
+ left: 0,
7056
+ // the curtain's search column keeps its own strip: the overlay covers the
7057
+ // outline, not the hits
7058
+ right: sheetOpen && sheetColW > 0 ? sheetColW : 0,
7059
+ top: 0,
7060
+ bottom: 0,
7061
+ overflowY: "auto",
7062
+ overflowAnchor: "none",
7063
+ padding: sheetOpen ? "0 20px 8px" : "0 8px 6px",
7064
+ direction: (sheetOpen || dockRight) ? "ltr" : "rtl"
7065
+ },
7066
+ children: react_jsx_runtime.jsx("div", { style: { direction: "ltr" }, children: markRowsEl })
7067
+ });
7068
+ }
7069
+ // across the curtain the results live in their own column, so the outline stays
7070
+ // where it is; only the docked panel swaps the list for the hits
6413
7071
  var listChildren = sheetOpen
6414
- ? [outlineEl, hintEl]
6415
- : (resultsEl ? [resultsEl, hintEl] : outlineEl);
7072
+ ? [outlineEl || emptyEl, hintEl]
7073
+ : (resultsEl ? [resultsEl, hintEl] : (outlineEl || emptyEl));
6416
7074
  var listEl = react_jsx_runtime.jsx("div", {
6417
7075
  ref: listRef,
6418
7076
  className: "dqt-list",
@@ -6422,6 +7080,9 @@ window.__ModuleLoader__.load({
6422
7080
  role: "group",
6423
7081
  "aria-label": T("a11y.list"),
6424
7082
  style: {
7083
+ // hidden rather than unmounted while the bookmark view is up (see above):
7084
+ // it keeps its height and therefore its scroll offset
7085
+ visibility: onlyMarks && !resultsEl ? "hidden" : undefined,
6425
7086
  overflowY: "auto",
6426
7087
  // We are the only thing that holds the reader's place in this list (see the
6427
7088
  // paging block): the browser's own scroll anchoring would also adjust
@@ -6434,6 +7095,11 @@ window.__ModuleLoader__.load({
6434
7095
  // header that many pixels below the toolbar (a visible gap). Spacing
6435
7096
  // between groups comes from the per-group divider instead.
6436
7097
  padding: sheetOpen ? "0 20px 8px" : "0 8px 6px",
7098
+ // the rows dissolve at BOTH edges instead of being cut by a hard line. The
7099
+ // top fade is safe now that the day strip lives outside the scroller: it
7100
+ // thins the rows approaching the strip, never the label itself.
7101
+ maskImage: "linear-gradient(to bottom, transparent 0, #000 12px, #000 calc(100% - 22px), transparent 100%)",
7102
+ WebkitMaskImage: "linear-gradient(to bottom, transparent 0, #000 12px, #000 calc(100% - 22px), transparent 100%)",
6437
7103
  flex: "1 1 auto",
6438
7104
  minHeight: 0,
6439
7105
  // across the curtain the list does not touch the edges: it is a centred
@@ -6499,10 +7165,36 @@ window.__ModuleLoader__.load({
6499
7165
  var showBack = awayFromRestore(listRef.current);
6500
7166
  // the outline and the search column share one row in BOTH layouts, so the list
6501
7167
  // is never moved between parents when the curtain opens or closes
7168
+ var dayStripEl = react_jsx_runtime.jsx("div", {
7169
+ "data-dqt-daystrip": "1",
7170
+ style: {
7171
+ flex: "none",
7172
+ // the space is reserved even while empty, so the list never jumps when
7173
+ // the first label scrolls under the edge. A STRING on purpose: the group
7174
+ // header's own height is the number 18, and several checks use that to
7175
+ // find header rows — the strip must not be mistaken for one.
7176
+ height: "18px",
7177
+ display: "flex",
7178
+ alignItems: "center",
7179
+ padding: sheetOpen ? "0 20px" : "0 10px",
7180
+ fontSize: 11,
7181
+ lineHeight: "14px",
7182
+ color: C.muted,
7183
+ whiteSpace: "nowrap",
7184
+ overflow: "hidden",
7185
+ textOverflow: "ellipsis",
7186
+ pointerEvents: "none"
7187
+ },
7188
+ // the bookmark view is a different list with its own scroll: the day label
7189
+ // would go on showing the last day the outline was scrolled to
7190
+ children: onlyMarks ? "" : dayStripText
7191
+ });
6502
7192
  var bodyRowEl = react_jsx_runtime.jsx("div", {
6503
7193
  className: "dqt-body-row",
6504
- style: { flex: "1 1 auto", minHeight: 0, display: "flex", flexDirection: "row", alignItems: "stretch" },
6505
- children: [listEl, sheetSearchColEl]
7194
+ // `position: relative` is what lets the bookmark overlay cover the list
7195
+ // without becoming part of its scrolled content
7196
+ style: { position: "relative", flex: "1 1 auto", minHeight: 0, display: "flex", flexDirection: "row", alignItems: "stretch" },
7197
+ children: [listEl, marksOverlayEl, sheetSearchColEl]
6506
7198
  });
6507
7199
  // one geometry for the panel and its shadow twin, merged (never duplicated)
6508
7200
  var mergeStyle = function (base, extra) {
@@ -6520,7 +7212,7 @@ window.__ModuleLoader__.load({
6520
7212
  maxHeight: zLen(vMaxH),
6521
7213
  height: panelH > 0 ? zLen(panelH) : undefined,
6522
7214
  zoom: zoom === 1 ? undefined : zoom,
6523
- borderRadius: 12,
7215
+ borderRadius: RADIUS_PANEL,
6524
7216
  transform: panelTransform,
6525
7217
  transition: viewFading ? "opacity 0.3s ease" : panelTransition,
6526
7218
  // held back while the edge handle slips home (see edgeLeaving): the panel is
@@ -6556,6 +7248,51 @@ window.__ModuleLoader__.load({
6556
7248
  var shadowClip = dockRight
6557
7249
  ? "inset(" + zLen(-SHADOW_BLEED) + " " + zLen(shadowWipe - SHADOW_REACH) + " " + zLen(-SHADOW_BLEED) + " " + zLen(geoOpen ? -SHADOW_BLEED : SHADOW_REACH) + ")"
6558
7250
  : "inset(" + zLen(-SHADOW_BLEED) + " " + zLen(geoOpen ? -SHADOW_BLEED : -SHADOW_REACH) + " " + zLen(-SHADOW_BLEED) + " " + zLen(shadowWipe - SHADOW_REACH) + ")";
7251
+ // The panel's blur lives OUTSIDE the clipping wrapper (see the portal list):
7252
+ // inside it, any backdrop-filter promotes a layer in that subtree and the
7253
+ // panel drops beneath the conversation. As a body-level twin with the same
7254
+ // geometry it samples the page normally, and the panel's own translucent
7255
+ // fill is painted over it.
7256
+ // A body-level blur twin was tried and removed: it composites as a SECOND
7257
+ // pass, so the panel's own translucency showed the page straight through
7258
+ // while the twin showed it blurred — the reader sees "one blurred layer and
7259
+ // one clear layer" stacked. A surface can only read as ONE material when the
7260
+ // blur and the fill are painted by the same element, and that is impossible
7261
+ // for the docked panel where it currently lives (a backdrop-filter anywhere
7262
+ // inside its clipping wrapper drops the panel under the conversation).
7263
+ // The blur twin. It lives as a DIRECT child of the clipping shell — the same
7264
+ // depth as the curtain, which is the only place in this subtree where a
7265
+ // backdrop-filter has been observed to sample the page without dragging the
7266
+ // panel under the conversation. The panel's own fill is painted over it, so
7267
+ // a twin that fails costs the blur only.
7268
+ var panelGlassTwinEl = (!sheetOpen && C.glassOn && panelBlurH > 0)
7269
+ ? react_jsx_runtime.jsx("div", {
7270
+ "data-dqt-panel-glass": "1",
7271
+ style: mergeStyle(panelGeoStyle, {
7272
+ zIndex: Z_PANEL - 1,
7273
+ height: zLen(panelBlurH),
7274
+ background: "transparent",
7275
+ backdropFilter: C.panelBlur,
7276
+ WebkitBackdropFilter: C.panelBlur,
7277
+ opacity: panelOpacity,
7278
+ pointerEvents: "none",
7279
+ borderRadius: RADIUS_PANEL,
7280
+ clipPath: panelClip
7281
+ })
7282
+ })
7283
+ : null;
7284
+ // NO live blur on the docked panel itself — settled by experiment, not preference.
7285
+ // Every placement was tried in the real host: on the panel itself, on a
7286
+ // child layer, and on a sibling twin behind it. All three made the panel
7287
+ // disappear, and a child layer inside the panel's own `opacity` group
7288
+ // produced no blur at all (an element with opacity is a backdrop root, so
7289
+ // the filter sampled that element's own content). The panel lives inside a
7290
+ // clipping, fixed wrapper that the host's compositor keeps below the
7291
+ // conversation; anything that promotes a layer in there loses the stack.
7292
+ // So the panel keeps a translucent FILL (which reads as glass on its own:
7293
+ // the rim, the gutter shadow and the see-through body do the work) and the
7294
+ // live blur stays on the curtain, the transient surface — exactly the split
7295
+ // Windows makes between Mica (persistent) and Acrylic (transient).
6559
7296
  var panelShadowEl = react_jsx_runtime.jsx("div", {
6560
7297
  "data-dqt-panel-shadow": "1",
6561
7298
  style: mergeStyle(panelGeoStyle, {
@@ -6564,9 +7301,14 @@ window.__ModuleLoader__.load({
6564
7301
  opacity: panelOpacity,
6565
7302
  pointerEvents: "none",
6566
7303
  clipPath: shadowClip,
6567
- boxShadow: isDarkScheme()
7304
+ // the halo is the panel's lift; the offset line adds the gutter shadow on
7305
+ // the conversation side, so the sheet reads as hovering over the transcript
7306
+ boxShadow: (isDarkScheme()
6568
7307
  ? "0 0 24px -4px rgba(255, 255, 255, 0.16), 0 0 10px -2px rgba(255, 255, 255, 0.10)"
6569
- : "0 0 24px -4px rgba(0, 0, 0, 0.38), 0 0 10px -2px rgba(0, 0, 0, 0.26)"
7308
+ : "0 0 24px -4px rgba(0, 0, 0, 0.38), 0 0 10px -2px rgba(0, 0, 0, 0.26)")
7309
+ + (dockRight
7310
+ ? ", -12px 0 20px -14px rgba(0, 0, 0, 0.45)"
7311
+ : ", 12px 0 20px -14px rgba(0, 0, 0, 0.45)")
6570
7312
  })
6571
7313
  });
6572
7314
  var panelEl = react_jsx_runtime.jsx("div", {
@@ -6593,7 +7335,9 @@ window.__ModuleLoader__.load({
6593
7335
  minHeight: 0,
6594
7336
  display: "flex",
6595
7337
  flexDirection: "column",
6596
- background: C.panelBg,
7338
+ // transparent on purpose: inside the curtain the CURTAIN is the glass, and
7339
+ // an opaque child would paint over it
7340
+ background: "transparent",
6597
7341
  border: "none",
6598
7342
  borderRadius: 0,
6599
7343
  zIndex: Z_PANEL,
@@ -6606,12 +7350,17 @@ window.__ModuleLoader__.load({
6606
7350
  } : mergeStyle(panelGeoStyle, {
6607
7351
  display: "flex",
6608
7352
  flexDirection: "column",
6609
- background: C.panelBg,
7353
+ // the filler colour is ALWAYS here (glassBg falls back to the opaque
7354
+ // panel colour where blur is unavailable); the blur rides on the child
7355
+ background: C.glassBg,
6610
7356
  border: "none",
6611
7357
  zIndex: Z_PANEL,
6612
7358
  overflow: "hidden",
6613
7359
  color: C.text,
6614
7360
  opacity: panelOpacity,
7361
+ // makes this element a stacking context so the glass layer's z-index:-1
7362
+ // stays inside it (behind the rows, in front of the page)
7363
+ isolation: "isolate",
6615
7364
  clipPath: panelClip,
6616
7365
  pointerEvents: chatViewActive ? "auto" : "none"
6617
7366
  }),
@@ -6656,6 +7405,13 @@ window.__ModuleLoader__.load({
6656
7405
  // the docked panel keeps its search band under the header; across the
6657
7406
  // curtain the same row lives at the top of the pushed-in search column
6658
7407
  sheetOpen ? null : searchRowEl,
7408
+ // the day strip: the "which day am I in" label, OUTSIDE the scroller.
7409
+ // A sticky label would have to be opaque to hide the rows passing under
7410
+ // it, and an opaque strip on a translucent surface is the band that reads
7411
+ // as a cut. Out here it needs no fill at all — it is just text on the
7412
+ // same material — while still staying put for as long as the reader
7413
+ // scrolls, and the list fades its first rows out before they reach it.
7414
+ dayStripEl,
6659
7415
  bodyRowEl,
6660
7416
  // "back to the newest row": floats over the list's lower-right corner and
6661
7417
  // fades in the moment the newest turns fall below the fold. Kept MOUNTED
@@ -7058,6 +7814,11 @@ window.__ModuleLoader__.load({
7058
7814
  // takes pointer events, so its text can be selected and its pin can be clicked);
7059
7815
  // the hover card stays click-through except for its pin button.
7060
7816
  var renderPreviewCard = function (card, opts) {
7817
+ // the per-render palette (accent + glass) has to be handed in: this body
7818
+ // closed over the module-level C, whose glass fields are undefined, so the
7819
+ // card silently fell back to the opaque colour while everything else was
7820
+ // translucent
7821
+ var P = (opts && opts.palette) || C;
7061
7822
  var cardW = 264;
7062
7823
  var top = Math.max(8, Math.min(card.top - 4, window.innerHeight - 210));
7063
7824
  var left = dockRight
@@ -7088,7 +7849,7 @@ window.__ModuleLoader__.load({
7088
7849
  style: { display: "flex", alignItems: "flex-start", gap: 6 },
7089
7850
  children: [
7090
7851
  react_jsx_runtime.jsx("div", {
7091
- style: { flex: "1 1 auto", minWidth: 0, fontSize: 12, fontWeight: 600, color: C.text, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
7852
+ style: { flex: "1 1 auto", minWidth: 0, fontSize: 12, fontWeight: 600, color: P.text, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
7092
7853
  children: card.title
7093
7854
  }, "hc-t"),
7094
7855
  react_jsx_runtime.jsx("button", {
@@ -7111,8 +7872,8 @@ window.__ModuleLoader__.load({
7111
7872
  justifyContent: "center",
7112
7873
  border: "none",
7113
7874
  borderRadius: 6,
7114
- background: opts.pinned ? C.hover : "transparent",
7115
- color: opts.pinned ? C.text : C.muted,
7875
+ background: opts.pinned ? P.hover : "transparent",
7876
+ color: opts.pinned ? P.text : P.muted,
7116
7877
  cursor: "pointer",
7117
7878
  // the hover card is click-through except for this button, so reaching
7118
7879
  // for it does not depend on the row staying hovered
@@ -7128,17 +7889,17 @@ window.__ModuleLoader__.load({
7128
7889
  ];
7129
7890
  if (card.meta) {
7130
7891
  kids.push(react_jsx_runtime.jsx("div", {
7131
- style: { fontSize: 10, color: C.muted, opacity: 0.8, marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
7892
+ style: { fontSize: 10, color: P.muted, opacity: 0.8, marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
7132
7893
  children: card.meta
7133
7894
  }, "hc-m"));
7134
7895
  }
7135
7896
  kids.push(react_jsx_runtime.jsx("div", {
7136
- style: { fontSize: 11, lineHeight: "16px", color: C.text, opacity: 0.9, marginTop: 4, maxHeight: 116, overflow: "hidden" },
7897
+ style: { fontSize: 11, lineHeight: "16px", color: P.text, opacity: 0.9, marginTop: 4, maxHeight: 116, overflow: "hidden" },
7137
7898
  children: body || (card.ghost ? T("turn.loadTip") : "")
7138
7899
  }, "hc-b"));
7139
7900
  if (card.ghost) {
7140
7901
  kids.push(react_jsx_runtime.jsx("div", {
7141
- style: { fontSize: 10, color: C.muted, opacity: 0.7, marginTop: 4 },
7902
+ style: { fontSize: 10, color: P.muted, opacity: 0.7, marginTop: 4 },
7142
7903
  children: T("preview.truncated")
7143
7904
  }, "hc-n"));
7144
7905
  }
@@ -7159,12 +7920,18 @@ window.__ModuleLoader__.load({
7159
7920
  left: left,
7160
7921
  width: cardW,
7161
7922
  padding: "8px 10px",
7162
- borderRadius: 8,
7923
+ borderRadius: 10,
7163
7924
  cornerShape: "round",
7164
- background: C.panelBg,
7165
- color: C.text,
7166
- border: "1px solid " + C.panelBorder,
7167
- boxShadow: "0 8px 24px rgba(0,0,0,0.28)",
7925
+ // The same material as the panel: the same translucent fill, the same
7926
+ // rim, and the same SMALL radius of blur — the card floats in this
7927
+ // portal (no opacity group, nobody's parent), so its backdrop-filter
7928
+ // samples the page exactly like the curtain's.
7929
+ background: P.glassOn ? P.glassBg : P.panelBg,
7930
+ backdropFilter: P.glassOn ? P.panelBlur : undefined,
7931
+ WebkitBackdropFilter: P.glassOn ? P.panelBlur : undefined,
7932
+ color: P.text,
7933
+ border: "1px solid " + P.panelBorder,
7934
+ boxShadow: P.glassOn ? rimInset() + ", 0 8px 24px rgba(0,0,0,0.28)" : "0 8px 24px rgba(0,0,0,0.28)",
7168
7935
  // above the panel and the curtain — it is a sibling of both in this
7169
7936
  // portal, and both sit below it in the layer band we claim
7170
7937
  zIndex: Z_HOVER + (opts.pinned ? 1 : 0),
@@ -7182,21 +7949,70 @@ window.__ModuleLoader__.load({
7182
7949
  // and it only leaves the screen through the same fade the hover card uses.
7183
7950
  var pinnedVisible = !!(pinnedCard && open);
7184
7951
  var pinnedEl = pinnedVisible
7185
- ? renderPreviewCard(pinnedCard, { pinned: true, closing: pinnedClosing })
7952
+ ? renderPreviewCard(pinnedCard, { pinned: true, closing: pinnedClosing, palette: C })
7186
7953
  : null;
7187
7954
  var hoverEl = null;
7188
- if (hoverCard && open) hoverEl = renderPreviewCard(hoverCard, { pinned: false, closing: hoverClosing });
7955
+ if (hoverCard && open) hoverEl = renderPreviewCard(hoverCard, { pinned: false, closing: hoverClosing, palette: C });
7189
7956
 
7190
7957
  // ---- the row menu ---------------------------------------------------------
7191
7958
  // Small, fixed-position, keyboard-reachable: ↑/↓ move, Enter runs, Esc closes.
7192
7959
  var rowMenuEl = null;
7193
7960
  if (rowMenu && open) {
7194
7961
  var menuW = 208;
7195
- var menuH = 76;
7196
- var menuLeft = Math.max(8, Math.min(rowMenu.x, window.innerWidth - menuW - 8));
7197
- var menuTop = Math.max(8, Math.min(rowMenu.y, window.innerHeight - menuH - 8));
7962
+ var menuLeft;
7963
+ var menuTop;
7964
+ // NOTE: the height (and therefore the clamped top) can only be known once
7965
+ // the item list exists — computing it above the list dereferenced a hoisted
7966
+ // `var` and threw on every open.
7967
+ // Two sources open this menu: an outline row and a time row (the group
7968
+ // header). A time row has no preview to pin and no single heading to copy,
7969
+ // so those two entries are absent there; everything else is identical.
7970
+ var menuIsGroup = !!(rowMenu.info && rowMenu.info.kind === "group");
7971
+ var menuCanCopySection = menuIsGroup
7972
+ ? !!(rowMenu.info && rowMenu.info.canCopy)
7973
+ : !!(rowMenu.info && rowMenu.info.hasKids);
7198
7974
  var menuItems = [
7199
7975
  {
7976
+ id: "goto",
7977
+ label: T("menu.goto"),
7978
+ run: function () {
7979
+ var key = rowMenu.key;
7980
+ var idx = rowMenu.idx;
7981
+ closeRowMenu();
7982
+ jump(key, idx === undefined ? 0 : idx);
7983
+ }
7984
+ },
7985
+ {
7986
+ id: "mark",
7987
+ label: isMarked(rowMenu.key, rowMenu.idx) ? T("menu.unmark") : T("menu.mark"),
7988
+ run: function () {
7989
+ toggleMark(rowMenu.key, rowMenu.idx, rowMenu.info && rowMenu.info.title,
7990
+ menuIsGroup ? 0 : (rowMenu.info && rowMenu.info.level));
7991
+ closeRowMenu();
7992
+ }
7993
+ },
7994
+ { id: "sep1" },
7995
+ menuIsGroup ? null : {
7996
+ id: "copyTitle",
7997
+ label: copiedId === "copyTitle" ? T("menu.copied") : T("menu.copyTitle"),
7998
+ run: function () {
7999
+ copyText((rowMenu.info && rowMenu.info.title) || "");
8000
+ flashCopied("copyTitle");
8001
+ }
8002
+ },
8003
+ // only when there really is an outline below it, so the entry never
8004
+ // copies the same text the entry above it already copies
8005
+ menuCanCopySection ? {
8006
+ id: "copySection",
8007
+ label: copiedId === "copySection" ? T("menu.copied") : T("menu.copySection"),
8008
+ run: function () {
8009
+ var info = rowMenu.info;
8010
+ copyText(info && typeof info.mdOf === "function" ? info.mdOf() : ((info && info.title) || ""));
8011
+ flashCopied("copySection");
8012
+ }
8013
+ } : null,
8014
+ { id: "sep2" },
8015
+ menuIsGroup ? null : {
7200
8016
  id: "pin",
7201
8017
  label: isPinned(rowMenu.key) ? T("menu.unpin") : T("menu.pin"),
7202
8018
  run: function () {
@@ -7226,7 +8042,23 @@ window.__ModuleLoader__.load({
7226
8042
  if (typeof endJumpRow === "function") endJumpRow(key, idx === undefined ? 0 : idx);
7227
8043
  }
7228
8044
  }
7229
- ];
8045
+ ].filter(function (it, i, arr) {
8046
+ // drop the entries this source does not offer, then collapse the
8047
+ // separators that ended up adjacent or at either end
8048
+ if (!it) return false;
8049
+ if (it.id.indexOf("sep") !== 0) return true;
8050
+ var prev = null;
8051
+ for (var p = i - 1; p >= 0; p--) { if (arr[p]) { prev = arr[p]; break; } }
8052
+ var next = null;
8053
+ for (var q = i + 1; q < arr.length; q++) { if (arr[q]) { next = arr[q]; break; } }
8054
+ if (!prev || !next) return false;
8055
+ if (prev.id.indexOf("sep") === 0) return false;
8056
+ return true;
8057
+ });
8058
+ // now that the list exists: keep the menu inside the viewport
8059
+ var menuH = 12 + menuItems.length * 30;
8060
+ menuLeft = Math.max(8, Math.min(rowMenu.x, window.innerWidth - menuW - 8));
8061
+ menuTop = Math.max(8, Math.min(rowMenu.y, window.innerHeight - menuH - 8));
7230
8062
  rowMenuEl = react_jsx_runtime.jsx("div", {
7231
8063
  "data-dqt-menu": "on",
7232
8064
  className: "dqt-rowmenu" + (menuClosing ? " dqt-rowmenu-closing" : ""),
@@ -7247,6 +8079,12 @@ window.__ModuleLoader__.load({
7247
8079
  pointerEvents: menuClosing ? "none" : "auto"
7248
8080
  },
7249
8081
  children: menuItems.map(function (item) {
8082
+ if (item.id.indexOf("sep") === 0) {
8083
+ return react_jsx_runtime.jsx("div", {
8084
+ "data-dqt-menu-sep": "1",
8085
+ style: { height: 1, background: C.panelBorder, margin: "4px 6px", opacity: 0.7 }
8086
+ }, "menu-" + item.id);
8087
+ }
7250
8088
  return react_jsx_runtime.jsx("button", {
7251
8089
  role: "menuitem",
7252
8090
  "data-dqt-menu-item": item.id,
@@ -7283,6 +8121,30 @@ window.__ModuleLoader__.load({
7283
8121
  // bottom shadow lives (the clip would otherwise cut it off).
7284
8122
  var SHEET_SHADOW_ROOM = 24;
7285
8123
  var sheetUp = sheetOpen && chatViewActive;
8124
+ // The curtain's resize edge: a 10px strip along its bottom, with the pill
8125
+ // that says "this can be pulled". Only rendered while the curtain is up.
8126
+ var sheetGripEl = react_jsx_runtime.jsx("div", {
8127
+ "data-dqt-sheet-grip": "1",
8128
+ onPointerDown: sheetResizeStart,
8129
+ onPointerMove: sheetResizeMove,
8130
+ onPointerUp: sheetResizeEnd,
8131
+ onPointerCancel: sheetResizeEnd,
8132
+ title: T("sheet.resize"),
8133
+ style: {
8134
+ flex: "none", height: 10, width: "100%", cursor: "row-resize",
8135
+ touchAction: "none", display: "flex", alignItems: "center",
8136
+ justifyContent: "center", background: "transparent"
8137
+ },
8138
+ children: react_jsx_runtime.jsx("span", {
8139
+ style: {
8140
+ display: "block", width: 42, height: 4, borderRadius: 999,
8141
+ cornerShape: "round",
8142
+ // the one decorative spot that follows the theme colour: the grip is
8143
+ // the curtain's own control, and a neutral grey pill read as disabled
8144
+ background: C.accent, opacity: 0.35
8145
+ }
8146
+ })
8147
+ });
7286
8148
  var sheetShellEl = react_jsx_runtime.jsx("div", {
7287
8149
  key: "sheet-shell",
7288
8150
  className: "dqt-sheet-clip",
@@ -7333,21 +8195,37 @@ window.__ModuleLoader__.load({
7333
8195
  height: 0,
7334
8196
  overflow: "visible"
7335
8197
  },
7336
- children: react_jsx_runtime.jsx("div", {
8198
+ // the panel's blur twin rides here, as a sibling of the curtain: the only
8199
+ // depth in this subtree where a backdrop-filter has been seen to work
8200
+ children: [panelGlassTwinEl, react_jsx_runtime.jsx("div", {
7337
8201
  ref: sheetRef,
7338
8202
  className: sheetUp ? "dqt-sheet dqt-sheet-open" : "dqt-sheet-idle",
7339
8203
  "data-sheet": sheetUp ? "on" : undefined,
7340
8204
  style: {
7341
8205
  width: sheetUp ? "100%" : 0,
7342
- // the bottom leaves the shadow room the clip box grew by
7343
- height: sheetUp ? "calc(100% - " + SHEET_SHADOW_ROOM + "px)" : 0,
8206
+ // the bottom leaves the shadow room the clip box grew by; the fraction
8207
+ // is the reader's own height (dragged from the bottom edge, remembered)
8208
+ height: sheetUp
8209
+ ? "calc((100% - " + SHEET_SHADOW_ROOM + "px) * " + (sheetDragFrac === null ? prefs.sheetHeight : sheetDragFrac) + ")"
8210
+ : 0,
7344
8211
  // border-box so the 1px edges stay INSIDE the measured height: with the
7345
8212
  // old content-box the bottom pair sat below it and the clip cut them
7346
8213
  // away, which is why the bottom edge had no visible line
7347
8214
  boxSizing: "border-box",
7348
8215
  display: "flex",
7349
8216
  flexDirection: "column",
7350
- background: C.panelBg,
8217
+ // The curtain is the transient surface (Windows' "Acrylic" case): the
8218
+ // live blur belongs here, where it is dismissed rather than lived in.
8219
+ // GATED ON sheetUp — this element is ALSO the docked panel's parent
8220
+ // while the curtain is away (height 0, yet still an ancestor), and a
8221
+ // backdrop-filter here promoted the whole subtree and dropped the
8222
+ // panel beneath the conversation. That was the real "panel disappears"
8223
+ // bug: the panel itself had no filter at all in that build.
8224
+ background: sheetUp ? C.sheetBg : "transparent",
8225
+ // L: a light/dark switch eases the material instead of snapping it
8226
+ transition: "background-color 0.25s ease",
8227
+ backdropFilter: sheetUp && C.glassOn ? C.sheetBlur : undefined,
8228
+ WebkitBackdropFilter: sheetUp && C.glassOn ? C.sheetBlur : undefined,
7351
8229
  color: C.text,
7352
8230
  border: "none",
7353
8231
  borderTop: sheetUp ? "1px solid " + C.panelBorder : "none",
@@ -7358,17 +8236,25 @@ window.__ModuleLoader__.load({
7358
8236
  // no dimming layer — the conversation below stays legible and usable.
7359
8237
  // The outer shadow is the bottom edge's own: it follows the rounded
7360
8238
  // bottom corners and stops before the vertical seams.
7361
- borderRadius: "0 0 12px 12px",
7362
- boxShadow: sheetUp ? C.sheetShadow : "none",
8239
+ borderRadius: "0 0 " + RADIUS_SHEET + "px " + RADIUS_SHEET + "px",
8240
+ // the drop shadow lifts the sheet off the conversation; the inset pair
8241
+ // is the rim that says "this is a surface, not a haze"
8242
+ boxShadow: sheetUp ? (rimInset() + ", " + C.sheetShadow) : "none",
7363
8243
  overflow: "hidden"
7364
8244
  },
7365
- children: sheetOpen ? panelEl : [panelShadowEl, panelEl]
7366
- })
8245
+ // inside the curtain the SHEET is the glass (it has no opacity of its own,
8246
+ // so its backdrop-filter samples the page): no second twin here.
8247
+ // The grip is the curtain's own bottom edge: drag it to set the height.
8248
+ children: sheetOpen ? [panelEl, sheetGripEl] : [panelShadowEl, panelEl]
8249
+ })]
7367
8250
  });
7368
8251
 
7369
8252
  return react_dom.createPortal(
7370
8253
  react_jsx_runtime.jsx(ErrorBoundary, {
7371
- children: [sheetShellEl, edgeEl, topHandleEl, pinnedEl, hoverEl, rowMenuEl]
8254
+ // panelGlassTwinEl sits OUTSIDE the clipping wrapper on purpose: it is
8255
+ // the only place a backdrop-filter can sample the page without dragging
8256
+ // the docked panel under the conversation
8257
+ children: [panelGlassTwinEl, sheetShellEl, edgeEl, topHandleEl, pinnedEl, hoverEl, rowMenuEl]
7372
8258
  }),
7373
8259
  document.body
7374
8260
  );
@@ -7463,7 +8349,16 @@ window.__ModuleLoader__.load({
7463
8349
  ghost: false,
7464
8350
  // what pinning and the row menu need to act on this very row
7465
8351
  key: n.key,
7466
- idx: n.idx
8352
+ idx: n.idx,
8353
+ level: n.level || 1,
8354
+ kind: "row",
8355
+ // "copy this section" is only offered when there IS a section below it:
8356
+ // for a heading with no sub-headings the entry would copy nothing but the
8357
+ // heading itself, which is what "copy heading" already does.
8358
+ hasKids: !!(n.children && n.children.length),
8359
+ // Built only if the reader actually picks "copy this section": walking a
8360
+ // subtree on every row of every render would be wasted work on a long log.
8361
+ mdOf: function () { return markdownOfNode(n).join("\n"); }
7467
8362
  };
7468
8363
  var rows;
7469
8364
  if (wide) {
@@ -7532,6 +8427,69 @@ window.__ModuleLoader__.load({
7532
8427
  // round backdrop appears, so the two boxes used to sit flush against each other
7533
8428
  // (and on a long row the highlight ran underneath the button). This layer stops
7534
8429
  // END_ZONE short of that edge, leaving a clear gap.
8430
+ // G: a hairline guide at this row's own indent, so H2/H3 read as children
8431
+ // without moving the text. Only rows below H1 carry one; the guide sits just
8432
+ // left of where the row's text starts.
8433
+ // The guide column depends on the LEVEL only. It must not share the text's
8434
+ // base inset: that inset differs for rows with children (2/4px) and without
8435
+ // (6/8px), which put rows of the same level 4px apart and made the lines
8436
+ // visibly ragged.
8437
+ var guideStep = wide ? 16 : 12;
8438
+ var guideOrigin = (wide ? 8 : 6) + MARK_INSET;
8439
+ if (n.level > 1) {
8440
+ rows.unshift(react_jsx_runtime.jsx("div", {
8441
+ "data-dqt-guide": "1",
8442
+ style: {
8443
+ position: "absolute",
8444
+ left: guideOrigin + (n.level - 2) * guideStep,
8445
+ top: 0,
8446
+ bottom: 0,
8447
+ width: 1,
8448
+ background: C.panelBorder,
8449
+ pointerEvents: "none",
8450
+ // the row leans in on hover; the guide column must not, or the line
8451
+ // visibly slides sideways every time the pointer crosses a row
8452
+ transition: "transform 0.15s ease"
8453
+ }
8454
+ }, uid + "-gd"));
8455
+ }
8456
+ // B: the bookmark. A ribbon, not a dot: a tab whose left edge is flush with
8457
+ // the row's left margin and whose right edge is cut inwards to a point — the
8458
+ // shape of a marker sticking out of the page, so it reads as "saved" rather
8459
+ // than as a bullet.
8460
+ //
8461
+ // ALWAYS RENDERED, faded by opacity: a layer that is created and destroyed pops
8462
+ // in and out, and saving a place is exactly the kind of small action that should
8463
+ // breathe (everything else in this panel fades).
8464
+ //
8465
+ // The colour is a concrete hex taken from the theme table, not a `var()`:
8466
+ // an SVG fill attribute silently rejects a var and paints black, and a colour
8467
+ // that has to be resolved by the compositor inside a clipped layer is one more
8468
+ // chance for that to happen again.
8469
+ var marked = !!(extra && extra.isMarked && extra.isMarked(n.key, n.idx));
8470
+ rows.unshift(react_jsx_runtime.jsx("div", {
8471
+ "data-dqt-mark": marked ? "1" : undefined,
8472
+ style: {
8473
+ position: "absolute",
8474
+ // Inside the row's own box on purpose: the row clips its content (it
8475
+ // needs `overflow: hidden` for the title's ellipsis), so a ribbon hung
8476
+ // outside it was cut down to a sliver. The row itself is pulled left
8477
+ // instead (see marginLeft below), which is what puts this at the panel
8478
+ // edge while still being painted by the row.
8479
+ left: 0,
8480
+ top: "50%",
8481
+ transform: "translateY(-50%)",
8482
+ width: MARK_W,
8483
+ height: 12,
8484
+ background: (extra && extra.markColor) || C.accent,
8485
+ // flat on the panel side, a concave point cut into the other side
8486
+ clipPath: "polygon(0 0, 100% 0, 70% 50%, 100% 100%, 0 100%)",
8487
+ WebkitClipPath: "polygon(0 0, 100% 0, 70% 50%, 100% 100%, 0 100%)",
8488
+ opacity: marked ? 1 : 0,
8489
+ transition: "opacity 0.24s ease",
8490
+ pointerEvents: "none"
8491
+ }
8492
+ }, uid + "-mk"));
7535
8493
  rows.unshift(react_jsx_runtime.jsx("div", {
7536
8494
  "data-dqt-hl": "1",
7537
8495
  style: {
@@ -7555,6 +8513,11 @@ window.__ModuleLoader__.load({
7555
8513
  if (hl) hl.style.background = C.hover;
7556
8514
  var b = e.currentTarget.querySelector("[data-dqt-end]");
7557
8515
  if (b) b.style.opacity = "1";
8516
+ // I: the row leans in by one pixel — enough to feel alive, small enough
8517
+ // that the text never looks like it jumped
8518
+ e.currentTarget.style.transform = "translateX(1px)";
8519
+ var gd = e.currentTarget.querySelector("[data-dqt-guide]");
8520
+ if (gd) gd.style.transform = "translateX(-1px)";
7558
8521
  if (extra && extra.hoverStart) extra.hoverStart(hoverInfo, e.currentTarget);
7559
8522
  },
7560
8523
  onMouseLeave: function (e) {
@@ -7562,6 +8525,9 @@ window.__ModuleLoader__.load({
7562
8525
  if (hl) hl.style.background = "transparent";
7563
8526
  var b = e.currentTarget.querySelector("[data-dqt-end]");
7564
8527
  if (b) b.style.opacity = "0";
8528
+ e.currentTarget.style.transform = "translateX(0)";
8529
+ var gd2 = e.currentTarget.querySelector("[data-dqt-guide]");
8530
+ if (gd2) gd2.style.transform = "translateX(0)";
7565
8531
  if (extra && extra.hoverEnd) extra.hoverEnd();
7566
8532
  },
7567
8533
  onContextMenu: function (e) {
@@ -7578,10 +8544,20 @@ window.__ModuleLoader__.load({
7578
8544
  position: "relative",
7579
8545
  padding: dense ? (wide ? "1px 8px" : "0px 6px") : wide ? "4px 8px" : "2px 6px",
7580
8546
  paddingRight: END_ZONE,
7581
- paddingLeft: (hasChildren ? (wide ? 4 : 2) : (wide ? 8 : 6)) + (n.level - 1) * (wide ? 16 : 12),
8547
+ // the ribbon gutter is always reserved (see MARK_INSET): saving a row must
8548
+ // never move the text of that row, or of every other row after it
8549
+ paddingLeft: MARK_INSET + (hasChildren ? (wide ? 4 : 2) : (wide ? 8 : 6)) + (n.level - 1) * (wide ? 16 : 12),
7582
8550
  margin: dense ? "0 0" : "1px 0",
8551
+ // Reach back over the group box's left padding so the ribbon gutter sits
8552
+ // at the surface's edge. The group clips its contents, so this is as far
8553
+ // left as anything on a row can go — and the row is still inside the
8554
+ // group's box, so nothing is cut.
8555
+ marginLeft: -(wide ? 8 : 7),
7583
8556
  borderRadius: 6,
7584
8557
  cursor: "pointer",
8558
+ // I: a 1px nudge on hover (no reflow — transform only), plus the panel's
8559
+ // own background transition so a light/dark switch eases instead of snapping
8560
+ transition: "transform 0.15s ease, background-color 0.25s ease",
7585
8561
  fontSize: n.level <= 2 ? (wide ? 16.5 : 13) : (wide ? 15.5 : 12),
7586
8562
  color: n.level <= 2 ? C.text : C.muted,
7587
8563
  fontWeight: n.level <= 2 ? 600 : 400,
@@ -7858,6 +8834,10 @@ window.__ModuleLoader__.load({
7858
8834
  var lastKey = (g.msgs && g.msgs.length > 0) ? g.msgs[g.msgs.length - 1].key
7859
8835
  : (g.failure ? g.failure.key : "");
7860
8836
  return react_jsx_runtime.jsx("div", {
8837
+ // the day strip above the list reads these two attributes on scroll: the
8838
+ // label that has scrolled past the top edge is the one it shows
8839
+ "data-dqt-grouplabel": "1",
8840
+ "data-dqt-day": g.time !== undefined ? String(fmtStampOf(g.time)) : "",
7861
8841
  onMouseEnter: function (e) {
7862
8842
  var b = e.currentTarget.querySelector("[data-dqt-end]");
7863
8843
  if (b) b.style.opacity = "1";
@@ -7866,6 +8846,26 @@ window.__ModuleLoader__.load({
7866
8846
  var b = e.currentTarget.querySelector("[data-dqt-end]");
7867
8847
  if (b) b.style.opacity = "0";
7868
8848
  },
8849
+ // The time row takes the same menu as an outline row, minus the two entries
8850
+ // that need a single preview card / single heading (pin, copy heading).
8851
+ // "Copy this section" here means every heading under this turn.
8852
+ onContextMenu: function (e) {
8853
+ if (!extra || !extra.rowMenu) return;
8854
+ var anchorKey = replyKey || g.userKey || "";
8855
+ extra.rowMenu(e, {
8856
+ title: (g.time ? String(g.time) : "") + (g.userText ? " · " + g.userText : ""),
8857
+ sub: "",
8858
+ preview: "",
8859
+ meta: "",
8860
+ ghost: false,
8861
+ key: anchorKey,
8862
+ idx: 0,
8863
+ level: 0,
8864
+ kind: "group",
8865
+ canCopy: !!(g.headings && g.headings.length),
8866
+ mdOf: function () { return markdownOfGroup(g); }
8867
+ }, anchorKey, 0);
8868
+ },
7869
8869
  style: {
7870
8870
  // ONE row geometry for every group header, whether or not the turn has
7871
8871
  // headings: same box, same 18px rhythm, same left inset, so the time
@@ -7883,15 +8883,20 @@ window.__ModuleLoader__.load({
7883
8883
  display: "flex",
7884
8884
  alignItems: "center",
7885
8885
  minWidth: 0,
7886
- // sticky: while you scroll the outline, the header of the group you are
7887
- // inside stays pinned at the top. It must be opaque to cover the rows
7888
- // scrolling underneath, so its base colour is the panel background in both
7889
- // states — the "current group" tint rides on its own layer inside, which
7890
- // can FADE (a background-image gradient cannot be transitioned).
7891
- position: "sticky",
7892
- top: 0,
7893
- zIndex: 2,
7894
- background: C.panelBg
8886
+ // NOT sticky since the surface became translucent (0.10.0): a label that
8887
+ // has to occlude the rows scrolling under it needs an opaque fill, and an
8888
+ // opaque fill on a see-through panel is exactly the horizontal band that
8889
+ // reads as a cut. Letting the label scroll away keeps ONE transparency
8890
+ // across the whole list; the current date moved into the title bar (which
8891
+ // is chrome, and therefore legitimately solid).
8892
+ // The "current group" tint still rides on its own layer inside the row,
8893
+ // which can FADE (a background-image gradient cannot be transitioned).
8894
+ // RELATIVE, not static: that tint layer is `position:absolute; inset:0`,
8895
+ // so it needs this row as its containing block. As a static row the tint
8896
+ // resolved against the panel itself and washed the whole list in the
8897
+ // accent colour for a frame (then the active group settled and it went).
8898
+ position: "relative",
8899
+ background: "transparent"
7895
8900
  },
7896
8901
  children: [
7897
8902
  react_jsx_runtime.jsx("div", {
@@ -8133,10 +9138,16 @@ window.__ModuleLoader__.load({
8133
9138
  borderTop: "1px solid " + (isActive ? C.groupEdgeSoft : "transparent"),
8134
9139
  borderRight: "1px solid " + (isActive ? C.groupEdgeSoft : "transparent"),
8135
9140
  borderBottom: "1px solid " + (isActive ? C.groupEdgeSoft : "transparent"),
8136
- borderLeft: "3px solid " + (isActive ? C.groupEdge : "transparent"),
9141
+ // The accent bar is a LAYER, not a border: it needs to be a gradient and
9142
+ // to fade with the rest of the box. It cannot sit in a transparent left
9143
+ // border, though — this box carries `content-visibility: auto`, which
9144
+ // implies `contain: paint`, and paint containment clips descendants to
9145
+ // the PADDING box (the bar was clipped away entirely). So the 3px the
9146
+ // border used to take is padding instead, and the bar lives inside it.
9147
+ // Same content position: 5px bar + 2px padding == 7px padding.
9148
+ paddingLeft: 7,
8137
9149
  borderRadius: 6,
8138
9150
  margin: dense ? "0 -5px" : "2px -5px",
8139
- paddingLeft: 2,
8140
9151
  // Long-session virtualization: every offscreen group is skipped by the
8141
9152
  // renderer (no layout, no paint) until it nears the viewport, while its
8142
9153
  // DOM stays in place — so the row queries the follow, the keyboard
@@ -8146,15 +9157,88 @@ window.__ModuleLoader__.load({
8146
9157
  // While a surface is ANIMATING the hold lifts the skip entirely:
8147
9158
  // a compositor-driven slide does not re-evaluate the skip state, so
8148
9159
  // an opening surface would otherwise stay blank until it lands.
9160
+ position: "relative",
8149
9161
  contentVisibility: extra && extra.virtHold ? "visible" : "auto",
8150
9162
  containIntrinsicSize: "auto " + (dense ? 80 : 96) + "px"
8151
9163
  },
8152
- children: items
9164
+ children: [
9165
+ // H: the accent bar, as a layer so it can be a gradient AND fade with the
9166
+ // rest of the box. It sits inside the 3px transparent left border, so the
9167
+ // row geometry is identical in both states.
9168
+ react_jsx_runtime.jsx("div", {
9169
+ key: "g-bar",
9170
+ style: {
9171
+ position: "absolute",
9172
+ left: 0,
9173
+ top: 0,
9174
+ bottom: 0,
9175
+ width: 5,
9176
+ background: "linear-gradient(to bottom, " + C.groupEdge + ", " + C.groupEdgeSoft + ")",
9177
+ opacity: isActive ? 1 : 0,
9178
+ transition: "opacity 0.4s ease",
9179
+ pointerEvents: "none"
9180
+ }
9181
+ })
9182
+ ].concat(items)
8153
9183
  }, "g-" + gi));
8154
9184
  }
8155
9185
  return out;
8156
9186
  }
8157
9187
 
9188
+ // A heading and everything under it, as Markdown. Only headings: this is the
9189
+ // outline below this entry, not a transcript of the section's prose. The entry
9190
+ // is offered only when there IS something below (see the row menu), so an empty
9191
+ // result can never be pasted by accident.
9192
+ function markdownOfNode(n) {
9193
+ var out = [];
9194
+ var lvl = Math.min(6, Math.max(1, n.level || 1));
9195
+ var hashes = "";
9196
+ for (var h = 0; h < lvl; h++) hashes += "#";
9197
+ out.push(hashes + " " + (n.title || ""));
9198
+ var kids = n.children || [];
9199
+ for (var i = 0; i < kids.length; i++) {
9200
+ out = out.concat(markdownOfNode(kids[i]));
9201
+ }
9202
+ return out;
9203
+ }
9204
+ // The same thing for a whole turn: every heading under one time row, in order.
9205
+ function markdownOfGroup(g) {
9206
+ var hs = (g && g.headings) || [];
9207
+ var out = [];
9208
+ for (var i = 0; i < hs.length; i++) {
9209
+ var lvl = Math.min(6, Math.max(1, hs[i].level || 1));
9210
+ var hashes = "";
9211
+ for (var h = 0; h < lvl; h++) hashes += "#";
9212
+ out.push(hashes + " " + (hs[i].title || ""));
9213
+ }
9214
+ return out.join("\n");
9215
+ }
9216
+ // Put text on the clipboard. The async API needs a secure context (the panel
9217
+ // runs on the host page, which is loopback or https) and is unavailable on a
9218
+ // plain-http remote visit, so the execCommand path is the fallback rather than
9219
+ // an afterthought.
9220
+ var copyText = function (text) {
9221
+ try {
9222
+ if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
9223
+ navigator.clipboard.writeText(String(text));
9224
+ return true;
9225
+ }
9226
+ } catch (e) { /* fall through to the legacy path */ }
9227
+ try {
9228
+ var ta = document.createElement("textarea");
9229
+ ta.value = String(text);
9230
+ ta.setAttribute("readonly", "");
9231
+ ta.style.position = "fixed";
9232
+ ta.style.top = "-1000px";
9233
+ ta.style.opacity = "0";
9234
+ document.body.appendChild(ta);
9235
+ if (ta.select) ta.select();
9236
+ var ok = typeof document.execCommand === "function" ? document.execCommand("copy") : false;
9237
+ document.body.removeChild(ta);
9238
+ return !!ok;
9239
+ } catch (e) { return false; }
9240
+ };
9241
+
8158
9242
  // Host-facing locale namespace: the slot declares `locale: "dsh-quick-toc"`, and
8159
9243
  // registering BOTH tables under the same keys is what lets the panel follow the
8160
9244
  // host's language: `T` asks the host translation first and only falls back to the
@@ -8216,6 +9300,43 @@ window.__ModuleLoader__.load({
8216
9300
  return seg([["on", T("settings.on")], ["off", T("settings.off")]], on ? "on" : "off",
8217
9301
  function (v) { onToggle(v === "on"); });
8218
9302
  };
9303
+ // Theme colour dots: the choice IS a colour, so it looks like one. Inline
9304
+ // styles (no stylesheet dependency): a 22px dot per option, the selected
9305
+ // one ringed. The dot shows the vivid hue; text roles use the readable
9306
+ // solid from the same option (see ACCENTS). Labels are listed statically
9307
+ // (like every seg row) so the dictionary check sees each key.
9308
+ var accentDots = function (current, onPick) {
9309
+ var pairs = [["blue", T("settings.accent.blue")], ["red", T("settings.accent.red")],
9310
+ ["yellow", T("settings.accent.yellow")], ["green", T("settings.accent.green")],
9311
+ ["purple", T("settings.accent.purple")], ["pink", T("settings.accent.pink")]];
9312
+ return react_jsx_runtime.jsx("div", {
9313
+ className: "dqt-dots",
9314
+ role: "radiogroup",
9315
+ "aria-label": T("settings.accent"),
9316
+ style: { display: "flex", gap: "10px", alignItems: "center", padding: "2px" },
9317
+ children: pairs.map(function (pair) {
9318
+ var id = pair[0];
9319
+ var on = current === id;
9320
+ var dot = (ACCENTS[id] || ACCENTS.blue).dot;
9321
+ return react_jsx_runtime.jsx("button", {
9322
+ type: "button",
9323
+ role: "radio",
9324
+ "aria-checked": on,
9325
+ title: pair[1],
9326
+ "aria-label": pair[1],
9327
+ disabled: !writable,
9328
+ onClick: function () { onPick(id); },
9329
+ className: "dqt-dot" + (on ? " dqt-dotOn" : ""),
9330
+ style: {
9331
+ width: "22px", height: "22px", borderRadius: "50%", padding: 0,
9332
+ border: "none", cursor: writable ? "pointer" : "default",
9333
+ background: dot, opacity: writable ? 1 : 0.4,
9334
+ boxShadow: on ? "0 0 0 2px var(--dsw-alias-bg-base, #fff), 0 0 0 4px " + dot : "inset 0 0 0 1px rgba(128,128,128,0.35)"
9335
+ }
9336
+ }, id);
9337
+ })
9338
+ });
9339
+ };
8219
9340
  // Percentage sliders (the panel scale and the handle position) — the same
8220
9341
  // control shape as the fonttune card's slider. Dragging only moves a LOCAL
8221
9342
  // value: the settings document is written once the pointer is released (a
@@ -8230,7 +9351,9 @@ window.__ModuleLoader__.load({
8230
9351
  sliderAwaiting.current[field] = pct;
8231
9352
  commit(field === "zoom" ? { zoom: pct / 100 }
8232
9353
  : field === "sheetZoom" ? { sheetZoom: pct / 100 }
8233
- : { handle: pct / 100 });
9354
+ // this one is stored as the 0..100 it reads as, not as a ratio
9355
+ : field === "glassStrength" ? { glassStrength: pct }
9356
+ : { handle: pct / 100 });
8234
9357
  };
8235
9358
  react.useEffect(function () {
8236
9359
  var a = sliderAwaiting.current;
@@ -8238,8 +9361,9 @@ window.__ModuleLoader__.load({
8238
9361
  if (a.zoom !== undefined && Math.round(prefs.zoom * 100) === a.zoom) { delete a.zoom; settled = true; }
8239
9362
  if (a.sheetZoom !== undefined && Math.round(prefs.sheetZoom * 100) === a.sheetZoom) { delete a.sheetZoom; settled = true; }
8240
9363
  if (a.handle !== undefined && Math.round(prefs.handle * 100) === a.handle) { delete a.handle; settled = true; }
9364
+ if (a.glassStrength !== undefined && Math.round(prefs.glassStrength) === a.glassStrength) { delete a.glassStrength; settled = true; }
8241
9365
  if (settled) setSliderDrag(null);
8242
- }, [prefs.zoom, prefs.sheetZoom, prefs.handle]);
9366
+ }, [prefs.zoom, prefs.sheetZoom, prefs.handle, prefs.glassStrength]);
8243
9367
  react.useEffect(function () {
8244
9368
  if (sliderDrag === null) return undefined;
8245
9369
  var release = function () {
@@ -8291,6 +9415,7 @@ window.__ModuleLoader__.load({
8291
9415
  var zoomControl = sliderCtl("zoom", "settings.zoom", 50, 200, 5, Math.round(prefs.zoom * 100));
8292
9416
  var sheetZoomControl = sliderCtl("sheetZoom", "settings.sheetZoom", 50, 200, 5, Math.round(prefs.sheetZoom * 100));
8293
9417
  var handleControl = sliderCtl("handle", "settings.handle", 0, 100, 1, Math.round(prefs.handle * 100));
9418
+ var glassStrengthControl = sliderCtl("glassStrength", "settings.glassStrength", 0, 100, 5, clampPct(prefs.glassStrength, 60));
8294
9419
  var field = function (key, label, hint, control, inline, last) {
8295
9420
  var overridden = Object.prototype.hasOwnProperty.call(user, key);
8296
9421
  var reset = overridden ? react_jsx_runtime.jsx("button", {
@@ -8353,6 +9478,12 @@ window.__ModuleLoader__.load({
8353
9478
  field("density", T("settings.density"), "",
8354
9479
  seg([["comfy", T("settings.density.comfy")], ["compact", T("settings.density.compact")]], prefs.density,
8355
9480
  function (v) { commit({ density: v }); }), true),
9481
+ field("accent", T("settings.accent"), "",
9482
+ accentDots(prefs.accent, function (v) { commit({ accent: v }); }), true),
9483
+ field("glass", T("settings.glass"),
9484
+ T("settings.glass.tip") + (autoGlassOff ? " " + T("settings.glass.autoOff") : ""),
9485
+ onoff(prefs.glass, function (v) { commit({ glass: v }); }), true),
9486
+ field("glassStrength", T("settings.glassStrength"), T("settings.glassStrength.tip"), glassStrengthControl, false),
8356
9487
  field("zoom", T("settings.zoom"), T("settings.zoom.tip"), zoomControl, false),
8357
9488
  field("sheetZoom", T("settings.sheetZoom"), T("settings.sheetZoom.tip"), sheetZoomControl, false),
8358
9489
  field("handle", T("settings.handle"), T("settings.handle.tip"), handleControl, false),
@@ -8697,6 +9828,10 @@ window.__ModuleLoader__.load({
8697
9828
  exports.name = "dsh-quick-toc"; // runner attributes slot entries to this registrant
8698
9829
  exports.apply = apply;
8699
9830
  exports.inject = inject;
9831
+ // Which build the page actually loaded. A stale client bundle looks exactly
9832
+ // like a broken feature, so this one string settles it in a single look:
9833
+ // type __dqtBuild in the console.
9834
+ try { if (typeof window !== "undefined") window.__dqtBuild = "0.10.0-glass-card"; } catch (e) {}
8700
9835
 
8701
9836
  // inject the small keyframe + panel-scrollbar stylesheet once
8702
9837
  (function () {
@@ -8716,7 +9851,9 @@ window.__ModuleLoader__.load({
8716
9851
  // card that is ADOPTED from the hover card (it is already on screen, so it must
8717
9852
  // not re-play the entrance and blink).
8718
9853
  ".dqt-hover{animation:dqt-fade-in 0.12s linear both}" +
8719
- ".dqt-hover-static{animation:none}" +
9854
+ // the bookmark view is a different list riding on top of the outline: it eases
9855
+ // in rather than appearing between two frames
9856
+ ".dqt-marks-overlay{animation:dqt-fade-in 0.16s linear both}" + ".dqt-hover-static{animation:none}" +
8720
9857
  ".dqt-hover-closing{animation:dqt-fade-out 0.16s cubic-bezier(0.2, 0.9, 0.3, 1) both;pointer-events:none}" +
8721
9858
  // the outline row menu: same vocabulary as the level picker (a small pop)
8722
9859
  ".dqt-rowmenu{animation:dqt-pop-in 0.14s cubic-bezier(0.22, 0.9, 0.3, 1) both;will-change:transform,opacity}" +
@@ -8734,9 +9871,25 @@ window.__ModuleLoader__.load({
8734
9871
  // on the way out — the same vocabulary as the docked panel sliding in from its
8735
9872
  // edge. The clip box it lives in (see the portal) is what keeps it from being
8736
9873
  // seen above that line, so it reads as emerging from under the view tabs.
8737
- "@keyframes dqt-sheet-in{from{transform:translateY(-100%)}to{transform:translateY(0)}}" +
8738
- "@keyframes dqt-sheet-out{from{transform:translateY(0)}to{transform:translateY(-100%)}}" +
8739
- ".dqt-sheet-open{animation:dqt-sheet-in 0.46s cubic-bezier(0.22, 0.9, 0.3, 1) both;will-change:transform}" +
9874
+ // K: the curtain drops a hair past its resting line and settles back, so it
9875
+ // reads as a sheet landing rather than a panel arriving at a coordinate.
9876
+ // The overshoot is small and late (88%) so the settle is a long, soft
9877
+ // exhale — at 70%/6px it read as a hard snap.
9878
+ // K: the curtain drops a hair past its resting line and settles back.
9879
+ // The settle gets its OWN timing function and a long segment (62% -> 100%):
9880
+ // with the overshoot at 88% the return lasted 55ms and, because the drop's
9881
+ // ease-out is fast at a segment's start, it read as a snap back into place.
9882
+ // At the turning point the velocity is zero, so that leg eases IN as well as
9883
+ // out — that is what makes it a settle instead of a bounce.
9884
+ "@keyframes dqt-sheet-in{from{transform:translateY(calc(-100% - 24px));animation-timing-function:cubic-bezier(0.22, 0.9, 0.3, 1)}62%{transform:translateY(4px);animation-timing-function:cubic-bezier(0.45, 0, 0.25, 1)}to{transform:translateY(0)}}" +
9885
+ // The retract has to clear the shadow too, not just the sheet: the sheet is
9886
+ // 24px shorter than its shell (that strip is where the drop shadow lands),
9887
+ // so `translateY(-100%)` parked its own shadow exactly at the shell's top
9888
+ // edge and the state flip 40ms later blinked that band away.
9889
+ "@keyframes dqt-sheet-out{from{transform:translateY(0)}to{transform:translateY(calc(-100% - 24px))}}" +
9890
+ // 0.5s: the drop is 62% of it and the settle the rest, so the settle is a
9891
+ // readable ~190ms rather than a two-frame correction
9892
+ ".dqt-sheet-open{animation:dqt-sheet-in 0.5s cubic-bezier(0.22, 0.9, 0.3, 1) both;will-change:transform}" +
8740
9893
  ".dqt-sheet-closing{animation:dqt-sheet-out 0.3s cubic-bezier(0.4, 0, 1, 1) both;pointer-events:none;will-change:transform}" +
8741
9894
  // The curtain's own drop and retract are deliberately NOT gated on
8742
9895
  // `prefers-reduced-motion`: with the host system's animations switched off (Windows