dsh-milestone 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -19,8 +19,105 @@ window.__ModuleLoader__.load({
19
19
  });
20
20
  }
21
21
  //#endregion
22
+ //#region src/client/accent-utils.ts
23
+ /** True for a canonical 6-digit hex color (`#4d7cfd`, `#4D7CFD`). */
24
+ function isHexColor(value) {
25
+ return /^#[0-9a-fA-F]{6}$/.test(value);
26
+ }
27
+ /**
28
+ * Parse a hex color into channels.
29
+ * @param hex - `#rrggbb` (case-insensitive).
30
+ * @returns the RGB channels, or null when `hex` is not a canonical hex color.
31
+ */
32
+ function hexToRgb(hex) {
33
+ if (!isHexColor(hex)) return null;
34
+ const n = parseInt(hex.slice(1), 16);
35
+ return {
36
+ r: n >> 16 & 255,
37
+ g: n >> 8 & 255,
38
+ b: n & 255
39
+ };
40
+ }
41
+ /** Format an alpha to the short decimal form the rail's inline styles use (0.55, 0.2). */
42
+ function formatAlpha(alpha) {
43
+ return Math.round(Math.max(0, Math.min(1, alpha)) * 100) / 100;
44
+ }
45
+ /**
46
+ * Color with alpha as a CSS `rgba(r, g, b, a)` string; alpha clamps to [0, 1].
47
+ * @returns the rgba() string, or null when `hex` is invalid.
48
+ */
49
+ function rgbaString(hex, alpha) {
50
+ const rgb = hexToRgb(hex);
51
+ if (rgb === null) return null;
52
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${formatAlpha(alpha)})`;
53
+ }
54
+ /**
55
+ * Linear mix of two hex colors in RGB space.
56
+ * @param a - start color (t = 0).
57
+ * @param b - end color (t = 1).
58
+ * @param t - mix factor, clamped to [0, 1].
59
+ * @returns the mixed `#rrggbb`, or null when either input is invalid.
60
+ */
61
+ function mixHex(a, b, t) {
62
+ const ca = hexToRgb(a);
63
+ const cb = hexToRgb(b);
64
+ if (ca === null || cb === null) return null;
65
+ const k = Math.max(0, Math.min(1, t));
66
+ const channel = (from, to) => Math.round(from + (to - from) * k).toString(16).padStart(2, "0");
67
+ return `#${channel(ca.r, cb.r)}${channel(ca.g, cb.g)}${channel(ca.b, cb.b)}`;
68
+ }
69
+ /**
70
+ * Lighten a hex color toward white — the rail's "accent soft" text shade
71
+ * (t = 0 keeps the color, t = 1 is white).
72
+ */
73
+ function lighten(hex, t) {
74
+ return mixHex(hex, "#ffffff", t);
75
+ }
76
+ /**
77
+ * Convert a hex color to HSL. Double-checked against the standard RGB→HSL
78
+ * formulas; saturation/lightness are percentages, hue is degrees.
79
+ * @returns the HSL channels, or null when `hex` is invalid.
80
+ */
81
+ function hexToHsl(hex) {
82
+ const rgb = hexToRgb(hex);
83
+ if (rgb === null) return null;
84
+ const r = rgb.r / 255;
85
+ const g = rgb.g / 255;
86
+ const b = rgb.b / 255;
87
+ const max = Math.max(r, g, b);
88
+ const min = Math.min(r, g, b);
89
+ const delta = max - min;
90
+ let h = 0;
91
+ if (delta !== 0) if (max === r) h = 60 * ((g - b) / delta % 6);
92
+ else if (max === g) h = 60 * ((b - r) / delta + 2);
93
+ else h = 60 * ((r - g) / delta + 4);
94
+ if (h < 0) h += 360;
95
+ const l = (max + min) / 2;
96
+ const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
97
+ return {
98
+ h,
99
+ s: s * 100,
100
+ l: l * 100
101
+ };
102
+ }
103
+ //#endregion
22
104
  //#region src/client/badge-logic.ts
23
105
  /**
106
+ * Pure turn-health badge derivation + styling for the milestone rail: given
107
+ * the harness snapshot signals, decide which (if any) colored glow a mark's
108
+ * dot should wear, plus the style tokens for that badge.
109
+ *
110
+ * Rendering contract (M-design change): a badge is NO LONGER a hard 2px ring
111
+ * (border) — it is a concentric "soft glow" made of three layered box-shadows
112
+ * (a crisp inner ring at 55% alpha, then two blurred blooms), and the
113
+ * running/awaiting pulse breathes opacity + shadow intensity instead of
114
+ * expanding a ring. The `data-badge` value and the semantic color of every
115
+ * kind are unchanged.
116
+ *
117
+ * All functions are side-effect free (no React, no DOM) so the rail component
118
+ * can consume them directly and tests can exercise them in isolation.
119
+ */
120
+ /**
24
121
  * Derive the badge for one mark.
25
122
  *
26
123
  * Precedence: error > max-tokens > retry > running > awaiting. Node-derived
@@ -65,13 +162,41 @@ window.__ModuleLoader__.load({
65
162
  pulse: true
66
163
  }
67
164
  };
165
+ /** Build the layered soft-glow shadow for a badge color. */
166
+ function glowShadow(color) {
167
+ const layer = (alpha) => rgbaString(color, alpha) ?? "currentColor";
168
+ return [
169
+ `0 0 0 2px ${layer(.55)}`,
170
+ `0 0 8px 2px ${layer(.45)}`,
171
+ `0 0 16px 5px ${layer(.2)}`
172
+ ].join(", ");
173
+ }
68
174
  /**
69
175
  * Style tokens for a badge kind.
70
176
  * @param badge - the derived badge kind.
71
- * @returns the ring color and whether the dot should pulse.
177
+ * @returns the glow color, whether the dot should pulse (breathing glow), and
178
+ * the static multi-layer box-shadow string for the badge span.
72
179
  */
73
180
  function badgeRingStyle(badge) {
74
- return RING_STYLES[badge];
181
+ const base = RING_STYLES[badge];
182
+ return {
183
+ ...base,
184
+ shadow: glowShadow(base.color)
185
+ };
186
+ }
187
+ /**
188
+ * Breathing-glow keyframes for one badge color. The rail injects this (via an
189
+ * inline <style>) only while a pulsing badge is on screen — the color is baked
190
+ * into the alphas, so the animation needs no runtime var lookups. Both stops
191
+ * keep the SAME three-layer shape (only alpha/blur breathe), so the glow never
192
+ * looks like the old expanding ring.
193
+ */
194
+ function badgePulseCss(color) {
195
+ const layer = (alpha) => rgbaString(color, alpha) ?? "currentColor";
196
+ return `@keyframes milestone-badge-pulse {
197
+ 0%, 100% { opacity: 0.95; box-shadow: ${`0 0 0 2px ${layer(.55)}, 0 0 8px 2px ${layer(.45)}, 0 0 16px 5px ${layer(.2)}`}; }
198
+ 50% { opacity: 0.45; box-shadow: ${`0 0 0 2px ${layer(.35)}, 0 0 4px 1px ${layer(.25)}, 0 0 9px 3px ${layer(.12)}`}; }
199
+ }`;
75
200
  }
76
201
  //#endregion
77
202
  //#region src/client/bookmark-logic.ts
@@ -349,6 +474,13 @@ window.__ModuleLoader__.load({
349
474
  //#endregion
350
475
  //#region src/client/rail-logic.ts
351
476
  /**
477
+ * Pure rail logic for the milestone rail: full-text search matching, current-
478
+ * position highlight, match-cycle navigation, mark visual state, and dot color.
479
+ *
480
+ * All functions are side-effect free (no React, no DOM) so the rail component
481
+ * can consume them directly and tests can exercise them in isolation.
482
+ */
483
+ /**
352
484
  * Extract the FULL plain text of a ContentBlock[] payload: the `text` of every
353
485
  * `{ type: 'text', text: string }` block, joined with a single space and
354
486
  * trimmed. Unlike the rail's hover preview this is NOT truncated — callers use
@@ -427,14 +559,312 @@ window.__ModuleLoader__.load({
427
559
  if (opts.hasQuery) return "dimmed";
428
560
  return "normal";
429
561
  }
562
+ /** The default accent (the classic milestone blue) — dotColor's fallback. */
563
+ const DEFAULT_DOT_ACCENT = "#4d7cfd";
430
564
  /**
431
- * Blue gradient dot color, reproduced exactly from MilestoneRail: newest
432
- * (highest index) is deepest, oldest is lightest. 72% lightness fading to 45%.
565
+ * Accent-driven gradient dot color: hue/saturation come from the user's
566
+ * accent (settings 强调色), lightness walks 72% 45% (newest/highest index
567
+ * deepest, oldest lightest — the original gradient shape). Invalid accents
568
+ * degrade to the default blue.
433
569
  * @param index - dot position in the rail (0 = oldest).
434
570
  * @param total - number of dots.
571
+ * @param accent - the accent hex (`#rrggbb`); defaults to the classic blue.
572
+ */
573
+ function dotColor(index, total, accent = DEFAULT_DOT_ACCENT) {
574
+ const hsl = hexToHsl(accent) ?? hexToHsl("#4d7cfd");
575
+ const lightness = 72 - (total <= 1 ? 0 : index / (total - 1)) * 27;
576
+ return `hsl(${Math.round(hsl.h)}, ${Math.round(hsl.s)}%, ${lightness}%)`;
577
+ }
578
+ //#endregion
579
+ //#region src/client/locales.ts
580
+ /**
581
+ * UI strings for the milestone rail, keyed flat (single-language-per-key,
582
+ * no nesting) so the later i18n threading stays a mechanical
583
+ * `value.replace('{name}', n)` substitution.
584
+ *
585
+ * `zh` is the source of truth and the key registry: it byte-matches the
586
+ * current hardcoded output of MilestoneRail / MilestoneRailTooltip /
587
+ * MilestoneRailSearch exactly (each `{n}`/`{m}`/`{name}` slot stands in for
588
+ * the interpolated number or label), so swapping in these templates is
589
+ * behavior-preserving. `en` is typed `Record<MilestoneKey, string>` so a
590
+ * missing English translation is a compile error, not a runtime miss.
591
+ */
592
+ const zh = {
593
+ /** aria-label on each dot: `跳转到第 ${i + 1} 条消息`. */
594
+ "jump.to": "跳转到第 {n} 条消息",
595
+ /** Load-older coverage hint: `已显示 {marks.length} 条 · 还有更早`. */
596
+ "window.hint": "已显示 {n} 条 · 还有更早",
597
+ /** Hover turn badge: `第 ${mark.turn} 轮`. */
598
+ "turn.label": "第 {n} 轮",
599
+ /** Hover position: `第 {hover.index + 1} / {hover.total} 条`. */
600
+ "pos.of": "第 {n} / {m} 条",
601
+ /** Search input placeholder. */
602
+ "search.placeholder": "搜索消息内容",
603
+ /** aria-label on the search toggle button and the search input. */
604
+ "search.label": "搜索消息",
605
+ /** aria-label on the bookmarks-only filter toggle. */
606
+ "bookmark.filter": "只看收藏",
607
+ /** aria-label + title on the focus-mode toggle when focus is OFF (arm it). */
608
+ "focus.on": "聚焦模式",
609
+ /** aria-label + title on the focus-mode toggle when focus is ON (disarm it). */
610
+ "focus.off": "退出聚焦",
611
+ /** aria-label on the hover tooltip star toggle. */
612
+ "bookmark.star": "收藏此消息",
613
+ /** aria-label on the search clear button. */
614
+ "search.clear": "清空搜索",
615
+ /** title + aria-label on the load-older `···` button. */
616
+ "load.older": "加载更早消息",
617
+ /** aria-label on the rail root. */
618
+ "rail.label": "会话里程碑",
619
+ /** aria-label on the dot list. */
620
+ "rail.list": "会话里程碑列表",
621
+ /** Hover preview fallback for empty message text. */
622
+ "no.text": "(无文本)",
623
+ /** Relative time: `< 60s`. */
624
+ "time.justNow": "刚刚",
625
+ /** Relative time: `< 1h`. */
626
+ "time.minutes": "{n} 分钟前",
627
+ /** Relative time: `< 1d`. */
628
+ "time.hours": "{n} 小时前",
629
+ /** Relative time: `>= 1d`. */
630
+ "time.days": "{n} 天前",
631
+ /** Hover duration: `用时 {durationLabel}`. */
632
+ "duration.label": "用时 {name}",
633
+ /** Hover TTFT: `首字 {ttftLabel}`. */
634
+ "ttft.label": "首字 {name}",
635
+ /** TurnEndReason `completed`. */
636
+ "reason.completed": "已完成",
637
+ /** TurnEndReason `aborted`. */
638
+ "reason.aborted": "已中止",
639
+ /** TurnEndReason `error`. */
640
+ "reason.error": "出错",
641
+ /** TurnEndReason `max-tokens`. */
642
+ "reason.maxTokens": "达到上限",
643
+ /** TurnEndReason `interrupted`. */
644
+ "reason.interrupted": "已中断",
645
+ /** TurnEndReason `blocked`. */
646
+ "reason.blocked": "已阻塞",
647
+ /** Copy-message tooltip action. */
648
+ "copy.message": "复制消息",
649
+ /** Fork-from-here tooltip action. */
650
+ "fork.here": "从此处 fork",
651
+ /** Collapse-turn tooltip action. */
652
+ "collapse.turn": "折叠此轮",
653
+ /** Expand-turn tooltip action. */
654
+ "expand.turn": "展开此轮",
655
+ /** aria-label + title on the milestone-list toggle when the panel is CLOSED. */
656
+ "list.open": "打开列表",
657
+ /** aria-label + title on the milestone-list toggle when the panel is OPEN. */
658
+ "list.close": "收起列表",
659
+ /** Header title of the all-prompts list panel. */
660
+ "list.label": "全部提问",
661
+ /** Header title + input placeholder of the cross-session search panel. */
662
+ "search.cross": "跨会话搜索",
663
+ /** aria-label + title on the cross-session search toggle when the panel is CLOSED. */
664
+ "search.cross.open": "打开跨会话搜索",
665
+ /** aria-label + title on the cross-session search toggle when the panel is OPEN. */
666
+ "search.cross.close": "收起跨会话搜索",
667
+ /** Cross-session result row title fallback for sessions with no display title. */
668
+ "search.untitled": "(无标题)",
669
+ /** Cross-session search failure notice. */
670
+ "search.error": "搜索失败,请重试",
671
+ /** Cross-session search footer hint when the harness capped the result list. */
672
+ "search.more": "结果已截断,请细化关键词",
673
+ /** aria-label on the toolbar expand arrow while the toolbar is COLLAPSED (expand it). */
674
+ "toolbar.expand": "展开工具栏",
675
+ /** aria-label on the toolbar expand arrow while the toolbar is EXPANDED (collapse it). */
676
+ "toolbar.collapse": "收起工具栏",
677
+ /** aria-label on the toolbar settings gear while the settings menu is CLOSED. */
678
+ "toolbar.settings.open": "打开设置",
679
+ /** aria-label on the toolbar settings gear while the settings menu is OPEN. */
680
+ "toolbar.settings.close": "关闭设置",
681
+ /** Header title of the toolbar settings menu. */
682
+ "settings.title": "设置",
683
+ /** Per-feature toggle label inside the settings menu: keep visible while collapsed. */
684
+ "settings.pin": "在折叠外显示",
685
+ /** Settings action that clears every pinned feature. */
686
+ "settings.reset": "恢复默认",
687
+ /** Settings footer heading above the project links. */
688
+ "settings.support": "支持我们",
689
+ /** Settings footer link: the GitHub repository. */
690
+ "settings.repo": "GitHub 仓库",
691
+ /** Settings footer link: star the repository. */
692
+ "settings.star": "欢迎 Star ★",
693
+ /** Settings footer link: file an issue. */
694
+ "settings.issues": "提交 Issue",
695
+ /** Settings footer link: the npm install channel. */
696
+ "settings.npm": "npm 安装渠道",
697
+ /** Settings: feature-row name of the settings key itself (registry row). */
698
+ "settings.label": "设置",
699
+ /** aria-label on the settings modal close button. */
700
+ "settings.close": "关闭",
701
+ /** Settings modal: section heading for the feature pin rows. */
702
+ "settings.section.features": "功能与快捷区",
703
+ /** Settings modal: section heading for the personalization controls. */
704
+ "settings.section.personal": "个性化",
705
+ /** Settings: hover description — in-rail search. */
706
+ "settings.desc.search": "按完整消息内容过滤并跳转到对应消息",
707
+ /** Settings: hover description — all-prompts list. */
708
+ "settings.desc.list": "本会话全部提问一览",
709
+ /** Settings: hover description — cross-session search. */
710
+ "settings.desc.sessionSearch": "跨会话搜索所有会话",
711
+ /** Settings: hover description — bookmarks filter. */
712
+ "settings.desc.bookmarks": "只显示已收藏的消息",
713
+ /** Settings: hover description — focus mode. */
714
+ "settings.desc.focus": "淡化 AI 思考块,阅读更清爽",
715
+ /** Settings: hover description — update check. */
716
+ "settings.desc.updateCheck": "检查 npm 是否有新版本",
717
+ /** Settings: hover description — the settings key itself. */
718
+ "settings.desc.settings": "自定义工具栏与外观",
719
+ /** Settings personalization: accent color row label. */
720
+ "settings.accent": "强调色",
721
+ /** Settings personalization: custom color swatch label. */
722
+ "settings.custom": "自定义",
723
+ /** Settings personalization: icon/dot size slider label. */
724
+ "settings.iconSize": "图标 / 圆点大小",
725
+ /** Settings personalization: edge-distance slider label. */
726
+ "settings.inset": "距侧边距离",
727
+ /** Settings personalization: rail side row label. */
728
+ "settings.side": "位置",
729
+ /** Settings personalization: side radio — hug the left edge. */
730
+ "settings.side.left": "左侧",
731
+ /** Settings personalization: side radio — hug the right edge. */
732
+ "settings.side.right": "右侧",
733
+ /** B4 update-check: toolbar button label + title/aria-label. */
734
+ "update.check": "检查更新",
735
+ /** B4 update-check: popover title. */
736
+ "update.title": "更新检测",
737
+ /** B4 update-check: installed-version row label. */
738
+ "update.current": "当前版本",
739
+ /** B4 update-check: newest-published-version row label. */
740
+ "update.latest": "最新版本",
741
+ /** B4 update-check: conclusion when the installed version is current. */
742
+ "update.upToDate": "已是最新版本",
743
+ /** B4 update-check: conclusion when a newer version exists. */
744
+ "update.available": "发现新版本",
745
+ /** B4 update-check: link text for the npm upgrade channel. */
746
+ "update.goNpm": "去 npm 升级",
747
+ /** B4 update-check: supported-host-lines metadata row label. */
748
+ "update.hostLines": "已适配官方版本线",
749
+ /** B4 update-check: in-flight state of the manual check button. */
750
+ "update.checking": "检查中…",
751
+ /** B4 update-check: failed state heading. */
752
+ "update.failed": "检查失败",
753
+ /** B4 update-check: retry action inside the failed state. */
754
+ "update.retry": "重试",
755
+ /** Settings modal section title: language. */
756
+ "settings.language": "语言",
757
+ /** Language option: follow the harness UI language. */
758
+ "settings.lang.system": "跟随系统",
759
+ /** Language option: force Chinese copy. */
760
+ "settings.lang.zh": "中文",
761
+ /** Language option: force English copy. */
762
+ "settings.lang.en": "English"
763
+ };
764
+ const en = {
765
+ "jump.to": "Jump to message {n}",
766
+ "window.hint": "Showing {n} messages · more below",
767
+ "turn.label": "Turn {n}",
768
+ "pos.of": "Message {n} of {m}",
769
+ "search.placeholder": "Search message content",
770
+ "search.label": "Search messages",
771
+ "bookmark.filter": "Bookmarks only",
772
+ "focus.on": "Focus mode",
773
+ "focus.off": "Exit focus",
774
+ "bookmark.star": "Bookmark this message",
775
+ "search.clear": "Clear search",
776
+ "load.older": "Load older messages",
777
+ "rail.label": "Session milestones",
778
+ "rail.list": "Session milestone list",
779
+ "no.text": "(no text)",
780
+ "time.justNow": "Just now",
781
+ "time.minutes": "{n} minutes ago",
782
+ "time.hours": "{n} hours ago",
783
+ "time.days": "{n} days ago",
784
+ "duration.label": "Duration {name}",
785
+ "ttft.label": "First token {name}",
786
+ "reason.completed": "Completed",
787
+ "reason.aborted": "Aborted",
788
+ "reason.error": "Error",
789
+ "reason.maxTokens": "Max tokens reached",
790
+ "reason.interrupted": "Interrupted",
791
+ "reason.blocked": "Blocked",
792
+ "copy.message": "Copy message",
793
+ "fork.here": "Fork from here",
794
+ "collapse.turn": "Collapse turn",
795
+ "expand.turn": "Expand turn",
796
+ "list.open": "Open list",
797
+ "list.close": "Close list",
798
+ "list.label": "All prompts",
799
+ "search.cross": "Cross-session search",
800
+ "search.cross.open": "Open cross-session search",
801
+ "search.cross.close": "Close cross-session search",
802
+ "search.untitled": "(untitled)",
803
+ "search.error": "Search failed, retry",
804
+ "search.more": "Results truncated — refine your query",
805
+ "toolbar.expand": "Expand toolbar",
806
+ "toolbar.collapse": "Collapse toolbar",
807
+ "toolbar.settings.open": "Open settings",
808
+ "toolbar.settings.close": "Close settings",
809
+ "settings.title": "Settings",
810
+ "settings.pin": "Show outside collapse",
811
+ "settings.reset": "Restore defaults",
812
+ "settings.support": "Support us",
813
+ "settings.repo": "GitHub repo",
814
+ "settings.star": "Give us a Star ★",
815
+ "settings.issues": "Report an Issue",
816
+ "settings.npm": "Install via npm",
817
+ "settings.label": "Settings",
818
+ "settings.close": "Close",
819
+ "settings.section.features": "Features & Shortcuts",
820
+ "settings.section.personal": "Personalization",
821
+ "settings.desc.search": "Filter by full message text and jump to the match",
822
+ "settings.desc.list": "Overview of every prompt in this session",
823
+ "settings.desc.sessionSearch": "Search across all sessions",
824
+ "settings.desc.bookmarks": "Show bookmarked messages only",
825
+ "settings.desc.focus": "Dim AI thinking blocks for a cleaner read",
826
+ "settings.desc.updateCheck": "Check npm for a newer release",
827
+ "settings.desc.settings": "Customize the toolbar and appearance",
828
+ "settings.accent": "Accent color",
829
+ "settings.custom": "Custom",
830
+ "settings.iconSize": "Icon / dot size",
831
+ "settings.inset": "Distance from the edge",
832
+ "settings.side": "Position",
833
+ "settings.side.left": "Left",
834
+ "settings.side.right": "Right",
835
+ "update.check": "Check updates",
836
+ "update.title": "Update check",
837
+ "update.current": "Current version",
838
+ "update.latest": "Latest version",
839
+ "update.upToDate": "You are up to date",
840
+ "update.available": "Update available",
841
+ "update.goNpm": "Upgrade on npm",
842
+ "update.hostLines": "Supported official version lines",
843
+ "update.checking": "Checking…",
844
+ "update.failed": "Check failed",
845
+ "update.retry": "Retry",
846
+ "settings.language": "Language",
847
+ "settings.lang.system": "Follow system",
848
+ "settings.lang.zh": "Chinese",
849
+ "settings.lang.en": "English"
850
+ };
851
+ /**
852
+ * Interpolate `{name}` placeholders with params, matching the harness t seat's
853
+ * substitution shape; an unknown parameter leaves the placeholder verbatim.
854
+ */
855
+ function interpolate(template, params) {
856
+ if (params === void 0) return template;
857
+ return template.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/g, (slot, name) => name in params ? String(params[name]) : slot);
858
+ }
859
+ /**
860
+ * Dictionary-backed translate for the forced-language override (locale prefs
861
+ * 'zh' / 'en'): resolves a key against the plugin's own dictionaries with
862
+ * placeholder interpolation; unknown keys pass through unchanged (same
863
+ * degradation as the harness seat).
435
864
  */
436
- function dotColor(index, total) {
437
- return `hsl(218, 88%, ${72 - (total <= 1 ? 0 : index / (total - 1)) * 27}%)`;
865
+ function translateDict(dict, key, params) {
866
+ const template = dict[key];
867
+ return template === void 0 ? key : interpolate(template, params);
438
868
  }
439
869
  //#endregion
440
870
  //#region src/client/turn-group-logic.ts
@@ -1300,20 +1730,29 @@ window.__ModuleLoader__.load({
1300
1730
  //#endregion
1301
1731
  //#region src/client/toolbar-prefs.ts
1302
1732
  /**
1303
- * toolbar-prefs: the persistence layer for the milestone rail's collapsible
1304
- * toolbar "pinned outside the collapse" preferences.
1733
+ * toolbar-prefs: the persistence layer for the milestone rail's toolbar
1734
+ * personalization WHICH function keys stay visible outside the collapse
1735
+ * (pinned) plus the settings-module appearance prefs (accent color, icon/dot
1736
+ * size, distance from the rail's screen edge, and rail side).
1737
+ *
1738
+ * Storage contract: one localStorage key (`dsh-milestone.toolbar`) holding a
1739
+ * JSON object:
1305
1740
  *
1306
- * Storage contract: one localStorage key (`dsh-milestone.toolbar`) holding
1307
- * `{ "pinned": string[] }` the ids of the function-key features the user
1308
- * wants to keep visible while the toolbar is COLLAPSED. An absent or corrupt
1309
- * value degrades to `[]` (everything folded away).
1741
+ * { "pinned": string[], "accent": "#rrggbb", "iconSize": number,
1742
+ * "inset": number, "side": "left" | "right", "locale": "system"|"zh"|"en" }
1310
1743
  *
1311
- * All reads are sanitized: ids are checked against the canonical whitelist
1312
- * (`TOOLBAR_PIN_IDS`), duplicates are dropped, unknown ids are discarded, and
1313
- * the pinned order is the caller's first-seen order (the order ids were
1314
- * pinned) — a future feature removal therefore never leaves a stale id
1315
- * behind, and a hand-edited value can never smuggle an out-of-registry id
1316
- * into render state.
1744
+ * Backward compatibility: the pre-personalization blob `{ "pinned": string[] }`
1745
+ * (and an entirely absent value) parses to the DEFAULT prefs with the new
1746
+ * fields at their defaults old users keep their pins untouched.
1747
+ *
1748
+ * All reads are sanitized per field:
1749
+ * - `pinned`: whitelisted ids only (`TOOLBAR_PIN_IDS`), duplicates dropped,
1750
+ * first-seen (pin) order preserved;
1751
+ * - `accent`: a canonical `#rrggbb` hex, lowercased; anything else falls
1752
+ * back to the default blue;
1753
+ * - `iconSize` / `inset`: finite numbers snapped to the slider step
1754
+ * (even values) and clamped to the slider range;
1755
+ * - `side`: exactly `'left'` or `'right'`.
1317
1756
  *
1318
1757
  * The whitelist lives HERE (not in MilestoneRail) so the pure functions stay
1319
1758
  * dependency-free and unit-testable; MilestoneRail's feature registry keys
@@ -1323,8 +1762,10 @@ window.__ModuleLoader__.load({
1323
1762
  const TOOLBAR_PREFS_KEY = "dsh-milestone.toolbar";
1324
1763
  /**
1325
1764
  * Canonical function-key ids that may be pinned outside the collapse, in
1326
- * toolbar render order. Adding a feature here (plus its registry entry in
1327
- * MilestoneRail) is the whole "pin it" extension point.
1765
+ * render order. `settings` is a REGULAR feature since the B-design move: the
1766
+ * gear left the always-visible chrome and now sits at the end of the expanded
1767
+ * feature queue (default unpinned). Adding a feature here (plus its registry
1768
+ * entry in MilestoneRail) is the whole "pin it" extension point.
1328
1769
  */
1329
1770
  const TOOLBAR_PIN_IDS = [
1330
1771
  "search",
@@ -1332,32 +1773,28 @@ window.__ModuleLoader__.load({
1332
1773
  "sessionSearch",
1333
1774
  "bookmarks",
1334
1775
  "focus",
1335
- "updateCheck"
1776
+ "updateCheck",
1777
+ "settings"
1336
1778
  ];
1779
+ /** The canonical default prefs ("恢复默认" target; also the read fallback). */
1780
+ const DEFAULT_PREFS = {
1781
+ pinned: [],
1782
+ accent: "#4d7cfd",
1783
+ iconSize: 28,
1784
+ inset: 14,
1785
+ side: "right",
1786
+ locale: "system"
1787
+ };
1337
1788
  /** Type guard for registry ids — unknown strings never survive a parse. */
1338
1789
  function isToolbarPinId(id) {
1339
1790
  return TOOLBAR_PIN_IDS.includes(id);
1340
1791
  }
1341
- /**
1342
- * Parse + sanitize the raw persisted blob: `null` (nothing stored), invalid
1343
- * JSON, or a non-`{pinned: string[]}` shape all degrade to `[]`; unknown ids
1344
- * and duplicates are dropped; the result keeps the caller's first-seen
1345
- * (pin) order.
1346
- */
1347
- function parsePrefs(raw) {
1348
- if (raw === null) return [];
1349
- let parsed;
1350
- try {
1351
- parsed = JSON.parse(raw);
1352
- } catch {
1353
- return [];
1354
- }
1355
- if (typeof parsed !== "object" || parsed === null) return [];
1356
- const { pinned } = parsed;
1357
- if (!Array.isArray(pinned)) return [];
1792
+ /** Whitelist + dedupe + first-seen-order sanitizer for the pinned list. */
1793
+ function sanitizePinned(raw) {
1794
+ if (!Array.isArray(raw)) return [];
1358
1795
  const seen = /* @__PURE__ */ new Set();
1359
1796
  const result = [];
1360
- for (const id of pinned) {
1797
+ for (const id of raw) {
1361
1798
  if (typeof id !== "string" || !isToolbarPinId(id) || seen.has(id)) continue;
1362
1799
  seen.add(id);
1363
1800
  result.push(id);
@@ -1365,42 +1802,78 @@ window.__ModuleLoader__.load({
1365
1802
  return result;
1366
1803
  }
1367
1804
  /**
1805
+ * Snap a finite number to the nearest `step` inside [min, max]; any non-finite
1806
+ * or non-number input falls back to `fallback`. Used for both sliders so a
1807
+ * hand-edited blob (e.g. `iconSize: 21`) converges on a legal slider value.
1808
+ */
1809
+ function clampStep(value, min, max, step, fallback) {
1810
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
1811
+ const snapped = Math.round(Math.min(max, Math.max(min, value)) / step) * step;
1812
+ return Math.min(max, Math.max(min, snapped));
1813
+ }
1814
+ /**
1815
+ * Parse + sanitize the raw persisted blob: `null` (nothing stored), invalid
1816
+ * JSON, or a non-object shape all degrade to the DEFAULT prefs. Each field is
1817
+ * sanitized independently, so a half-corrupt blob keeps its valid parts
1818
+ * (e.g. an old `{pinned}`-only blob gains the default accent/size/inset/side).
1819
+ */
1820
+ function parsePrefs(raw) {
1821
+ if (raw === null) return { ...DEFAULT_PREFS };
1822
+ let parsed;
1823
+ try {
1824
+ parsed = JSON.parse(raw);
1825
+ } catch {
1826
+ return { ...DEFAULT_PREFS };
1827
+ }
1828
+ if (typeof parsed !== "object" || parsed === null) return { ...DEFAULT_PREFS };
1829
+ const { pinned, accent, iconSize, inset, side, locale } = parsed;
1830
+ return {
1831
+ pinned: sanitizePinned(pinned),
1832
+ accent: typeof accent === "string" && isHexColor(accent) ? accent.toLowerCase() : DEFAULT_PREFS.accent,
1833
+ iconSize: clampStep(iconSize, 20, 36, 2, DEFAULT_PREFS.iconSize),
1834
+ inset: clampStep(inset, 0, 40, 2, DEFAULT_PREFS.inset),
1835
+ side: side === "left" || side === "right" ? side : DEFAULT_PREFS.side,
1836
+ locale: locale === "zh" || locale === "en" || locale === "system" ? locale : DEFAULT_PREFS.locale
1837
+ };
1838
+ }
1839
+ /**
1368
1840
  * Read + sanitize the persisted toolbar prefs from localStorage. Degrades to
1369
- * `[]` when storage is unavailable (SSR, sandboxed iframe) — pinning is a
1370
- * best-effort enhancement, never a render blocker.
1841
+ * the DEFAULT prefs when storage is unavailable (SSR, sandboxed iframe) —
1842
+ * personalization is a best-effort enhancement, never a render blocker.
1371
1843
  */
1372
1844
  function loadPrefs() {
1373
1845
  try {
1374
1846
  return parsePrefs(localStorage.getItem(TOOLBAR_PREFS_KEY));
1375
1847
  } catch {
1376
- return [];
1848
+ return { ...DEFAULT_PREFS };
1377
1849
  }
1378
1850
  }
1379
1851
  /**
1380
- * Persist the pinned ids (sanitized on the way out so a corrupt in-memory
1381
- * value is never written). Takes plain strings it cannot trust its input
1382
- * any more than it trusts localStorage, so unknown ids are dropped, not
1383
- * rejected. Swallows storage failures for the same best-effort reason as
1384
- * {@link loadPrefs}.
1852
+ * Persist the full prefs (sanitized on the way out so a corrupt in-memory
1853
+ * value is never written). Swallows storage failures for the same best-effort
1854
+ * reason as {@link loadPrefs}.
1385
1855
  */
1386
- function savePrefs(pinned) {
1387
- const cleaned = parsePrefs(JSON.stringify({ pinned: [...pinned] }));
1856
+ function savePrefs(prefs) {
1857
+ const cleaned = parsePrefs(JSON.stringify(prefs));
1388
1858
  try {
1389
- localStorage.setItem(TOOLBAR_PREFS_KEY, JSON.stringify({ pinned: cleaned }));
1859
+ localStorage.setItem(TOOLBAR_PREFS_KEY, JSON.stringify(cleaned));
1390
1860
  } catch {}
1391
1861
  }
1392
1862
  /**
1393
1863
  * Pure toggle: adds `id` to the pinned set when absent, removes it when
1394
- * present. Unknown ids are ignored (the set is returned unchanged) and the
1395
- * result is always deduped via the parse path, so callers can feed state
1864
+ * present. Unknown ids are ignored (prefs returned unchanged) and the pinned
1865
+ * set is always deduped via the sanitizer, so callers can feed the result
1396
1866
  * straight back into {@link savePrefs}.
1397
1867
  */
1398
- function togglePin(pinned, id) {
1399
- if (!isToolbarPinId(id)) return [...pinned];
1400
- const next = new Set(pinned);
1868
+ function togglePin(prefs, id) {
1869
+ if (!isToolbarPinId(id)) return { ...prefs };
1870
+ const next = new Set(prefs.pinned);
1401
1871
  if (next.has(id)) next.delete(id);
1402
1872
  else next.add(id);
1403
- return parsePrefs(JSON.stringify({ pinned: [...next] }));
1873
+ return {
1874
+ ...prefs,
1875
+ pinned: sanitizePinned([...next])
1876
+ };
1404
1877
  }
1405
1878
  //#endregion
1406
1879
  //#region src/client/version-logic.ts
@@ -1706,12 +2179,12 @@ window.__ModuleLoader__.load({
1706
2179
  * Installed plugin version. Injected at build time as
1707
2180
  * `__DSH_MILESTONE_VERSION__`; falls back to `0.0.0-dev` when unbuilt.
1708
2181
  */
1709
- const PLUGIN_VERSION = "0.6.1";
2182
+ const PLUGIN_VERSION = "0.6.2";
1710
2183
  //#endregion
1711
2184
  //#region src/client/MilestoneRail.tsx
1712
2185
  /**
1713
2186
  * MilestoneRail: the milestone.rail entry (session scope). Renders a fixed
1714
- * right-side vertical scrubber as a **fixed-pitch dot list** (like a git commit
2187
+ * side vertical scrubber as a **fixed-pitch dot list** (like a git commit
1715
2188
  * graph), NOT a minimap: one dot per user message, equal spacing regardless of
1716
2189
  * conversation length. The list itself scrolls with the wheel when it outgrows
1717
2190
  * the viewport; hovering a dot shows rich metadata (time, turn, duration, end
@@ -1723,14 +2196,16 @@ window.__ModuleLoader__.load({
1723
2196
  * and the ui-conversation 'turn-tail'
1724
2197
  * location data (ttftMs/tokensPerSecond)
1725
2198
  *
1726
- * Positioning: the rail hugs the conversation scrollport's right edge (offset a
1727
- * little inward so it clears the native scrollbar and sits near the prose).
2199
+ * Positioning: the rail hugs the conversation scrollport's chosen screen edge
2200
+ * (settings 位置: left or right), offset a little inward so it clears the
2201
+ * native scrollbar and sits near the prose.
1728
2202
  *
1729
2203
  * In-rail search (F1): a magnifier toggle at the rail top opens a compact
1730
- * panel to the rail's left with a message-text search input; matches light up
1731
- * the dots (non-matches dim), Enter cycles the active match (wrapping) and
1732
- * jumps to it, Escape clears and closes. Matching runs over the FULL message
1733
- * text (`text` from rail-logic.extractText), not the truncated hover preview.
2204
+ * panel on the rail's free side with a message-text search input; matches
2205
+ * light up the dots (non-matches dim), Enter cycles the active match
2206
+ * (wrapping) and jumps to it, Escape clears and closes. Matching runs over the
2207
+ * FULL message text (`text` from rail-logic.extractText), not the truncated
2208
+ * hover preview.
1734
2209
  *
1735
2210
  * Current-position highlight (F2): the dot for the user message at/just above
1736
2211
  * the conversation viewport top carries a white ring (`useCurrentAnchor`
@@ -1739,8 +2214,15 @@ window.__ModuleLoader__.load({
1739
2214
  * Load-older + window coverage (F3): when the session still has earlier pages
1740
2215
  * (`hasMore`) a slim `···` button sits at the rail top and triggers the
1741
2216
  * injected `loadOlder` action (disabled + `data-loading-older` while
1742
- * `loadingOlder`), and a compact hint to the rail's left states how many
2217
+ * `loadingOlder`), and a compact hint on the rail's free side states how many
1743
2218
  * messages the current window covers.
2219
+ *
2220
+ * Settings (B-design): the gear is a REGULAR toolbar feature ("settings",
2221
+ * registry last, default unpinned) — the collapsed rail shows only the expand
2222
+ * arrow plus the user's pinned keys, and expanding reveals the gear at the end
2223
+ * of the queue. The gear opens a CENTERED modal dialog (function-key pins /
2224
+ * hover descriptions, the personalization controls, and the support-us card
2225
+ * grid); everything the modal changes persists under `dsh-milestone.toolbar`.
1744
2226
  */
1745
2227
  /** Minimum user messages before the rail adds value. */
1746
2228
  const MIN_MARKS = 2;
@@ -1749,17 +2231,40 @@ window.__ModuleLoader__.load({
1749
2231
  const NO_BOOKMARKS = [];
1750
2232
  /** Stable no-kinds fallback for marks whose turn carries no badge nodes. */
1751
2233
  const NO_KINDS = [];
2234
+ /** Visual dot diameter at the default icon size (px). */
2235
+ const DOT_SIZE = 14;
2236
+ /** Hit area per dot at the default icon size (px) — larger than the dot. */
2237
+ const DOT_HIT = 28;
2238
+ /** Vertical gap between dot hit areas at the default icon size (px). */
2239
+ const DOT_GAP = 14;
1752
2240
  /**
1753
- * Self-contained pulse keyframes for the transient badges (running/awaiting):
1754
- * an expanding currentColor ring on box-shadow plus an opacity beat, driven by
1755
- * `animation` on the badge ring span (kept in an inline <style> so the plugin
1756
- * stays zero-asset).
2241
+ * Extra top margin a new turn group's FIRST dot gets (replaces the old
2242
+ * `data-turn-separator` line): same-group pitch stays DOT_GAP, a group
2243
+ * boundary opens another GROUP_GAP_EXTRA px (14 18 at default size),
2244
+ * expressed purely as spacing — no line element.
1757
2245
  */
1758
- const BADGE_PULSE_CSS = `@keyframes milestone-badge-pulse {
1759
- 0% { box-shadow: 0 0 0 0 currentColor; opacity: 0.85 }
1760
- 70% { box-shadow: 0 0 0 5px transparent; opacity: 0.35 }
1761
- 100% { box-shadow: 0 0 0 0 transparent; opacity: 0.85 }
1762
- }`;
2246
+ const GROUP_GAP_EXTRA = 4;
2247
+ /**
2248
+ * Preset accent swatches for the settings 强调色 row (default blue first).
2249
+ * The custom color input accepts any #rrggbb.
2250
+ */
2251
+ const ACCENT_PRESETS = [
2252
+ "#4d7cfd",
2253
+ "#22c55e",
2254
+ "#f59e0b",
2255
+ "#ef4444",
2256
+ "#a855f7",
2257
+ "#06b6d4",
2258
+ "#ec4899",
2259
+ "#f97316"
2260
+ ];
2261
+ /** Known floating-panel widths (px) used to anchor side=left panels to the
2262
+ * rail's free (right) side — the panel components take a viewport `right`
2263
+ * offset, so a left rail must back-calculate it from the panel width. */
2264
+ const PANEL_WIDTH_SEARCH = 220;
2265
+ const PANEL_WIDTH_STANDARD = 280;
2266
+ /** Tooltip anchor width: its maxWidth cap, so a tooltip never overlaps the rail. */
2267
+ const TOOLTIP_ANCHOR_WIDTH = 300;
1763
2268
  /**
1764
2269
  * P3 focus mode: dims the harness's AI thinking/scratchpad blocks so the
1765
2270
  * conversation reads cleaner. The rule targets the stable, un-hashed
@@ -1767,18 +2272,33 @@ window.__ModuleLoader__.load({
1767
2272
  * renders it as `data-variant="think"` with `data-state="running|ok"`), so an
1768
2273
  * overlay plugin can dim it with plain CSS. Hovering a dimmed block (or
1769
2274
  * opening it, `[data-open]`) restores full opacity. Kept in an inline
1770
- * <style> so the plugin stays zero-asset — same pattern as BADGE_PULSE_CSS.
2275
+ * <style> so the plugin stays zero-asset.
1771
2276
  */
1772
2277
  const FOCUS_CSS = `[data-variant="think"] { opacity: 0.4; transition: opacity 0.2s; }
1773
2278
  [data-variant="think"]:hover, [data-variant="think"] [data-open] { opacity: 1; }`;
1774
- /** Visual dot diameter (px). */
1775
- const DOT_SIZE = 14;
1776
- /** Hit area per dot (px) larger than the dot for comfortable clicking. */
1777
- const DOT_HIT = 28;
1778
- /** Vertical gap between dot hit areas (px) fixed pitch, never scaled. */
1779
- const DOT_GAP = 14;
1780
- /** Inward offset from the scrollport right edge so the rail clears the scrollbar. */
1781
- const RAIL_INSET = 14;
2279
+ /**
2280
+ * Static settings-modal styling that needs `:hover` (which inline styles
2281
+ * cannot express): the support-us card grid (micro-lift + accent highlight)
2282
+ * and the pin-row hover wash. Accent values come from the rail root's CSS
2283
+ * variables (`--ms-accent`), so one block serves every accent. The
2284
+ * search-toggle recolor rule makes the EXTERNAL RailSearchUi chrome follow
2285
+ * the accent too (that file is owned by an earlier phase and cannot change);
2286
+ * `!important` is required because the toggle's own inline styles win
2287
+ * otherwise.
2288
+ */
2289
+ const MODAL_CSS = `
2290
+ [data-support-card] {
2291
+ display: flex; align-items: center; justify-content: center; gap: 8px;
2292
+ padding: 10px 12px; border-radius: 8px;
2293
+ background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.12);
2294
+ color: #c7cede; text-decoration: none; font-size: 12.5px; line-height: 1.4;
2295
+ transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
2296
+ }
2297
+ [data-support-card]:hover { transform: translateY(-2px); border-color: var(--ms-accent); background: rgba(255, 255, 255, 0.09); }
2298
+ [data-toolbar-pin-toggle]:hover, [data-toolbar-pin-toggle]:focus-visible { background: rgba(255, 255, 255, 0.06); }
2299
+ [data-search-toggle] { color: #8b96ab !important; }
2300
+ [data-search-toggle][aria-pressed="true"] { background: var(--ms-accent-bg) !important; color: var(--ms-accent-soft) !important; }
2301
+ `;
1782
2302
  /**
1783
2303
  * P3 deep links (`#msg=<anchor-key>`): initial delay before the first
1784
2304
  * deep-link attempt — the harness scrolls the conversation to the bottom on
@@ -1846,7 +2366,7 @@ window.__ModuleLoader__.load({
1846
2366
  function MilestoneRail({ useSession, loadOlder, forkAt, useStore, actions, searchSessions = async () => ({
1847
2367
  items: [],
1848
2368
  hasMore: false
1849
- }), openSession = () => {}, t = (key) => key }) {
2369
+ }), openSession = () => {}, t: frameworkT = (key) => key }) {
1850
2370
  const order = useSession((s) => s.chat.order);
1851
2371
  const nodes = useSession((s) => s.chat.nodes);
1852
2372
  const locations = useSession((s) => s.chat.locations);
@@ -1951,46 +2471,57 @@ window.__ModuleLoader__.load({
1951
2471
  }
1952
2472
  return counts;
1953
2473
  }, [displayMarks]);
1954
- (0, react.useLayoutEffect)(() => {
1955
- if (marks.length < MIN_MARKS) {
1956
- setRailBox(null);
1957
- return;
1958
- }
1959
- const scrollport = document.querySelector("[data-conversation-scroll]");
1960
- if (scrollport === null) return;
1961
- const compute = () => {
1962
- const sp = scrollport.getBoundingClientRect();
1963
- setRailBox({
1964
- top: sp.top,
1965
- height: sp.height,
1966
- right: Math.max(8, window.innerWidth - sp.right + RAIL_INSET)
1967
- });
1968
- };
1969
- compute();
1970
- const observer = new ResizeObserver(compute);
1971
- observer.observe(scrollport);
1972
- window.addEventListener("resize", compute);
1973
- return () => {
1974
- observer.disconnect();
1975
- window.removeEventListener("resize", compute);
1976
- };
1977
- }, [marks.length]);
1978
- (0, react.useLayoutEffect)(() => {
1979
- setFocusIndex((f) => clampIndex(f, render.items.length));
1980
- }, [render.items.length]);
1981
- (0, react.useEffect)(() => {
1982
- if (!listOpen && !crossOpen) return;
1983
- const onKey = (e) => {
1984
- if (e.key !== "Escape") return;
1985
- setListOpen(false);
1986
- setCrossOpen(false);
1987
- };
1988
- window.addEventListener("keydown", onKey);
1989
- return () => window.removeEventListener("keydown", onKey);
1990
- }, [listOpen, crossOpen]);
2474
+ const [prefs, setPrefs] = (0, react.useState)(() => loadPrefs());
2475
+ const { pinned, accent, iconSize, inset, side } = prefs;
2476
+ const scale = iconSize / DOT_HIT;
2477
+ const hit = iconSize;
2478
+ const size = DOT_SIZE * scale;
2479
+ const gap = DOT_GAP * scale;
2480
+ const accentSoft = lighten(accent, .42) ?? "#9db8ff";
2481
+ const accentBg = rgbaString(accent, .18) ?? "rgba(77, 124, 254, 0.18)";
2482
+ const accentStrong = rgbaString(accent, .55) ?? "rgba(77, 124, 254, 0.55)";
2483
+ /**
2484
+ * Language override (settings → 语言): `system` delegates to the harness
2485
+ * `t` seat (the framework-synthesized interpreter for the registered
2486
+ * `dsh-milestone` namespace); `zh`/`en` force the plugin's own dictionaries
2487
+ * so the rail copy switches independently of the host UI language. Every
2488
+ * call site below — rail chrome, panels, tooltip and the settings modal —
2489
+ * already resolves through this binding, so the override is global to the
2490
+ * rail without threading a second translate prop anywhere.
2491
+ */
2492
+ const t = prefs.locale === "system" ? frameworkT : prefs.locale === "en" ? (key, params) => translateDict(en, key, params) : (key, params) => translateDict(zh, key, params);
2493
+ /** Write a patch of prefs through to state + localStorage. */
2494
+ const updatePrefs = (patch) => {
2495
+ setPrefs((prev) => {
2496
+ const next = {
2497
+ ...prev,
2498
+ ...patch
2499
+ };
2500
+ savePrefs(next);
2501
+ return next;
2502
+ });
2503
+ };
2504
+ /** B1: flip one feature's pin — state and the persisted blob update together. */
2505
+ const onTogglePin = (id) => {
2506
+ setPrefs((prev) => {
2507
+ const next = togglePin(prev, id);
2508
+ savePrefs(next);
2509
+ return next;
2510
+ });
2511
+ };
2512
+ /** B-design: 恢复默认 resets EVERYTHING — pins AND personalization. */
2513
+ const onResetAll = () => {
2514
+ const next = { ...DEFAULT_PREFS };
2515
+ setPrefs(next);
2516
+ savePrefs(next);
2517
+ };
1991
2518
  const [toolbarExpanded, setToolbarExpanded] = (0, react.useState)(false);
2519
+ const [expandHovered, setExpandHovered] = (0, react.useState)(false);
2520
+ const [settingsHovered, setSettingsHovered] = (0, react.useState)(false);
1992
2521
  const [settingsOpen, setSettingsOpen] = (0, react.useState)(false);
1993
- const [pinned, setPinned] = (0, react.useState)(() => loadPrefs());
2522
+ /** The feature whose description the settings modal's right pane shows
2523
+ * (defaults to the first registry feature — 'search'). */
2524
+ const [descFeature, setDescFeature] = (0, react.useState)("search");
1994
2525
  const settingsRef = (0, react.useRef)(null);
1995
2526
  const settingsBtnRef = (0, react.useRef)(null);
1996
2527
  const [updateOpen, setUpdateOpen] = (0, react.useState)(false);
@@ -1998,11 +2529,12 @@ window.__ModuleLoader__.load({
1998
2529
  const updatePanelRef = (0, react.useRef)(null);
1999
2530
  const updateBtnRef = (0, react.useRef)(null);
2000
2531
  /**
2001
- * B1 settings menu: outside-pointerdown dismisses the menu (shared
2532
+ * B1 settings modal: outside-pointerdown dismisses it (shared
2002
2533
  * useOutsideDismiss contract) with focus returning to the gear afterwards.
2003
- * The gear's own click keeps its flip semantics through a
2004
- * `[data-toolbar-settings]` exclusion pointerdown on an armed gear must
2005
- * not double-close.
2534
+ * The modal's full-screen overlay wraps the dialog, so a pointerdown on the
2535
+ * backdrop (or anywhere outside the dialog) closes it; the gear's own click
2536
+ * keeps its flip semantics through a `[data-toolbar-settings]` exclusion —
2537
+ * pointerdown on an armed gear must not double-close.
2006
2538
  */
2007
2539
  useOutsideDismiss(settingsRef, settingsOpen, () => {
2008
2540
  setSettingsOpen(false);
@@ -2018,6 +2550,10 @@ window.__ModuleLoader__.load({
2018
2550
  window.addEventListener("keydown", onKey);
2019
2551
  return () => window.removeEventListener("keydown", onKey);
2020
2552
  }, [settingsOpen]);
2553
+ (0, react.useEffect)(() => {
2554
+ if (!settingsOpen) return;
2555
+ (settingsRef.current?.querySelector("[data-toolbar-settings-close]"))?.focus();
2556
+ }, [settingsOpen]);
2021
2557
  /**
2022
2558
  * B4: run one update check. Cache-aware (`loadCachedLatest` reuses an
2023
2559
  * unexpired cached result without any network traffic) and never throws:
@@ -2112,6 +2648,64 @@ window.__ModuleLoader__.load({
2112
2648
  window.addEventListener("hashchange", onHashChange);
2113
2649
  return () => window.removeEventListener("hashchange", onHashChange);
2114
2650
  }, []);
2651
+ (0, react.useLayoutEffect)(() => {
2652
+ if (marks.length < MIN_MARKS) {
2653
+ setRailBox(null);
2654
+ return;
2655
+ }
2656
+ const scrollport = document.querySelector("[data-conversation-scroll]");
2657
+ if (scrollport === null) return;
2658
+ const compute = () => {
2659
+ const sp = scrollport.getBoundingClientRect();
2660
+ setRailBox({
2661
+ top: sp.top,
2662
+ height: sp.height,
2663
+ right: Math.max(0, window.innerWidth - sp.right + inset),
2664
+ left: Math.max(0, sp.left + inset)
2665
+ });
2666
+ };
2667
+ compute();
2668
+ const observer = new ResizeObserver(compute);
2669
+ observer.observe(scrollport);
2670
+ window.addEventListener("resize", compute);
2671
+ return () => {
2672
+ observer.disconnect();
2673
+ window.removeEventListener("resize", compute);
2674
+ };
2675
+ }, [marks.length, inset]);
2676
+ (0, react.useLayoutEffect)(() => {
2677
+ setFocusIndex((f) => clampIndex(f, render.items.length));
2678
+ }, [render.items.length]);
2679
+ const lastBadge = (0, react.useMemo)(() => {
2680
+ if (displayMarks.length === 0) return null;
2681
+ const last = displayMarks[displayMarks.length - 1];
2682
+ return deriveBadge({
2683
+ nodeKinds: last.turn === void 0 ? NO_KINDS : kindsByTurn.get(last.turn) ?? NO_KINDS,
2684
+ lastMark: true,
2685
+ running,
2686
+ awaitingInput
2687
+ });
2688
+ }, [
2689
+ displayMarks,
2690
+ kindsByTurn,
2691
+ running,
2692
+ awaitingInput
2693
+ ]);
2694
+ const pulseCss = (0, react.useMemo)(() => {
2695
+ if (lastBadge === null) return null;
2696
+ const style = badgeRingStyle(lastBadge);
2697
+ return style.pulse ? badgePulseCss(style.color) : null;
2698
+ }, [lastBadge]);
2699
+ (0, react.useEffect)(() => {
2700
+ if (!listOpen && !crossOpen) return;
2701
+ const onKey = (e) => {
2702
+ if (e.key !== "Escape") return;
2703
+ setListOpen(false);
2704
+ setCrossOpen(false);
2705
+ };
2706
+ window.addEventListener("keydown", onKey);
2707
+ return () => window.removeEventListener("keydown", onKey);
2708
+ }, [listOpen, crossOpen]);
2115
2709
  if (railBox === null || marks.length < MIN_MARKS) return null;
2116
2710
  const updateQuery = (query) => {
2117
2711
  setSearch({
@@ -2148,28 +2742,45 @@ window.__ModuleLoader__.load({
2148
2742
  if (e.key === "Enter") advanceMatch();
2149
2743
  if (e.key === "Escape") closeSearch();
2150
2744
  };
2151
- /** B1: flip one feature's pin — state and the persisted blob update together. */
2152
- const onTogglePin = (id) => {
2153
- setPinned((prev) => {
2154
- const next = togglePin(prev, id);
2155
- savePrefs(next);
2156
- return next;
2157
- });
2158
- };
2159
- /** B1: reset every pin (everything folds away) and persist the empty set. */
2160
- const onResetPins = () => {
2161
- setPinned([]);
2162
- savePrefs([]);
2745
+ /**
2746
+ * B-design: viewport `right` offset for the floating layers. On the classic
2747
+ * right-side rail the panels sit left of the rail (their right edge at
2748
+ * railBox.right + hit + 8); on a LEFT rail every layer flips to the rail's
2749
+ * OTHER side — its left edge at railBox.left + hit + 8, which means its
2750
+ * viewport `right` must be backed out from the (known) panel width.
2751
+ */
2752
+ const panelRightFor = (panelWidth) => side === "left" ? window.innerWidth - (railBox.left + hit + 8 + panelWidth) : railBox.right + hit + 8;
2753
+ /** Close the settings modal via its backdrop/close button paths. */
2754
+ const closeSettings = () => {
2755
+ setSettingsOpen(false);
2756
+ settingsBtnRef.current?.focus();
2163
2757
  };
2164
2758
  /** B1: a feature renders while the toolbar is EXPANDED or while it is pinned. */
2165
2759
  const featureVisible = (id) => toolbarExpanded || pinned.includes(id);
2760
+ /** Base chrome-button style: accent-defined active tint, scaled hit area. */
2761
+ const chromeButtonStyle = (active) => ({
2762
+ width: hit,
2763
+ height: hit,
2764
+ flexShrink: 0,
2765
+ display: "flex",
2766
+ alignItems: "center",
2767
+ justifyContent: "center",
2768
+ background: active ? accentBg : "transparent",
2769
+ border: "none",
2770
+ padding: 0,
2771
+ cursor: "pointer",
2772
+ color: active ? accentSoft : "#8b96ab",
2773
+ transition: "background 120ms ease, color 120ms ease"
2774
+ });
2166
2775
  /**
2167
2776
  * B1: the data-driven feature registry. Each entry's render is the feature's
2168
- * UNCHANGED rail-top chrome (data attributes / aria semantics preserved),
2169
- * moved verbatim from the previous static button block; `search` is the
2170
- * whole RailSearchUi (toggle + panel) so its lifecycle stays component-local
2171
- * in the rail (search state lives in the rail and survives unmount).
2172
- * Registry order = settings-menu order (站内搜索/全部提问/跨会话搜索/只看收藏/聚焦模式).
2777
+ * rail-top chrome (data attributes / aria semantics preserved), moved
2778
+ * verbatim from the previous static button block; `search` is the whole
2779
+ * RailSearchUi (toggle + panel) so its lifecycle stays component-local in
2780
+ * the rail (search state lives in the rail and survives unmount). `settings`
2781
+ * lives LAST in the queue — the gear is a regular, default-unpinned feature;
2782
+ * the modal must stay reachable via 展开→齿轮.
2783
+ * Registry order = settings-menu order (站内搜索/全部提问/跨会话搜索/只看收藏/聚焦模式/检查更新/设置).
2173
2784
  *
2174
2785
  * EXTENSION POINT: push a new feature here (+ its id in toolbar-prefs.ts's
2175
2786
  * TOOLBAR_PIN_IDS and its locale keys) and pinning/settings/expand all
@@ -2181,7 +2792,7 @@ window.__ModuleLoader__.load({
2181
2792
  labelKey: "search.label",
2182
2793
  render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RailSearchUi, {
2183
2794
  panelTop: railBox.top,
2184
- panelRight: railBox.right + DOT_HIT + 8,
2795
+ panelRight: panelRightFor(PANEL_WIDTH_SEARCH),
2185
2796
  query: search.query,
2186
2797
  panelOpen: search.panelOpen,
2187
2798
  matches: matches.length,
@@ -2206,19 +2817,7 @@ window.__ModuleLoader__.load({
2206
2817
  title: listOpen ? t("list.close") : t("list.open"),
2207
2818
  "aria-pressed": listOpen,
2208
2819
  onClick: () => setListOpen((v) => !v),
2209
- style: {
2210
- width: DOT_HIT,
2211
- height: DOT_HIT,
2212
- flexShrink: 0,
2213
- display: "flex",
2214
- alignItems: "center",
2215
- justifyContent: "center",
2216
- background: listOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
2217
- border: "none",
2218
- padding: 0,
2219
- cursor: "pointer",
2220
- color: listOpen ? "#9db8ff" : "#8b96ab"
2221
- },
2820
+ style: chromeButtonStyle(listOpen),
2222
2821
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2223
2822
  width: "16",
2224
2823
  height: "16",
@@ -2246,19 +2845,7 @@ window.__ModuleLoader__.load({
2246
2845
  title: crossOpen ? t("search.cross.close") : t("search.cross.open"),
2247
2846
  "aria-pressed": crossOpen,
2248
2847
  onClick: () => setCrossOpen((v) => !v),
2249
- style: {
2250
- width: DOT_HIT,
2251
- height: DOT_HIT,
2252
- flexShrink: 0,
2253
- display: "flex",
2254
- alignItems: "center",
2255
- justifyContent: "center",
2256
- background: crossOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
2257
- border: "none",
2258
- padding: 0,
2259
- cursor: "pointer",
2260
- color: crossOpen ? "#9db8ff" : "#8b96ab"
2261
- },
2848
+ style: chromeButtonStyle(crossOpen),
2262
2849
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2263
2850
  width: "16",
2264
2851
  height: "16",
@@ -2293,19 +2880,7 @@ window.__ModuleLoader__.load({
2293
2880
  "aria-pressed": bookmarksOnly,
2294
2881
  "data-active": bookmarksOnly ? "true" : void 0,
2295
2882
  onClick: () => setBookmarksOnly((v) => !v),
2296
- style: {
2297
- width: DOT_HIT,
2298
- height: DOT_HIT,
2299
- flexShrink: 0,
2300
- display: "flex",
2301
- alignItems: "center",
2302
- justifyContent: "center",
2303
- background: bookmarksOnly ? "rgba(77, 124, 254, 0.18)" : "transparent",
2304
- border: "none",
2305
- padding: 0,
2306
- cursor: "pointer",
2307
- color: bookmarksOnly ? "#9db8ff" : "#8b96ab"
2308
- },
2883
+ style: chromeButtonStyle(bookmarksOnly),
2309
2884
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
2310
2885
  width: "16",
2311
2886
  height: "16",
@@ -2329,19 +2904,7 @@ window.__ModuleLoader__.load({
2329
2904
  title: focusActive ? t("focus.off") : t("focus.on"),
2330
2905
  "aria-pressed": focusActive,
2331
2906
  onClick: () => setFocusActive((v) => !v),
2332
- style: {
2333
- width: DOT_HIT,
2334
- height: DOT_HIT,
2335
- flexShrink: 0,
2336
- display: "flex",
2337
- alignItems: "center",
2338
- justifyContent: "center",
2339
- background: focusActive ? "rgba(126, 226, 168, 0.14)" : "transparent",
2340
- border: "none",
2341
- padding: 0,
2342
- cursor: "pointer",
2343
- color: focusActive ? "#7ee2a8" : "#8b96ab"
2344
- },
2907
+ style: chromeButtonStyle(focusActive),
2345
2908
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2346
2909
  width: "16",
2347
2910
  height: "16",
@@ -2373,17 +2936,17 @@ window.__ModuleLoader__.load({
2373
2936
  onClick: () => setUpdateOpen((v) => !v),
2374
2937
  style: {
2375
2938
  position: "relative",
2376
- width: DOT_HIT,
2377
- height: DOT_HIT,
2939
+ width: hit,
2940
+ height: hit,
2378
2941
  flexShrink: 0,
2379
2942
  display: "flex",
2380
2943
  alignItems: "center",
2381
2944
  justifyContent: "center",
2382
- background: updateOpen || updateCheck.available ? "rgba(245, 197, 66, 0.14)" : "transparent",
2945
+ background: updateCheck.available ? "rgba(245, 197, 66, 0.14)" : updateOpen ? accentBg : "transparent",
2383
2946
  border: "none",
2384
2947
  padding: 0,
2385
2948
  cursor: "pointer",
2386
- color: updateCheck.available ? "#f5c542" : "#8b96ab"
2949
+ color: updateCheck.available ? "#f5c542" : updateOpen ? accentSoft : "#8b96ab"
2387
2950
  },
2388
2951
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2389
2952
  width: "16",
@@ -2416,6 +2979,40 @@ window.__ModuleLoader__.load({
2416
2979
  }
2417
2980
  })]
2418
2981
  })
2982
+ },
2983
+ {
2984
+ id: "settings",
2985
+ labelKey: "settings.label",
2986
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2987
+ type: "button",
2988
+ ref: settingsBtnRef,
2989
+ "data-toolbar-settings": true,
2990
+ "aria-pressed": settingsOpen,
2991
+ "aria-label": settingsOpen ? t("toolbar.settings.close") : t("toolbar.settings.open"),
2992
+ title: settingsOpen ? t("toolbar.settings.close") : t("toolbar.settings.open"),
2993
+ onClick: () => setSettingsOpen((v) => !v),
2994
+ onMouseEnter: () => setSettingsHovered(true),
2995
+ onMouseLeave: () => setSettingsHovered(false),
2996
+ onFocus: () => setSettingsHovered(true),
2997
+ onBlur: () => setSettingsHovered(false),
2998
+ style: chromeButtonStyle(settingsOpen || settingsHovered),
2999
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
3000
+ width: "16",
3001
+ height: "16",
3002
+ viewBox: "0 0 24 24",
3003
+ fill: "none",
3004
+ stroke: "currentColor",
3005
+ strokeWidth: "2",
3006
+ strokeLinecap: "round",
3007
+ strokeLinejoin: "round",
3008
+ "aria-hidden": "true",
3009
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
3010
+ cx: "12",
3011
+ cy: "12",
3012
+ r: "3"
3013
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" })]
3014
+ })
3015
+ })
2419
3016
  }
2420
3017
  ];
2421
3018
  /** Focus the dot at `index` (no-op while the list is unmounted). */
@@ -2533,25 +3130,37 @@ window.__ModuleLoader__.load({
2533
3130
  forkAt(mark.seq).then(() => setForkedKey(mark.key));
2534
3131
  };
2535
3132
  const showLoadOlder = hasMore && marks.length >= MIN_MARKS;
3133
+ const railStyle = {
3134
+ position: "fixed",
3135
+ top: railBox.top,
3136
+ ...side === "left" ? { left: railBox.left } : { right: railBox.right },
3137
+ height: railBox.height,
3138
+ width: hit,
3139
+ pointerEvents: "auto",
3140
+ zIndex: 100,
3141
+ display: "flex",
3142
+ flexDirection: "column",
3143
+ gap: 6,
3144
+ paddingTop: 6,
3145
+ "--ms-accent": accent,
3146
+ "--ms-accent-soft": accentSoft,
3147
+ "--ms-accent-bg": accentBg,
3148
+ "--ms-icon": `${iconSize}px`,
3149
+ "--ms-inset": `${inset}px`
3150
+ };
3151
+ const descKey = `settings.desc.${descFeature}`;
2536
3152
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2537
- style: {
2538
- position: "fixed",
2539
- top: railBox.top,
2540
- right: railBox.right,
2541
- height: railBox.height,
2542
- width: DOT_HIT,
2543
- pointerEvents: "auto",
2544
- zIndex: 100,
2545
- display: "flex",
2546
- flexDirection: "column",
2547
- gap: 6,
2548
- paddingTop: 6
2549
- },
3153
+ style: railStyle,
2550
3154
  "aria-label": t("rail.label"),
2551
3155
  "data-focus-active": focusActive ? "true" : void 0,
3156
+ "data-accent": accent,
3157
+ "data-side": side,
3158
+ "data-icon-size": String(iconSize),
3159
+ "data-inset": String(inset),
2552
3160
  children: [
2553
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: BADGE_PULSE_CSS }),
3161
+ pulseCss !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: pulseCss }),
2554
3162
  focusActive && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: FOCUS_CSS }),
3163
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: MODAL_CSS }),
2555
3164
  showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2556
3165
  type: "button",
2557
3166
  "data-load-older": true,
@@ -2563,8 +3172,8 @@ window.__ModuleLoader__.load({
2563
3172
  loadOlder();
2564
3173
  },
2565
3174
  style: {
2566
- width: DOT_HIT,
2567
- height: DOT_HIT,
3175
+ width: hit,
3176
+ height: hit,
2568
3177
  flexShrink: 0,
2569
3178
  display: "flex",
2570
3179
  alignItems: "center",
@@ -2587,19 +3196,11 @@ window.__ModuleLoader__.load({
2587
3196
  "aria-label": toolbarExpanded ? t("toolbar.collapse") : t("toolbar.expand"),
2588
3197
  title: toolbarExpanded ? t("toolbar.collapse") : t("toolbar.expand"),
2589
3198
  onClick: () => setToolbarExpanded((v) => !v),
2590
- style: {
2591
- width: DOT_HIT,
2592
- height: DOT_HIT,
2593
- flexShrink: 0,
2594
- display: "flex",
2595
- alignItems: "center",
2596
- justifyContent: "center",
2597
- background: "transparent",
2598
- border: "none",
2599
- padding: 0,
2600
- cursor: "pointer",
2601
- color: "#8b96ab"
2602
- },
3199
+ onMouseEnter: () => setExpandHovered(true),
3200
+ onMouseLeave: () => setExpandHovered(false),
3201
+ onFocus: () => setExpandHovered(true),
3202
+ onBlur: () => setExpandHovered(false),
3203
+ style: chromeButtonStyle(expandHovered),
2603
3204
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
2604
3205
  width: "16",
2605
3206
  height: "16",
@@ -2613,221 +3214,576 @@ window.__ModuleLoader__.load({
2613
3214
  children: toolbarExpanded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m18 15-6-6-6 6" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 9 6 6 6-6" })
2614
3215
  })
2615
3216
  }),
2616
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2617
- type: "button",
2618
- ref: settingsBtnRef,
2619
- "data-toolbar-settings": true,
2620
- "aria-expanded": settingsOpen,
2621
- "aria-label": settingsOpen ? t("toolbar.settings.close") : t("toolbar.settings.open"),
2622
- title: settingsOpen ? t("toolbar.settings.close") : t("toolbar.settings.open"),
2623
- onClick: () => setSettingsOpen((v) => !v),
3217
+ toolbarFeatures.map((feature) => featureVisible(feature.id) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react.Fragment, { children: feature.render() }, feature.id) : null),
3218
+ settingsOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3219
+ "data-toolbar-settings-overlay": true,
3220
+ onClick: closeSettings,
2624
3221
  style: {
2625
- width: DOT_HIT,
2626
- height: DOT_HIT,
2627
- flexShrink: 0,
3222
+ position: "fixed",
3223
+ inset: 0,
3224
+ background: "rgba(8, 10, 15, 0.55)",
3225
+ zIndex: 105,
2628
3226
  display: "flex",
2629
3227
  alignItems: "center",
2630
3228
  justifyContent: "center",
2631
- background: settingsOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
2632
- border: "none",
2633
- padding: 0,
2634
- cursor: "pointer",
2635
- color: settingsOpen ? "#9db8ff" : "#8b96ab"
3229
+ padding: 16
2636
3230
  },
2637
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2638
- width: "16",
2639
- height: "16",
2640
- viewBox: "0 0 24 24",
2641
- fill: "none",
2642
- stroke: "currentColor",
2643
- strokeWidth: "2",
2644
- strokeLinecap: "round",
2645
- strokeLinejoin: "round",
2646
- "aria-hidden": "true",
2647
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
2648
- cx: "12",
2649
- cy: "12",
2650
- r: "3"
2651
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" })]
2652
- })
2653
- }),
2654
- toolbarFeatures.map((feature) => featureVisible(feature.id) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react.Fragment, { children: feature.render() }, feature.id) : null),
2655
- settingsOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2656
- ref: settingsRef,
2657
- "data-toolbar-settings-panel": true,
2658
- style: {
2659
- position: "fixed",
2660
- top: railBox.top,
2661
- right: railBox.right + DOT_HIT + 8,
2662
- width: "min(240px, calc(100vw - 48px))",
2663
- padding: "10px 12px",
2664
- background: "rgba(20, 24, 32, 0.97)",
2665
- color: "#e6e8ee",
2666
- borderRadius: 8,
2667
- boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
2668
- zIndex: 104
2669
- },
2670
- children: [
2671
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: `[data-toolbar-pin-toggle]:hover { background: rgba(77, 124, 254, 0.18); }` }),
2672
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2673
- "data-toolbar-settings-title": true,
2674
- style: {
2675
- fontSize: 13,
2676
- fontWeight: 600,
2677
- color: "#e6e8ee",
2678
- marginBottom: 2
2679
- },
2680
- children: t("settings.title")
2681
- }),
2682
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2683
- style: {
2684
- fontSize: 12,
2685
- color: "#8b96ab",
2686
- marginBottom: 4,
2687
- textAlign: "right"
2688
- },
2689
- children: t("settings.pin")
2690
- }),
2691
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2692
- role: "menu",
2693
- "aria-label": t("settings.title"),
2694
- style: {
2695
- display: "flex",
2696
- flexDirection: "column",
2697
- gap: 2
2698
- },
2699
- children: toolbarFeatures.map((feature) => {
2700
- const checked = pinned.includes(feature.id);
2701
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3231
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3232
+ ref: settingsRef,
3233
+ "data-toolbar-settings-panel": true,
3234
+ role: "dialog",
3235
+ "aria-modal": "true",
3236
+ "aria-label": t("settings.title"),
3237
+ onClick: (e) => e.stopPropagation(),
3238
+ style: {
3239
+ width: "min(600px, 92vw)",
3240
+ maxHeight: "78vh",
3241
+ overflowY: "auto",
3242
+ padding: "16px 18px",
3243
+ background: "rgba(20, 24, 32, 0.98)",
3244
+ color: "#e6e8ee",
3245
+ borderRadius: 12,
3246
+ boxShadow: "0 20px 60px rgba(0, 0, 0, 0.5)"
3247
+ },
3248
+ children: [
3249
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3250
+ style: {
3251
+ display: "flex",
3252
+ alignItems: "center",
3253
+ justifyContent: "space-between",
3254
+ gap: 8,
3255
+ marginBottom: 12
3256
+ },
3257
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3258
+ "data-toolbar-settings-title": true,
3259
+ style: {
3260
+ fontSize: 15,
3261
+ fontWeight: 600,
3262
+ color: "#e6e8ee"
3263
+ },
3264
+ children: t("settings.title")
3265
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2702
3266
  type: "button",
2703
- role: "menuitemcheckbox",
2704
- "data-toolbar-pin-toggle": true,
2705
- "data-pin-id": feature.id,
2706
- "aria-checked": checked,
2707
- onClick: () => onTogglePin(feature.id),
3267
+ "data-toolbar-settings-close": true,
3268
+ "aria-label": t("settings.close"),
3269
+ title: t("settings.close"),
3270
+ onClick: closeSettings,
2708
3271
  style: {
3272
+ width: 26,
3273
+ height: 26,
3274
+ flexShrink: 0,
2709
3275
  display: "flex",
2710
3276
  alignItems: "center",
2711
- justifyContent: "space-between",
2712
- gap: 8,
2713
- width: "100%",
2714
- padding: "6px 8px",
3277
+ justifyContent: "center",
2715
3278
  background: "transparent",
2716
3279
  border: "none",
2717
- borderRadius: 6,
3280
+ padding: 0,
2718
3281
  cursor: "pointer",
2719
- color: "#e6e8ee",
3282
+ color: "#8b96ab",
3283
+ borderRadius: 6,
3284
+ fontSize: 14,
3285
+ lineHeight: 1
3286
+ },
3287
+ children: "✕"
3288
+ })]
3289
+ }),
3290
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3291
+ "data-settings-section": true,
3292
+ style: { marginBottom: 14 },
3293
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3294
+ "data-settings-section-title": true,
3295
+ style: {
2720
3296
  fontSize: 13,
2721
- textAlign: "left"
3297
+ fontWeight: 600,
3298
+ color: "#c7cede",
3299
+ marginBottom: 8
3300
+ },
3301
+ children: t("settings.section.features")
3302
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3303
+ style: {
3304
+ display: "grid",
3305
+ gridTemplateColumns: "minmax(0, 1fr) 190px",
3306
+ gap: 12,
3307
+ alignItems: "stretch"
2722
3308
  },
2723
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t(feature.labelKey) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2724
- "aria-hidden": "true",
3309
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2725
3310
  style: {
2726
- width: 14,
2727
- height: 14,
2728
- flexShrink: 0,
2729
- borderRadius: 3,
2730
- border: "1px solid rgba(255, 255, 255, 0.35)",
2731
- background: checked ? "rgba(77, 124, 254, 0.9)" : "transparent",
2732
3311
  display: "flex",
2733
- alignItems: "center",
2734
- justifyContent: "center",
2735
- color: "#ffffff",
2736
- fontSize: 10,
2737
- lineHeight: 1
3312
+ flexDirection: "column",
3313
+ gap: 2
2738
3314
  },
2739
- children: checked ? "✓" : ""
2740
- })]
2741
- }, feature.id);
2742
- })
2743
- }),
2744
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2745
- type: "button",
2746
- "data-toolbar-settings-reset": true,
2747
- onClick: onResetPins,
2748
- style: {
2749
- width: "100%",
2750
- marginTop: 6,
2751
- padding: "6px 8px",
2752
- background: "transparent",
2753
- border: "none",
2754
- borderRadius: 6,
2755
- cursor: "pointer",
2756
- color: "#8b96ab",
2757
- fontSize: 13,
2758
- textAlign: "left"
2759
- },
2760
- children: t("settings.reset")
2761
- }),
2762
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2763
- "data-toolbar-settings-footer": true,
2764
- style: {
2765
- marginTop: 8,
2766
- paddingTop: 8,
2767
- borderTop: "1px solid rgba(255, 255, 255, 0.12)"
2768
- },
2769
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2770
- style: {
2771
- fontSize: 12,
2772
- color: "#8b96ab",
2773
- marginBottom: 6
2774
- },
2775
- children: t("settings.support")
2776
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2777
- style: {
2778
- display: "flex",
2779
- flexDirection: "column",
2780
- gap: 4
2781
- },
2782
- children: [
2783
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2784
- href: "https://github.com/SnowCrescenter-tech/dsh-milestone",
2785
- target: "_blank",
2786
- rel: "noreferrer",
2787
- style: {
2788
- fontSize: 12,
2789
- color: "#9db8ff",
2790
- textDecoration: "none"
2791
- },
2792
- children: t("settings.repo")
2793
- }),
2794
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2795
- href: "https://github.com/SnowCrescenter-tech/dsh-milestone",
2796
- target: "_blank",
2797
- rel: "noreferrer",
3315
+ children: toolbarFeatures.map((feature) => {
3316
+ const checked = pinned.includes(feature.id);
3317
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3318
+ type: "button",
3319
+ role: "switch",
3320
+ "data-toolbar-pin-toggle": true,
3321
+ "data-pin-id": feature.id,
3322
+ "aria-checked": checked,
3323
+ "aria-label": t(feature.labelKey),
3324
+ onMouseEnter: () => setDescFeature(feature.id),
3325
+ onFocus: () => setDescFeature(feature.id),
3326
+ onClick: () => onTogglePin(feature.id),
3327
+ style: {
3328
+ display: "flex",
3329
+ alignItems: "center",
3330
+ justifyContent: "space-between",
3331
+ gap: 10,
3332
+ width: "100%",
3333
+ padding: "7px 10px",
3334
+ background: "transparent",
3335
+ border: "none",
3336
+ borderRadius: 8,
3337
+ cursor: "pointer",
3338
+ color: "#e6e8ee",
3339
+ fontSize: 13,
3340
+ textAlign: "left"
3341
+ },
3342
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t(feature.labelKey) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3343
+ "aria-hidden": "true",
3344
+ style: {
3345
+ position: "relative",
3346
+ width: 32,
3347
+ height: 18,
3348
+ flexShrink: 0,
3349
+ borderRadius: 9,
3350
+ background: checked ? accentStrong : "rgba(255, 255, 255, 0.16)",
3351
+ transition: "background 120ms ease"
3352
+ },
3353
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: {
3354
+ position: "absolute",
3355
+ top: 2,
3356
+ left: checked ? 16 : 2,
3357
+ width: 14,
3358
+ height: 14,
3359
+ borderRadius: "50%",
3360
+ background: "#ffffff",
3361
+ transition: "left 120ms ease"
3362
+ } })
3363
+ })]
3364
+ }, feature.id);
3365
+ })
3366
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3367
+ "data-settings-desc": true,
3368
+ "aria-live": "polite",
2798
3369
  style: {
2799
- fontSize: 12,
2800
- color: "#9db8ff",
2801
- textDecoration: "none"
2802
- },
2803
- children: t("settings.star")
2804
- }),
2805
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2806
- href: `https://github.com/SnowCrescenter-tech/dsh-milestone/issues`,
2807
- target: "_blank",
2808
- rel: "noreferrer",
2809
- style: {
2810
- fontSize: 12,
2811
- color: "#9db8ff",
2812
- textDecoration: "none"
3370
+ display: "flex",
3371
+ alignItems: "center",
3372
+ padding: "10px 12px",
3373
+ borderRadius: 8,
3374
+ background: "rgba(255, 255, 255, 0.05)",
3375
+ color: "#b9c2d4",
3376
+ fontSize: 12.5,
3377
+ lineHeight: 1.5
2813
3378
  },
2814
- children: t("settings.issues")
2815
- }),
2816
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2817
- href: "https://www.npmjs.com/package/dsh-milestone",
2818
- target: "_blank",
2819
- rel: "noreferrer",
3379
+ children: t(descKey)
3380
+ })]
3381
+ })]
3382
+ }),
3383
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3384
+ "data-settings-section": true,
3385
+ "data-settings-personal": true,
3386
+ style: { marginBottom: 14 },
3387
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3388
+ "data-settings-section-title": true,
3389
+ style: {
3390
+ fontSize: 13,
3391
+ fontWeight: 600,
3392
+ color: "#c7cede",
3393
+ marginBottom: 8
3394
+ },
3395
+ children: t("settings.section.personal")
3396
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3397
+ style: {
3398
+ display: "flex",
3399
+ flexDirection: "column",
3400
+ gap: 10
3401
+ },
3402
+ children: [
3403
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3404
+ style: {
3405
+ display: "flex",
3406
+ alignItems: "center",
3407
+ gap: 10,
3408
+ flexWrap: "wrap"
3409
+ },
3410
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3411
+ style: {
3412
+ fontSize: 12.5,
3413
+ color: "#8b96ab",
3414
+ width: 90,
3415
+ flexShrink: 0
3416
+ },
3417
+ children: t("settings.accent")
3418
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3419
+ "data-accent-swatches": true,
3420
+ style: {
3421
+ display: "flex",
3422
+ alignItems: "center",
3423
+ gap: 6,
3424
+ flexWrap: "wrap"
3425
+ },
3426
+ children: [ACCENT_PRESETS.map((preset) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3427
+ type: "button",
3428
+ "data-accent-swatch": true,
3429
+ "data-accent": preset,
3430
+ "aria-label": preset,
3431
+ "aria-pressed": accent === preset,
3432
+ onClick: () => updatePrefs({ accent: preset }),
3433
+ style: {
3434
+ width: 22,
3435
+ height: 22,
3436
+ borderRadius: "50%",
3437
+ background: preset,
3438
+ border: accent === preset ? "2px solid #ffffff" : "2px solid rgba(255, 255, 255, 0.25)",
3439
+ boxShadow: accent === preset ? `0 0 0 2px ${preset}` : "none",
3440
+ padding: 0,
3441
+ cursor: "pointer",
3442
+ transition: "border-color 120ms ease, box-shadow 120ms ease"
3443
+ }
3444
+ }, preset)), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3445
+ "data-accent-custom": true,
3446
+ style: {
3447
+ display: "inline-flex",
3448
+ alignItems: "center",
3449
+ gap: 6,
3450
+ fontSize: 12.5,
3451
+ color: "#b9c2d4",
3452
+ cursor: "pointer"
3453
+ },
3454
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3455
+ type: "color",
3456
+ value: accent,
3457
+ onChange: (e) => updatePrefs({ accent: e.target.value }),
3458
+ "aria-label": `${t("settings.custom")} ${t("settings.accent")}`,
3459
+ style: {
3460
+ width: 26,
3461
+ height: 26,
3462
+ padding: 0,
3463
+ border: "none",
3464
+ background: "transparent",
3465
+ cursor: "pointer"
3466
+ }
3467
+ }), t("settings.custom")]
3468
+ })]
3469
+ })]
3470
+ }),
3471
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3472
+ style: {
3473
+ display: "flex",
3474
+ alignItems: "center",
3475
+ gap: 10,
3476
+ flexWrap: "wrap"
3477
+ },
3478
+ children: [
3479
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3480
+ style: {
3481
+ fontSize: 12.5,
3482
+ color: "#8b96ab",
3483
+ width: 90,
3484
+ flexShrink: 0
3485
+ },
3486
+ children: t("settings.iconSize")
3487
+ }),
3488
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3489
+ type: "range",
3490
+ "data-icon-size": true,
3491
+ min: 20,
3492
+ max: 36,
3493
+ step: 2,
3494
+ value: iconSize,
3495
+ onChange: (e) => updatePrefs({ iconSize: Number(e.target.value) }),
3496
+ style: {
3497
+ flex: 1,
3498
+ minWidth: 140,
3499
+ maxWidth: 260
3500
+ }
3501
+ }),
3502
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3503
+ "data-icon-size-value": true,
3504
+ style: {
3505
+ fontSize: 12.5,
3506
+ color: "#b9c2d4",
3507
+ width: 40
3508
+ },
3509
+ children: [iconSize, "px"]
3510
+ })
3511
+ ]
3512
+ }),
3513
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3514
+ style: {
3515
+ display: "flex",
3516
+ alignItems: "center",
3517
+ gap: 10,
3518
+ flexWrap: "wrap"
3519
+ },
3520
+ children: [
3521
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3522
+ style: {
3523
+ fontSize: 12.5,
3524
+ color: "#8b96ab",
3525
+ width: 90,
3526
+ flexShrink: 0
3527
+ },
3528
+ children: t("settings.inset")
3529
+ }),
3530
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3531
+ type: "range",
3532
+ "data-inset": true,
3533
+ min: 0,
3534
+ max: 40,
3535
+ step: 2,
3536
+ value: inset,
3537
+ onChange: (e) => updatePrefs({ inset: Number(e.target.value) }),
3538
+ style: {
3539
+ flex: 1,
3540
+ minWidth: 140,
3541
+ maxWidth: 260
3542
+ }
3543
+ }),
3544
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3545
+ "data-inset-value": true,
3546
+ style: {
3547
+ fontSize: 12.5,
3548
+ color: "#b9c2d4",
3549
+ width: 40
3550
+ },
3551
+ children: [inset, "px"]
3552
+ })
3553
+ ]
3554
+ }),
3555
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3556
+ role: "radiogroup",
3557
+ "aria-label": t("settings.side"),
3558
+ style: {
3559
+ display: "flex",
3560
+ alignItems: "center",
3561
+ gap: 10,
3562
+ flexWrap: "wrap"
3563
+ },
3564
+ children: [
3565
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3566
+ style: {
3567
+ fontSize: 12.5,
3568
+ color: "#8b96ab",
3569
+ width: 90,
3570
+ flexShrink: 0
3571
+ },
3572
+ children: t("settings.side")
3573
+ }),
3574
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3575
+ style: {
3576
+ display: "inline-flex",
3577
+ alignItems: "center",
3578
+ gap: 5,
3579
+ fontSize: 13,
3580
+ color: "#e6e8ee",
3581
+ cursor: "pointer"
3582
+ },
3583
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3584
+ type: "radio",
3585
+ name: "ms-rail-side",
3586
+ "data-side-radio": true,
3587
+ value: "left",
3588
+ checked: side === "left",
3589
+ onChange: () => updatePrefs({ side: "left" })
3590
+ }), t("settings.side.left")]
3591
+ }),
3592
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3593
+ style: {
3594
+ display: "inline-flex",
3595
+ alignItems: "center",
3596
+ gap: 5,
3597
+ fontSize: 13,
3598
+ color: "#e6e8ee",
3599
+ cursor: "pointer"
3600
+ },
3601
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3602
+ type: "radio",
3603
+ name: "ms-rail-side",
3604
+ "data-side-radio": true,
3605
+ value: "right",
3606
+ checked: side === "right",
3607
+ onChange: () => updatePrefs({ side: "right" })
3608
+ }), t("settings.side.right")]
3609
+ })
3610
+ ]
3611
+ })
3612
+ ]
3613
+ })]
3614
+ }),
3615
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3616
+ "data-settings-section": true,
3617
+ "data-settings-lang": true,
3618
+ style: { marginBottom: 14 },
3619
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3620
+ "data-settings-section-title": true,
3621
+ style: {
3622
+ fontSize: 13,
3623
+ fontWeight: 600,
3624
+ color: "#c7cede",
3625
+ marginBottom: 8
3626
+ },
3627
+ children: t("settings.language")
3628
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3629
+ role: "radiogroup",
3630
+ "aria-label": t("settings.language"),
3631
+ style: {
3632
+ display: "flex",
3633
+ alignItems: "center",
3634
+ gap: 18,
3635
+ flexWrap: "wrap"
3636
+ },
3637
+ children: [
3638
+ "system",
3639
+ "zh",
3640
+ "en"
3641
+ ].map((value) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2820
3642
  style: {
2821
- fontSize: 12,
2822
- color: "#9db8ff",
2823
- textDecoration: "none"
3643
+ display: "inline-flex",
3644
+ alignItems: "center",
3645
+ gap: 5,
3646
+ fontSize: 13,
3647
+ color: "#e6e8ee",
3648
+ cursor: "pointer"
2824
3649
  },
2825
- children: t("settings.npm")
2826
- })
2827
- ]
2828
- })]
2829
- })
2830
- ]
3650
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3651
+ type: "radio",
3652
+ name: "ms-rail-locale",
3653
+ "data-locale-pref": true,
3654
+ value,
3655
+ checked: prefs.locale === value,
3656
+ onChange: () => updatePrefs({ locale: value })
3657
+ }), t(`settings.lang.${value}`)]
3658
+ }, value))
3659
+ })]
3660
+ }),
3661
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3662
+ "data-toolbar-settings-footer": true,
3663
+ style: { textAlign: "center" },
3664
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3665
+ style: {
3666
+ fontSize: 12,
3667
+ color: "#8b96ab",
3668
+ marginBottom: 10
3669
+ },
3670
+ children: t("settings.support")
3671
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3672
+ "data-support-grid": true,
3673
+ style: {
3674
+ display: "grid",
3675
+ gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
3676
+ gap: 10,
3677
+ maxWidth: 460,
3678
+ margin: "0 auto"
3679
+ },
3680
+ children: [
3681
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
3682
+ href: "https://github.com/SnowCrescenter-tech/dsh-milestone",
3683
+ target: "_blank",
3684
+ rel: "noreferrer",
3685
+ "data-support-card": true,
3686
+ "data-card": "repo",
3687
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
3688
+ width: "14",
3689
+ height: "14",
3690
+ viewBox: "0 0 24 24",
3691
+ fill: "none",
3692
+ stroke: "currentColor",
3693
+ strokeWidth: "2",
3694
+ strokeLinecap: "round",
3695
+ strokeLinejoin: "round",
3696
+ "aria-hidden": "true",
3697
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" })
3698
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.repo") })]
3699
+ }),
3700
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
3701
+ href: "https://github.com/SnowCrescenter-tech/dsh-milestone",
3702
+ target: "_blank",
3703
+ rel: "noreferrer",
3704
+ "data-support-card": true,
3705
+ "data-card": "star",
3706
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
3707
+ width: "14",
3708
+ height: "14",
3709
+ viewBox: "0 0 24 24",
3710
+ fill: "currentColor",
3711
+ stroke: "none",
3712
+ "aria-hidden": "true",
3713
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" })
3714
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.star") })]
3715
+ }),
3716
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
3717
+ href: `https://github.com/SnowCrescenter-tech/dsh-milestone/issues`,
3718
+ target: "_blank",
3719
+ rel: "noreferrer",
3720
+ "data-support-card": true,
3721
+ "data-card": "issues",
3722
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
3723
+ width: "14",
3724
+ height: "14",
3725
+ viewBox: "0 0 24 24",
3726
+ fill: "none",
3727
+ stroke: "currentColor",
3728
+ strokeWidth: "2",
3729
+ "aria-hidden": "true",
3730
+ children: [
3731
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
3732
+ cx: "12",
3733
+ cy: "12",
3734
+ r: "9"
3735
+ }),
3736
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
3737
+ d: "M12 8v4",
3738
+ strokeLinecap: "round"
3739
+ }),
3740
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
3741
+ cx: "12",
3742
+ cy: "16",
3743
+ r: "0.5",
3744
+ fill: "currentColor"
3745
+ })
3746
+ ]
3747
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.issues") })]
3748
+ }),
3749
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
3750
+ href: "https://www.npmjs.com/package/dsh-milestone",
3751
+ target: "_blank",
3752
+ rel: "noreferrer",
3753
+ "data-support-card": true,
3754
+ "data-card": "npm",
3755
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
3756
+ width: "14",
3757
+ height: "14",
3758
+ viewBox: "0 0 24 24",
3759
+ fill: "currentColor",
3760
+ stroke: "none",
3761
+ "aria-hidden": "true",
3762
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M2 8.5h20V15h-6v2.5h-3V15H2V8.5zm1.5 1.5v3.5H6V11.5h1.5v3.5h1.5V10h-4.5zm6 0v5h3V13h2v2h1.5v-5h-6.5z" })
3763
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.npm") })]
3764
+ })
3765
+ ]
3766
+ })]
3767
+ }),
3768
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3769
+ type: "button",
3770
+ "data-toolbar-settings-reset": true,
3771
+ onClick: onResetAll,
3772
+ style: {
3773
+ display: "block",
3774
+ margin: "14px auto 0",
3775
+ padding: "7px 16px",
3776
+ background: "rgba(255, 255, 255, 0.06)",
3777
+ border: "1px solid rgba(255, 255, 255, 0.14)",
3778
+ borderRadius: 8,
3779
+ cursor: "pointer",
3780
+ color: "#b9c2d4",
3781
+ fontSize: 12.5
3782
+ },
3783
+ children: t("settings.reset")
3784
+ })
3785
+ ]
3786
+ })
2831
3787
  }),
2832
3788
  updateOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2833
3789
  ref: updatePanelRef,
@@ -2835,7 +3791,7 @@ window.__ModuleLoader__.load({
2835
3791
  style: {
2836
3792
  position: "fixed",
2837
3793
  top: railBox.top,
2838
- right: railBox.right + DOT_HIT + 8,
3794
+ right: panelRightFor(PANEL_WIDTH_STANDARD),
2839
3795
  width: "min(280px, calc(100vw - 48px))",
2840
3796
  padding: "10px 12px",
2841
3797
  background: "rgba(20, 24, 32, 0.97)",
@@ -2865,7 +3821,7 @@ window.__ModuleLoader__.load({
2865
3821
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2866
3822
  style: { color: "#8b96ab" },
2867
3823
  children: [t("update.current"), ": "]
2868
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "0.6.1" })] }),
3824
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "0.6.2" })] }),
2869
3825
  updateCheck.phase === "ok" && updateCheck.latest !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2870
3826
  "data-update-latest": true,
2871
3827
  children: [
@@ -2908,7 +3864,7 @@ window.__ModuleLoader__.load({
2908
3864
  border: "none",
2909
3865
  padding: 0,
2910
3866
  cursor: "pointer",
2911
- color: "#9db8ff",
3867
+ color: accentSoft,
2912
3868
  fontSize: 12,
2913
3869
  textDecoration: "underline"
2914
3870
  },
@@ -2928,7 +3884,7 @@ window.__ModuleLoader__.load({
2928
3884
  target: "_blank",
2929
3885
  rel: "noreferrer",
2930
3886
  style: {
2931
- color: "#9db8ff",
3887
+ color: accentSoft,
2932
3888
  textDecoration: "none"
2933
3889
  },
2934
3890
  children: t("update.goNpm")
@@ -2952,11 +3908,11 @@ window.__ModuleLoader__.load({
2952
3908
  style: {
2953
3909
  marginTop: 4,
2954
3910
  padding: "6px 10px",
2955
- background: updateCheck.phase === "checking" ? "transparent" : "rgba(77, 124, 254, 0.18)",
3911
+ background: updateCheck.phase === "checking" ? "transparent" : accentBg,
2956
3912
  border: "none",
2957
3913
  borderRadius: 6,
2958
3914
  cursor: updateCheck.phase === "checking" ? "default" : "pointer",
2959
- color: updateCheck.phase === "checking" ? "#5a6375" : "#9db8ff",
3915
+ color: updateCheck.phase === "checking" ? "#5a6375" : accentSoft,
2960
3916
  fontSize: 12,
2961
3917
  alignSelf: "flex-start"
2962
3918
  },
@@ -2967,14 +3923,14 @@ window.__ModuleLoader__.load({
2967
3923
  }),
2968
3924
  listOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneListPanel, {
2969
3925
  panelTop: railBox.top,
2970
- panelRight: railBox.right + DOT_HIT + 8,
3926
+ panelRight: panelRightFor(PANEL_WIDTH_STANDARD),
2971
3927
  marks,
2972
3928
  onJump: jump,
2973
3929
  t
2974
3930
  }),
2975
3931
  crossOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneSessionSearch, {
2976
3932
  panelTop: railBox.top,
2977
- panelRight: railBox.right + DOT_HIT + 8,
3933
+ panelRight: panelRightFor(PANEL_WIDTH_STANDARD),
2978
3934
  onClose: () => setCrossOpen(false),
2979
3935
  searchSessions,
2980
3936
  openSession,
@@ -2994,12 +3950,12 @@ window.__ModuleLoader__.load({
2994
3950
  display: "flex",
2995
3951
  flexDirection: "column",
2996
3952
  alignItems: "center",
2997
- gap: DOT_GAP,
3953
+ gap,
2998
3954
  padding: "6px 0",
2999
3955
  scrollbarWidth: "none"
3000
3956
  },
3001
3957
  children: render.items.map((item, i) => {
3002
- const showSeparator = separatorIndices.has(i);
3958
+ const showGroupGap = separatorIndices.has(i) && i > 0;
3003
3959
  const mark = displayMarks[item.displayIndex];
3004
3960
  const summaryCount = collapsedSummaries.get(mark.key);
3005
3961
  const bookmarked = isBookmarked(bookmarkedKeys, mark.key);
@@ -3011,7 +3967,7 @@ window.__ModuleLoader__.load({
3011
3967
  isCurrent: !hasQuery && mark.key === currentKey
3012
3968
  });
3013
3969
  const isHovered = hover?.mark.key === mark.key;
3014
- const boxShadow = isHovered ? "0 0 0 3px rgba(77, 124, 254, 0.35)" : dotState === "active" ? "0 0 0 3px rgba(255, 255, 255, 0.9)" : dotState === "current" ? "0 0 0 3px rgba(255, 255, 255, 0.75)" : "none";
3970
+ const boxShadow = isHovered ? `0 0 0 3px ${rgbaString(accent, .35) ?? "rgba(77, 124, 254, 0.35)"}` : dotState === "active" ? `0 0 0 3px rgba(255, 255, 255, 0.9), 0 0 10px 2px ${rgbaString(accent, .55) ?? "rgba(77, 124, 254, 0.55)"}` : dotState === "current" ? "0 0 0 3px rgba(255, 255, 255, 0.75)" : dotState === "match" ? `0 0 0 2px ${rgbaString(accent, .45) ?? "rgba(77, 124, 254, 0.45)"}` : "none";
3015
3971
  const badge = deriveBadge({
3016
3972
  nodeKinds: mark.turn === void 0 ? NO_KINDS : kindsByTurn.get(mark.turn) ?? NO_KINDS,
3017
3973
  lastMark: item.displayIndex === displayMarks.length - 1,
@@ -3019,21 +3975,11 @@ window.__ModuleLoader__.load({
3019
3975
  awaitingInput
3020
3976
  });
3021
3977
  const ringStyle = badge === null ? null : badgeRingStyle(badge);
3022
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [showSeparator && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3023
- "data-turn-separator": true,
3024
- "data-turn": mark.turn === void 0 ? void 0 : mark.turn,
3025
- style: {
3026
- width: DOT_HIT - 8,
3027
- height: 1,
3028
- flexShrink: 0,
3029
- background: "rgba(139, 150, 171, 0.35)",
3030
- borderRadius: 1
3031
- }
3032
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3978
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react.Fragment, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3033
3979
  type: "button",
3034
3980
  style: {
3035
- width: DOT_HIT,
3036
- height: DOT_HIT,
3981
+ width: hit,
3982
+ height: hit,
3037
3983
  flexShrink: 0,
3038
3984
  display: "flex",
3039
3985
  alignItems: "center",
@@ -3041,7 +3987,8 @@ window.__ModuleLoader__.load({
3041
3987
  background: "transparent",
3042
3988
  border: "none",
3043
3989
  padding: 0,
3044
- cursor: "pointer"
3990
+ cursor: "pointer",
3991
+ marginTop: showGroupGap ? GROUP_GAP_EXTRA : 0
3045
3992
  },
3046
3993
  onMouseEnter: (e) => {
3047
3994
  const rect = e.currentTarget.getBoundingClientRect();
@@ -3052,6 +3999,8 @@ window.__ModuleLoader__.load({
3052
3999
  },
3053
4000
  onClick: () => jump(mark.key),
3054
4001
  "data-rail-dot": true,
4002
+ "data-turn-gap": showGroupGap ? "true" : void 0,
4003
+ "data-turn": showGroupGap && mark.turn !== void 0 ? mark.turn : void 0,
3055
4004
  "data-collapsed-summary": summaryCount !== void 0 ? "true" : void 0,
3056
4005
  "data-collapsed-count": summaryCount,
3057
4006
  tabIndex: focusIndex === i ? 0 : -1,
@@ -3063,10 +4012,10 @@ window.__ModuleLoader__.load({
3063
4012
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3064
4013
  style: {
3065
4014
  position: "relative",
3066
- width: DOT_SIZE,
3067
- height: DOT_SIZE,
4015
+ width: size,
4016
+ height: size,
3068
4017
  borderRadius: "50%",
3069
- background: dotColor(item.displayIndex, marks.length),
4018
+ background: dotColor(item.displayIndex, marks.length, accent),
3070
4019
  boxShadow,
3071
4020
  transition: "transform 120ms ease, opacity 120ms ease",
3072
4021
  transform: `scale(${isHovered ? 1.35 : dotState === "active" || dotState === "current" ? 1.25 : 1})`,
@@ -3079,18 +4028,18 @@ window.__ModuleLoader__.load({
3079
4028
  position: "absolute",
3080
4029
  inset: -3,
3081
4030
  borderRadius: "50%",
3082
- border: `2px solid ${ringStyle.color}`,
4031
+ boxShadow: ringStyle.shadow,
3083
4032
  color: ringStyle.color,
3084
4033
  pointerEvents: "none",
3085
- animation: ringStyle.pulse ? "milestone-badge-pulse 1.4s ease-out infinite" : void 0
4034
+ animation: ringStyle.pulse ? "milestone-badge-pulse 2s ease-in-out infinite" : void 0
3086
4035
  }
3087
4036
  })
3088
4037
  })
3089
- })] }, mark.key);
4038
+ }) }, mark.key);
3090
4039
  })
3091
4040
  }),
3092
4041
  hover !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneRailTooltip, {
3093
- panelRight: railBox.right + DOT_HIT + 8,
4042
+ panelRight: panelRightFor(TOOLTIP_ANCHOR_WIDTH),
3094
4043
  hover,
3095
4044
  bookmarked: isBookmarked(bookmarkedKeys, hover.mark.key),
3096
4045
  onToggleBookmark: () => onToggleBookmark(hover.mark.key),
@@ -3109,8 +4058,13 @@ window.__ModuleLoader__.load({
3109
4058
  style: {
3110
4059
  position: "absolute",
3111
4060
  bottom: 6,
3112
- right: "100%",
3113
- marginRight: 8,
4061
+ ...side === "left" ? {
4062
+ left: "100%",
4063
+ marginLeft: 8
4064
+ } : {
4065
+ right: "100%",
4066
+ marginRight: 8
4067
+ },
3114
4068
  whiteSpace: "nowrap",
3115
4069
  fontSize: 12,
3116
4070
  lineHeight: 1,
@@ -3233,213 +4187,6 @@ window.__ModuleLoader__.load({
3233
4187
  });
3234
4188
  }
3235
4189
  //#endregion
3236
- //#region src/client/locales.ts
3237
- /**
3238
- * UI strings for the milestone rail, keyed flat (single-language-per-key,
3239
- * no nesting) so the later i18n threading stays a mechanical
3240
- * `value.replace('{name}', n)` substitution.
3241
- *
3242
- * `zh` is the source of truth and the key registry: it byte-matches the
3243
- * current hardcoded output of MilestoneRail / MilestoneRailTooltip /
3244
- * MilestoneRailSearch exactly (each `{n}`/`{m}`/`{name}` slot stands in for
3245
- * the interpolated number or label), so swapping in these templates is
3246
- * behavior-preserving. `en` is typed `Record<MilestoneKey, string>` so a
3247
- * missing English translation is a compile error, not a runtime miss.
3248
- */
3249
- const zh = {
3250
- /** aria-label on each dot: `跳转到第 ${i + 1} 条消息`. */
3251
- "jump.to": "跳转到第 {n} 条消息",
3252
- /** Load-older coverage hint: `已显示 {marks.length} 条 · 还有更早`. */
3253
- "window.hint": "已显示 {n} 条 · 还有更早",
3254
- /** Hover turn badge: `第 ${mark.turn} 轮`. */
3255
- "turn.label": "第 {n} 轮",
3256
- /** Hover position: `第 {hover.index + 1} / {hover.total} 条`. */
3257
- "pos.of": "第 {n} / {m} 条",
3258
- /** Search input placeholder. */
3259
- "search.placeholder": "搜索消息内容",
3260
- /** aria-label on the search toggle button and the search input. */
3261
- "search.label": "搜索消息",
3262
- /** aria-label on the bookmarks-only filter toggle. */
3263
- "bookmark.filter": "只看收藏",
3264
- /** aria-label + title on the focus-mode toggle when focus is OFF (arm it). */
3265
- "focus.on": "聚焦模式",
3266
- /** aria-label + title on the focus-mode toggle when focus is ON (disarm it). */
3267
- "focus.off": "退出聚焦",
3268
- /** aria-label on the hover tooltip star toggle. */
3269
- "bookmark.star": "收藏此消息",
3270
- /** aria-label on the search clear button. */
3271
- "search.clear": "清空搜索",
3272
- /** title + aria-label on the load-older `···` button. */
3273
- "load.older": "加载更早消息",
3274
- /** aria-label on the rail root. */
3275
- "rail.label": "会话里程碑",
3276
- /** aria-label on the dot list. */
3277
- "rail.list": "会话里程碑列表",
3278
- /** Hover preview fallback for empty message text. */
3279
- "no.text": "(无文本)",
3280
- /** Relative time: `< 60s`. */
3281
- "time.justNow": "刚刚",
3282
- /** Relative time: `< 1h`. */
3283
- "time.minutes": "{n} 分钟前",
3284
- /** Relative time: `< 1d`. */
3285
- "time.hours": "{n} 小时前",
3286
- /** Relative time: `>= 1d`. */
3287
- "time.days": "{n} 天前",
3288
- /** Hover duration: `用时 {durationLabel}`. */
3289
- "duration.label": "用时 {name}",
3290
- /** Hover TTFT: `首字 {ttftLabel}`. */
3291
- "ttft.label": "首字 {name}",
3292
- /** TurnEndReason `completed`. */
3293
- "reason.completed": "已完成",
3294
- /** TurnEndReason `aborted`. */
3295
- "reason.aborted": "已中止",
3296
- /** TurnEndReason `error`. */
3297
- "reason.error": "出错",
3298
- /** TurnEndReason `max-tokens`. */
3299
- "reason.maxTokens": "达到上限",
3300
- /** TurnEndReason `interrupted`. */
3301
- "reason.interrupted": "已中断",
3302
- /** TurnEndReason `blocked`. */
3303
- "reason.blocked": "已阻塞",
3304
- /** Copy-message tooltip action. */
3305
- "copy.message": "复制消息",
3306
- /** Fork-from-here tooltip action. */
3307
- "fork.here": "从此处 fork",
3308
- /** Collapse-turn tooltip action. */
3309
- "collapse.turn": "折叠此轮",
3310
- /** Expand-turn tooltip action. */
3311
- "expand.turn": "展开此轮",
3312
- /** aria-label + title on the milestone-list toggle when the panel is CLOSED. */
3313
- "list.open": "打开列表",
3314
- /** aria-label + title on the milestone-list toggle when the panel is OPEN. */
3315
- "list.close": "收起列表",
3316
- /** Header title of the all-prompts list panel. */
3317
- "list.label": "全部提问",
3318
- /** Header title + input placeholder of the cross-session search panel. */
3319
- "search.cross": "跨会话搜索",
3320
- /** aria-label + title on the cross-session search toggle when the panel is CLOSED. */
3321
- "search.cross.open": "打开跨会话搜索",
3322
- /** aria-label + title on the cross-session search toggle when the panel is OPEN. */
3323
- "search.cross.close": "收起跨会话搜索",
3324
- /** Cross-session result row title fallback for sessions with no display title. */
3325
- "search.untitled": "(无标题)",
3326
- /** Cross-session search failure notice. */
3327
- "search.error": "搜索失败,请重试",
3328
- /** Cross-session search footer hint when the harness capped the result list. */
3329
- "search.more": "结果已截断,请细化关键词",
3330
- /** aria-label on the toolbar expand arrow while the toolbar is COLLAPSED (expand it). */
3331
- "toolbar.expand": "展开工具栏",
3332
- /** aria-label on the toolbar expand arrow while the toolbar is EXPANDED (collapse it). */
3333
- "toolbar.collapse": "收起工具栏",
3334
- /** aria-label on the toolbar settings gear while the settings menu is CLOSED. */
3335
- "toolbar.settings.open": "打开设置",
3336
- /** aria-label on the toolbar settings gear while the settings menu is OPEN. */
3337
- "toolbar.settings.close": "关闭设置",
3338
- /** Header title of the toolbar settings menu. */
3339
- "settings.title": "设置",
3340
- /** Per-feature toggle label inside the settings menu: keep visible while collapsed. */
3341
- "settings.pin": "在折叠外显示",
3342
- /** Settings action that clears every pinned feature. */
3343
- "settings.reset": "恢复默认",
3344
- /** Settings footer heading above the project links. */
3345
- "settings.support": "支持我们",
3346
- /** Settings footer link: the GitHub repository. */
3347
- "settings.repo": "GitHub 仓库",
3348
- /** Settings footer link: star the repository. */
3349
- "settings.star": "欢迎 Star ★",
3350
- /** Settings footer link: file an issue. */
3351
- "settings.issues": "提交 Issue",
3352
- /** Settings footer link: the npm install channel. */
3353
- "settings.npm": "npm 安装渠道",
3354
- /** B4 update-check: toolbar button label + title/aria-label. */
3355
- "update.check": "检查更新",
3356
- /** B4 update-check: popover title. */
3357
- "update.title": "更新检测",
3358
- /** B4 update-check: installed-version row label. */
3359
- "update.current": "当前版本",
3360
- /** B4 update-check: newest-published-version row label. */
3361
- "update.latest": "最新版本",
3362
- /** B4 update-check: conclusion when the installed version is current. */
3363
- "update.upToDate": "已是最新版本",
3364
- /** B4 update-check: conclusion when a newer version exists. */
3365
- "update.available": "发现新版本",
3366
- /** B4 update-check: link text for the npm upgrade channel. */
3367
- "update.goNpm": "去 npm 升级",
3368
- /** B4 update-check: supported-host-lines metadata row label. */
3369
- "update.hostLines": "已适配官方版本线",
3370
- /** B4 update-check: in-flight state of the manual check button. */
3371
- "update.checking": "检查中…",
3372
- /** B4 update-check: failed state heading. */
3373
- "update.failed": "检查失败",
3374
- /** B4 update-check: retry action inside the failed state. */
3375
- "update.retry": "重试"
3376
- };
3377
- const en = {
3378
- "jump.to": "Jump to message {n}",
3379
- "window.hint": "Showing {n} messages · more below",
3380
- "turn.label": "Turn {n}",
3381
- "pos.of": "Message {n} of {m}",
3382
- "search.placeholder": "Search message content",
3383
- "search.label": "Search messages",
3384
- "bookmark.filter": "Bookmarks only",
3385
- "focus.on": "Focus mode",
3386
- "focus.off": "Exit focus",
3387
- "bookmark.star": "Bookmark this message",
3388
- "search.clear": "Clear search",
3389
- "load.older": "Load older messages",
3390
- "rail.label": "Session milestones",
3391
- "rail.list": "Session milestone list",
3392
- "no.text": "(no text)",
3393
- "time.justNow": "Just now",
3394
- "time.minutes": "{n} minutes ago",
3395
- "time.hours": "{n} hours ago",
3396
- "time.days": "{n} days ago",
3397
- "duration.label": "Duration {name}",
3398
- "ttft.label": "First token {name}",
3399
- "reason.completed": "Completed",
3400
- "reason.aborted": "Aborted",
3401
- "reason.error": "Error",
3402
- "reason.maxTokens": "Max tokens reached",
3403
- "reason.interrupted": "Interrupted",
3404
- "reason.blocked": "Blocked",
3405
- "copy.message": "Copy message",
3406
- "fork.here": "Fork from here",
3407
- "collapse.turn": "Collapse turn",
3408
- "expand.turn": "Expand turn",
3409
- "list.open": "Open list",
3410
- "list.close": "Close list",
3411
- "list.label": "All prompts",
3412
- "search.cross": "Cross-session search",
3413
- "search.cross.open": "Open cross-session search",
3414
- "search.cross.close": "Close cross-session search",
3415
- "search.untitled": "(untitled)",
3416
- "search.error": "Search failed, retry",
3417
- "search.more": "Results truncated — refine your query",
3418
- "toolbar.expand": "Expand toolbar",
3419
- "toolbar.collapse": "Collapse toolbar",
3420
- "toolbar.settings.open": "Open settings",
3421
- "toolbar.settings.close": "Close settings",
3422
- "settings.title": "Settings",
3423
- "settings.pin": "Show outside collapse",
3424
- "settings.reset": "Restore defaults",
3425
- "settings.support": "Support us",
3426
- "settings.repo": "GitHub repo",
3427
- "settings.star": "Give us a Star ★",
3428
- "settings.issues": "Report an Issue",
3429
- "settings.npm": "Install via npm",
3430
- "update.check": "Check updates",
3431
- "update.title": "Update check",
3432
- "update.current": "Current version",
3433
- "update.latest": "Latest version",
3434
- "update.upToDate": "You are up to date",
3435
- "update.available": "Update available",
3436
- "update.goNpm": "Upgrade on npm",
3437
- "update.hostLines": "Supported official version lines",
3438
- "update.checking": "Checking…",
3439
- "update.failed": "Check failed",
3440
- "update.retry": "Retry"
3441
- };
3442
- //#endregion
3443
4190
  //#region src/client/index.ts
3444
4191
  /** Required services (cordis fiber inject). */
3445
4192
  const inject = [