dsh-milestone 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,366 @@ 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: feature-section hint explaining what the pin switch does. */
686
+ "settings.pin.hint": "开启后,工具栏折叠时该功能仍显示在箭头旁",
687
+ /** Settings action that clears every pinned feature. */
688
+ "settings.reset": "恢复默认",
689
+ /** Settings footer heading above the project links. */
690
+ "settings.support": "支持我们",
691
+ /** Settings footer link: the GitHub repository. */
692
+ "settings.repo": "GitHub 仓库",
693
+ /** Settings footer link: star the repository. */
694
+ "settings.star": "欢迎 Star ★",
695
+ /** Settings footer link: file an issue. */
696
+ "settings.issues": "提交 Issue",
697
+ /** Settings footer link: the npm install channel. */
698
+ "settings.npm": "npm 安装渠道",
699
+ /** Settings: feature-row name of the settings key itself (registry row). */
700
+ "settings.label": "设置",
701
+ /** aria-label on the settings modal close button. */
702
+ "settings.close": "关闭",
703
+ /** Settings modal: section heading for the feature pin rows. */
704
+ "settings.section.features": "功能与快捷区",
705
+ /** Settings modal: section heading for the personalization controls. */
706
+ "settings.section.personal": "个性化",
707
+ /** Settings modal: section heading for the focus-mode controls (0.6.3). */
708
+ "settings.section.focus": "聚焦",
709
+ /** Settings: personalization-section hint shown inside the expanded block. */
710
+ "settings.personal.hint": "圆点、强调色与位置,即调即存",
711
+ /** Settings: aria-label on the personalization block toggle while COLLAPSED. */
712
+ "settings.personal.expand": "展开个性化设置",
713
+ /** Settings: aria-label on the personalization block toggle while EXPANDED. */
714
+ "settings.personal.collapse": "收起个性化设置",
715
+ /** Settings: live value summary on the collapsed personalization header. */
716
+ "settings.personal.summary": "{accent} · 图标 {icon}px · {side}",
717
+ /** Settings: hover description — in-rail search. */
718
+ "settings.desc.search": "按完整消息内容过滤并跳转到对应消息",
719
+ /** Settings: hover description — all-prompts list. */
720
+ "settings.desc.list": "本会话全部提问一览",
721
+ /** Settings: hover description — cross-session search. */
722
+ "settings.desc.sessionSearch": "跨会话搜索所有会话",
723
+ /** Settings: hover description — bookmarks filter. */
724
+ "settings.desc.bookmarks": "只显示已收藏的消息",
725
+ /** Settings: hover description — focus mode. */
726
+ "settings.desc.focus": "淡化 AI 思考块,阅读更清爽",
727
+ /** Settings: hover description — update check. */
728
+ "settings.desc.updateCheck": "检查 npm 是否有新版本",
729
+ /** Settings: hover description — the settings key itself. */
730
+ "settings.desc.settings": "自定义工具栏与外观",
731
+ /** Settings personalization: accent color row label. */
732
+ "settings.accent": "强调色",
733
+ /** Settings personalization: custom color swatch label. */
734
+ "settings.custom": "自定义",
735
+ /** Settings personalization: icon/dot size slider label. */
736
+ "settings.iconSize": "图标 / 圆点大小",
737
+ /** Settings personalization: edge-distance slider label. */
738
+ "settings.inset": "距侧边距离",
739
+ /** Settings personalization: rail side row label. */
740
+ "settings.side": "位置",
741
+ /** Settings personalization: side radio — hug the left edge. */
742
+ "settings.side.left": "左侧",
743
+ /** Settings personalization: side radio — hug the right edge. */
744
+ "settings.side.right": "右侧",
745
+ /** Settings: focus block — hint shown inside the expanded block. */
746
+ "settings.focus.hint": "这些选项自由组合成你的「聚焦搭配」;总开关仍是工具栏的眼睛按钮",
747
+ /** Settings: aria-label on the focus block toggle while COLLAPSED. */
748
+ "settings.focus.expand": "展开聚焦设置",
749
+ /** Settings: aria-label on the focus block toggle while EXPANDED. */
750
+ "settings.focus.collapse": "收起聚焦设置",
751
+ /** Settings: focus option — dim the think reasoning disclosures. */
752
+ "settings.focus.dimThink": "淡化 think 推理区",
753
+ /** Settings: focus option — dim the tool-call cards. */
754
+ "settings.focus.dimTools": "淡化工具调用卡片",
755
+ /** Settings: focus option — compress think disclosures to a hover strip. */
756
+ "settings.focus.collapseThink": "折叠 think",
757
+ /** Settings: focus option — dim strength slider label. */
758
+ "settings.focus.opacity": "淡化强度",
759
+ /** Settings: live value summary on the collapsed focus header. */
760
+ "settings.focus.summary": "{opts} · 强度 {opacity}%",
761
+ /** Settings: focus summary — short label for the think-dim option. */
762
+ "settings.focus.summary.think": "think 淡化",
763
+ /** Settings: focus summary — short label for the tool-dim option. */
764
+ "settings.focus.summary.tools": "工具淡化",
765
+ /** Settings: focus summary — short label for the think-collapse option. */
766
+ "settings.focus.summary.collapse": "折叠 think",
767
+ /** Settings: focus summary — placeholder when every option is off. */
768
+ "settings.focus.summary.none": "未启用",
769
+ /** B4 update-check: toolbar button label + title/aria-label. */
770
+ "update.check": "检查更新",
771
+ /** B4 update-check: popover title. */
772
+ "update.title": "更新检测",
773
+ /** B4 update-check: installed-version row label. */
774
+ "update.current": "当前版本",
775
+ /** B4 update-check: newest-published-version row label. */
776
+ "update.latest": "最新版本",
777
+ /** B4 update-check: conclusion when the installed version is current. */
778
+ "update.upToDate": "已是最新版本",
779
+ /** B4 update-check: conclusion when a newer version exists. */
780
+ "update.available": "发现新版本",
781
+ /** B4 update-check: link text for the npm upgrade channel. */
782
+ "update.goNpm": "去 npm 升级",
783
+ /** B4 update-check: supported-host-lines metadata row label. */
784
+ "update.hostLines": "已适配官方版本线",
785
+ /** B4 update-check: in-flight state of the manual check button. */
786
+ "update.checking": "检查中…",
787
+ /** B4 update-check: failed state heading. */
788
+ "update.failed": "检查失败",
789
+ /** B4 update-check: retry action inside the failed state. */
790
+ "update.retry": "重试",
791
+ /** Settings modal section title: language. */
792
+ "settings.language": "语言",
793
+ /** Language option: follow the harness UI language. */
794
+ "settings.lang.system": "跟随系统",
795
+ /** Language option: force Chinese copy. */
796
+ "settings.lang.zh": "中文",
797
+ /** Language option: force English copy. */
798
+ "settings.lang.en": "English"
799
+ };
800
+ const en = {
801
+ "jump.to": "Jump to message {n}",
802
+ "window.hint": "Showing {n} messages · more below",
803
+ "turn.label": "Turn {n}",
804
+ "pos.of": "Message {n} of {m}",
805
+ "search.placeholder": "Search message content",
806
+ "search.label": "Search messages",
807
+ "bookmark.filter": "Bookmarks only",
808
+ "focus.on": "Focus mode",
809
+ "focus.off": "Exit focus",
810
+ "bookmark.star": "Bookmark this message",
811
+ "search.clear": "Clear search",
812
+ "load.older": "Load older messages",
813
+ "rail.label": "Session milestones",
814
+ "rail.list": "Session milestone list",
815
+ "no.text": "(no text)",
816
+ "time.justNow": "Just now",
817
+ "time.minutes": "{n} minutes ago",
818
+ "time.hours": "{n} hours ago",
819
+ "time.days": "{n} days ago",
820
+ "duration.label": "Duration {name}",
821
+ "ttft.label": "First token {name}",
822
+ "reason.completed": "Completed",
823
+ "reason.aborted": "Aborted",
824
+ "reason.error": "Error",
825
+ "reason.maxTokens": "Max tokens reached",
826
+ "reason.interrupted": "Interrupted",
827
+ "reason.blocked": "Blocked",
828
+ "copy.message": "Copy message",
829
+ "fork.here": "Fork from here",
830
+ "collapse.turn": "Collapse turn",
831
+ "expand.turn": "Expand turn",
832
+ "list.open": "Open list",
833
+ "list.close": "Close list",
834
+ "list.label": "All prompts",
835
+ "search.cross": "Cross-session search",
836
+ "search.cross.open": "Open cross-session search",
837
+ "search.cross.close": "Close cross-session search",
838
+ "search.untitled": "(untitled)",
839
+ "search.error": "Search failed, retry",
840
+ "search.more": "Results truncated — refine your query",
841
+ "toolbar.expand": "Expand toolbar",
842
+ "toolbar.collapse": "Collapse toolbar",
843
+ "toolbar.settings.open": "Open settings",
844
+ "toolbar.settings.close": "Close settings",
845
+ "settings.title": "Settings",
846
+ "settings.pin": "Show outside collapse",
847
+ "settings.pin.hint": "When on, the feature stays beside the arrow while the toolbar is folded",
848
+ "settings.reset": "Restore defaults",
849
+ "settings.support": "Support us",
850
+ "settings.repo": "GitHub repo",
851
+ "settings.star": "Give us a Star ★",
852
+ "settings.issues": "Report an Issue",
853
+ "settings.npm": "Install via npm",
854
+ "settings.label": "Settings",
855
+ "settings.close": "Close",
856
+ "settings.section.features": "Features & Shortcuts",
857
+ "settings.section.personal": "Personalization",
858
+ "settings.section.focus": "Focus",
859
+ "settings.personal.hint": "Dot size, accent color, and position — saved as you adjust",
860
+ "settings.personal.expand": "Expand personalization",
861
+ "settings.personal.collapse": "Collapse personalization",
862
+ "settings.personal.summary": "{accent} · Icon {icon}px · {side}",
863
+ "settings.desc.search": "Filter by full message text and jump to the match",
864
+ "settings.desc.list": "Overview of every prompt in this session",
865
+ "settings.desc.sessionSearch": "Search across all sessions",
866
+ "settings.desc.bookmarks": "Show bookmarked messages only",
867
+ "settings.desc.focus": "Dim AI thinking blocks for a cleaner read",
868
+ "settings.desc.updateCheck": "Check npm for a newer release",
869
+ "settings.desc.settings": "Customize the toolbar and appearance",
870
+ "settings.accent": "Accent color",
871
+ "settings.custom": "Custom",
872
+ "settings.iconSize": "Icon / dot size",
873
+ "settings.inset": "Distance from the edge",
874
+ "settings.side": "Position",
875
+ "settings.side.left": "Left",
876
+ "settings.side.right": "Right",
877
+ "settings.focus.hint": "Combine these options into your own focus recipe; the eye button on the toolbar stays the master switch",
878
+ "settings.focus.expand": "Expand focus settings",
879
+ "settings.focus.collapse": "Collapse focus settings",
880
+ "settings.focus.dimThink": "Dim think reasoning",
881
+ "settings.focus.dimTools": "Dim tool call cards",
882
+ "settings.focus.collapseThink": "Collapse think",
883
+ "settings.focus.opacity": "Dim strength",
884
+ "settings.focus.summary": "{opts} · Strength {opacity}%",
885
+ "settings.focus.summary.think": "Think dim",
886
+ "settings.focus.summary.tools": "Tools dim",
887
+ "settings.focus.summary.collapse": "Think collapse",
888
+ "settings.focus.summary.none": "Off",
889
+ "update.check": "Check updates",
890
+ "update.title": "Update check",
891
+ "update.current": "Current version",
892
+ "update.latest": "Latest version",
893
+ "update.upToDate": "You are up to date",
894
+ "update.available": "Update available",
895
+ "update.goNpm": "Upgrade on npm",
896
+ "update.hostLines": "Supported official version lines",
897
+ "update.checking": "Checking…",
898
+ "update.failed": "Check failed",
899
+ "update.retry": "Retry",
900
+ "settings.language": "Language",
901
+ "settings.lang.system": "Follow system",
902
+ "settings.lang.zh": "Chinese",
903
+ "settings.lang.en": "English"
904
+ };
905
+ /**
906
+ * Interpolate `{name}` placeholders with params, matching the harness t seat's
907
+ * substitution shape; an unknown parameter leaves the placeholder verbatim.
908
+ */
909
+ function interpolate(template, params) {
910
+ if (params === void 0) return template;
911
+ return template.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/g, (slot, name) => name in params ? String(params[name]) : slot);
912
+ }
913
+ /**
914
+ * Dictionary-backed translate for the forced-language override (locale prefs
915
+ * 'zh' / 'en'): resolves a key against the plugin's own dictionaries with
916
+ * placeholder interpolation; unknown keys pass through unchanged (same
917
+ * degradation as the harness seat).
435
918
  */
436
- function dotColor(index, total) {
437
- return `hsl(218, 88%, ${72 - (total <= 1 ? 0 : index / (total - 1)) * 27}%)`;
919
+ function translateDict(dict, key, params) {
920
+ const template = dict[key];
921
+ return template === void 0 ? key : interpolate(template, params);
438
922
  }
439
923
  //#endregion
440
924
  //#region src/client/turn-group-logic.ts
@@ -1300,20 +1784,36 @@ window.__ModuleLoader__.load({
1300
1784
  //#endregion
1301
1785
  //#region src/client/toolbar-prefs.ts
1302
1786
  /**
1303
- * toolbar-prefs: the persistence layer for the milestone rail's collapsible
1304
- * toolbar "pinned outside the collapse" preferences.
1787
+ * toolbar-prefs: the persistence layer for the milestone rail's toolbar
1788
+ * personalization — WHICH function keys stay visible outside the collapse
1789
+ * (pinned) plus the settings-module appearance prefs (accent color, icon/dot
1790
+ * size, distance from the rail's screen edge, and rail side).
1791
+ *
1792
+ * Storage contract: one localStorage key (`dsh-milestone.toolbar`) holding a
1793
+ * JSON object:
1305
1794
  *
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).
1795
+ * { "pinned": string[], "accent": "#rrggbb", "iconSize": number,
1796
+ * "inset": number, "side": "left" | "right", "locale": "system"|"zh"|"en",
1797
+ * "focus": { "dimThink": boolean, "dimTools": boolean,
1798
+ * "collapseThink": boolean, "opacity": number } }
1310
1799
  *
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.
1800
+ * Backward compatibility: the pre-personalization blob `{ "pinned": string[] }`
1801
+ * (and an entirely absent value) parses to the DEFAULT prefs with the new
1802
+ * fields at their defaults — old users keep their pins untouched. The same
1803
+ * rule covers the `focus` object: a blob stored before 0.6.3 (no `focus`
1804
+ * field) gains the default focus mix.
1805
+ *
1806
+ * All reads are sanitized per field:
1807
+ * - `pinned`: whitelisted ids only (`TOOLBAR_PIN_IDS`), duplicates dropped,
1808
+ * first-seen (pin) order preserved;
1809
+ * - `accent`: a canonical `#rrggbb` hex, lowercased; anything else falls
1810
+ * back to the default blue;
1811
+ * - `iconSize` / `inset`: finite numbers snapped to the slider step
1812
+ * (even values) and clamped to the slider range;
1813
+ * - `side`: exactly `'left'` or `'right'`;
1814
+ * - `focus`: three booleans (`dimThink` / `dimTools` / `collapseThink`)
1815
+ * defaulting to `true` / `false` / `false`, plus the dim `opacity`
1816
+ * snapped to the 0.1 step and clamped to [0.2, 0.8].
1317
1817
  *
1318
1818
  * The whitelist lives HERE (not in MilestoneRail) so the pure functions stay
1319
1819
  * dependency-free and unit-testable; MilestoneRail's feature registry keys
@@ -1323,8 +1823,10 @@ window.__ModuleLoader__.load({
1323
1823
  const TOOLBAR_PREFS_KEY = "dsh-milestone.toolbar";
1324
1824
  /**
1325
1825
  * 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.
1826
+ * render order. `settings` is a REGULAR feature since the B-design move: the
1827
+ * gear left the always-visible chrome and now sits at the end of the expanded
1828
+ * feature queue (default unpinned). Adding a feature here (plus its registry
1829
+ * entry in MilestoneRail) is the whole "pin it" extension point.
1328
1830
  */
1329
1831
  const TOOLBAR_PIN_IDS = [
1330
1832
  "search",
@@ -1332,32 +1834,41 @@ window.__ModuleLoader__.load({
1332
1834
  "sessionSearch",
1333
1835
  "bookmarks",
1334
1836
  "focus",
1335
- "updateCheck"
1837
+ "updateCheck",
1838
+ "settings"
1336
1839
  ];
1840
+ /** The default accent (the classic milestone blue). */
1841
+ const DEFAULT_ACCENT = "#4d7cfd";
1842
+ /** Slider domain for the focus dim strength (0.2 = 20% .. 0.8 = 80%). */
1843
+ const FOCUS_OPACITY_MIN = .2;
1844
+ const FOCUS_OPACITY_MAX = .8;
1845
+ /** The canonical default focus mix (dim think at 40%; tools/collapse off). */
1846
+ const DEFAULT_FOCUS_PREFS = {
1847
+ dimThink: true,
1848
+ dimTools: false,
1849
+ collapseThink: false,
1850
+ opacity: .4
1851
+ };
1852
+ /** The canonical default prefs ("恢复默认" target; also the read fallback). */
1853
+ const DEFAULT_PREFS = {
1854
+ pinned: [],
1855
+ accent: DEFAULT_ACCENT,
1856
+ iconSize: 28,
1857
+ inset: 14,
1858
+ side: "right",
1859
+ locale: "system",
1860
+ focus: { ...DEFAULT_FOCUS_PREFS }
1861
+ };
1337
1862
  /** Type guard for registry ids — unknown strings never survive a parse. */
1338
1863
  function isToolbarPinId(id) {
1339
1864
  return TOOLBAR_PIN_IDS.includes(id);
1340
1865
  }
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 [];
1866
+ /** Whitelist + dedupe + first-seen-order sanitizer for the pinned list. */
1867
+ function sanitizePinned(raw) {
1868
+ if (!Array.isArray(raw)) return [];
1358
1869
  const seen = /* @__PURE__ */ new Set();
1359
1870
  const result = [];
1360
- for (const id of pinned) {
1871
+ for (const id of raw) {
1361
1872
  if (typeof id !== "string" || !isToolbarPinId(id) || seen.has(id)) continue;
1362
1873
  seen.add(id);
1363
1874
  result.push(id);
@@ -1365,42 +1876,104 @@ window.__ModuleLoader__.load({
1365
1876
  return result;
1366
1877
  }
1367
1878
  /**
1879
+ * Snap a finite number to the nearest `step` inside [min, max]; any non-finite
1880
+ * or non-number input falls back to `fallback`. Used for both sliders so a
1881
+ * hand-edited blob (e.g. `iconSize: 21`) converges on a legal slider value.
1882
+ */
1883
+ function clampStep(value, min, max, step, fallback) {
1884
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
1885
+ const snapped = Math.round(Math.min(max, Math.max(min, value)) / step) * step;
1886
+ return Math.min(max, Math.max(min, snapped));
1887
+ }
1888
+ /** Boolean sanitizer for the focus mix flags: only `true`/`false` survive. */
1889
+ function sanitizeBoolean(value, fallback) {
1890
+ return typeof value === "boolean" ? value : fallback;
1891
+ }
1892
+ /**
1893
+ * Focus-mix sanitizer: `dimTools`/`collapseThink` default off, `dimThink`
1894
+ * defaults ON (the classic pre-0.6.3 behavior), and `opacity` snaps to the
1895
+ * 0.1 step inside [0.2, 0.8]. Snapping works in tenths (step × 10 = integer)
1896
+ * instead of a raw division so hand-edited floats like `0.30000000000000004`
1897
+ * always converge on exactly `0.3`.
1898
+ */
1899
+ function sanitizeFocus(raw) {
1900
+ if (typeof raw !== "object" || raw === null) return { ...DEFAULT_FOCUS_PREFS };
1901
+ const { dimThink, dimTools, collapseThink, opacity } = raw;
1902
+ const clamped = typeof opacity === "number" && Number.isFinite(opacity) ? Math.min(FOCUS_OPACITY_MAX, Math.max(FOCUS_OPACITY_MIN, opacity)) : DEFAULT_FOCUS_PREFS.opacity;
1903
+ const tenths = Math.round(clamped * 10);
1904
+ const snapped = Math.min(FOCUS_OPACITY_MAX, Math.max(FOCUS_OPACITY_MIN, tenths / 10));
1905
+ return {
1906
+ dimThink: sanitizeBoolean(dimThink, DEFAULT_FOCUS_PREFS.dimThink),
1907
+ dimTools: sanitizeBoolean(dimTools, DEFAULT_FOCUS_PREFS.dimTools),
1908
+ collapseThink: sanitizeBoolean(collapseThink, DEFAULT_FOCUS_PREFS.collapseThink),
1909
+ opacity: snapped
1910
+ };
1911
+ }
1912
+ /**
1913
+ * Parse + sanitize the raw persisted blob: `null` (nothing stored), invalid
1914
+ * JSON, or a non-object shape all degrade to the DEFAULT prefs. Each field is
1915
+ * sanitized independently, so a half-corrupt blob keeps its valid parts
1916
+ * (e.g. an old `{pinned}`-only blob gains the default accent/size/inset/side
1917
+ * AND the default focus mix).
1918
+ */
1919
+ function parsePrefs(raw) {
1920
+ if (raw === null) return { ...DEFAULT_PREFS };
1921
+ let parsed;
1922
+ try {
1923
+ parsed = JSON.parse(raw);
1924
+ } catch {
1925
+ return { ...DEFAULT_PREFS };
1926
+ }
1927
+ if (typeof parsed !== "object" || parsed === null) return { ...DEFAULT_PREFS };
1928
+ const { pinned, accent, iconSize, inset, side, locale, focus } = parsed;
1929
+ return {
1930
+ pinned: sanitizePinned(pinned),
1931
+ accent: typeof accent === "string" && isHexColor(accent) ? accent.toLowerCase() : DEFAULT_PREFS.accent,
1932
+ iconSize: clampStep(iconSize, 20, 36, 2, DEFAULT_PREFS.iconSize),
1933
+ inset: clampStep(inset, 0, 40, 2, DEFAULT_PREFS.inset),
1934
+ side: side === "left" || side === "right" ? side : DEFAULT_PREFS.side,
1935
+ locale: locale === "zh" || locale === "en" || locale === "system" ? locale : DEFAULT_PREFS.locale,
1936
+ focus: sanitizeFocus(focus)
1937
+ };
1938
+ }
1939
+ /**
1368
1940
  * 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.
1941
+ * the DEFAULT prefs when storage is unavailable (SSR, sandboxed iframe) —
1942
+ * personalization is a best-effort enhancement, never a render blocker.
1371
1943
  */
1372
1944
  function loadPrefs() {
1373
1945
  try {
1374
1946
  return parsePrefs(localStorage.getItem(TOOLBAR_PREFS_KEY));
1375
1947
  } catch {
1376
- return [];
1948
+ return { ...DEFAULT_PREFS };
1377
1949
  }
1378
1950
  }
1379
1951
  /**
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}.
1952
+ * Persist the full prefs (sanitized on the way out so a corrupt in-memory
1953
+ * value is never written). Swallows storage failures for the same best-effort
1954
+ * reason as {@link loadPrefs}.
1385
1955
  */
1386
- function savePrefs(pinned) {
1387
- const cleaned = parsePrefs(JSON.stringify({ pinned: [...pinned] }));
1956
+ function savePrefs(prefs) {
1957
+ const cleaned = parsePrefs(JSON.stringify(prefs));
1388
1958
  try {
1389
- localStorage.setItem(TOOLBAR_PREFS_KEY, JSON.stringify({ pinned: cleaned }));
1959
+ localStorage.setItem(TOOLBAR_PREFS_KEY, JSON.stringify(cleaned));
1390
1960
  } catch {}
1391
1961
  }
1392
1962
  /**
1393
1963
  * 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
1964
+ * present. Unknown ids are ignored (prefs returned unchanged) and the pinned
1965
+ * set is always deduped via the sanitizer, so callers can feed the result
1396
1966
  * straight back into {@link savePrefs}.
1397
1967
  */
1398
- function togglePin(pinned, id) {
1399
- if (!isToolbarPinId(id)) return [...pinned];
1400
- const next = new Set(pinned);
1968
+ function togglePin(prefs, id) {
1969
+ if (!isToolbarPinId(id)) return { ...prefs };
1970
+ const next = new Set(prefs.pinned);
1401
1971
  if (next.has(id)) next.delete(id);
1402
1972
  else next.add(id);
1403
- return parsePrefs(JSON.stringify({ pinned: [...next] }));
1973
+ return {
1974
+ ...prefs,
1975
+ pinned: sanitizePinned([...next])
1976
+ };
1404
1977
  }
1405
1978
  //#endregion
1406
1979
  //#region src/client/version-logic.ts
@@ -1706,12 +2279,12 @@ window.__ModuleLoader__.load({
1706
2279
  * Installed plugin version. Injected at build time as
1707
2280
  * `__DSH_MILESTONE_VERSION__`; falls back to `0.0.0-dev` when unbuilt.
1708
2281
  */
1709
- const PLUGIN_VERSION = "0.6.1";
2282
+ const PLUGIN_VERSION = "0.6.3";
1710
2283
  //#endregion
1711
2284
  //#region src/client/MilestoneRail.tsx
1712
2285
  /**
1713
2286
  * 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
2287
+ * side vertical scrubber as a **fixed-pitch dot list** (like a git commit
1715
2288
  * graph), NOT a minimap: one dot per user message, equal spacing regardless of
1716
2289
  * conversation length. The list itself scrolls with the wheel when it outgrows
1717
2290
  * the viewport; hovering a dot shows rich metadata (time, turn, duration, end
@@ -1723,14 +2296,16 @@ window.__ModuleLoader__.load({
1723
2296
  * and the ui-conversation 'turn-tail'
1724
2297
  * location data (ttftMs/tokensPerSecond)
1725
2298
  *
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).
2299
+ * Positioning: the rail hugs the conversation scrollport's chosen screen edge
2300
+ * (settings 位置: left or right), offset a little inward so it clears the
2301
+ * native scrollbar and sits near the prose.
1728
2302
  *
1729
2303
  * 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.
2304
+ * panel on the rail's free side with a message-text search input; matches
2305
+ * light up the dots (non-matches dim), Enter cycles the active match
2306
+ * (wrapping) and jumps to it, Escape clears and closes. Matching runs over the
2307
+ * FULL message text (`text` from rail-logic.extractText), not the truncated
2308
+ * hover preview.
1734
2309
  *
1735
2310
  * Current-position highlight (F2): the dot for the user message at/just above
1736
2311
  * the conversation viewport top carries a white ring (`useCurrentAnchor`
@@ -1739,8 +2314,15 @@ window.__ModuleLoader__.load({
1739
2314
  * Load-older + window coverage (F3): when the session still has earlier pages
1740
2315
  * (`hasMore`) a slim `···` button sits at the rail top and triggers the
1741
2316
  * injected `loadOlder` action (disabled + `data-loading-older` while
1742
- * `loadingOlder`), and a compact hint to the rail's left states how many
2317
+ * `loadingOlder`), and a compact hint on the rail's free side states how many
1743
2318
  * messages the current window covers.
2319
+ *
2320
+ * Settings (B-design): the gear is a REGULAR toolbar feature ("settings",
2321
+ * registry last, default unpinned) — the collapsed rail shows only the expand
2322
+ * arrow plus the user's pinned keys, and expanding reveals the gear at the end
2323
+ * of the queue. The gear opens a CENTERED modal dialog (function-key pins /
2324
+ * hover descriptions, the personalization controls, and the support-us card
2325
+ * grid); everything the modal changes persists under `dsh-milestone.toolbar`.
1744
2326
  */
1745
2327
  /** Minimum user messages before the rail adds value. */
1746
2328
  const MIN_MARKS = 2;
@@ -1749,36 +2331,211 @@ window.__ModuleLoader__.load({
1749
2331
  const NO_BOOKMARKS = [];
1750
2332
  /** Stable no-kinds fallback for marks whose turn carries no badge nodes. */
1751
2333
  const NO_KINDS = [];
1752
- /**
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).
1757
- */
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
- }`;
1763
- /**
1764
- * P3 focus mode: dims the harness's AI thinking/scratchpad blocks so the
1765
- * conversation reads cleaner. The rule targets the stable, un-hashed
1766
- * `data-variant="think"` attribute on the thinking-block ROOT (the harness
1767
- * renders it as `data-variant="think"` with `data-state="running|ok"`), so an
1768
- * overlay plugin can dim it with plain CSS. Hovering a dimmed block (or
1769
- * 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.
1771
- */
1772
- const FOCUS_CSS = `[data-variant="think"] { opacity: 0.4; transition: opacity 0.2s; }
1773
- [data-variant="think"]:hover, [data-variant="think"] [data-open] { opacity: 1; }`;
1774
- /** Visual dot diameter (px). */
2334
+ /** Visual dot diameter at the default icon size (px). */
1775
2335
  const DOT_SIZE = 14;
1776
- /** Hit area per dot (px) — larger than the dot for comfortable clicking. */
2336
+ /** Hit area per dot at the default icon size (px) — larger than the dot. */
1777
2337
  const DOT_HIT = 28;
1778
- /** Vertical gap between dot hit areas (px) — fixed pitch, never scaled. */
2338
+ /** Vertical gap between dot hit areas at the default icon size (px). */
1779
2339
  const DOT_GAP = 14;
1780
- /** Inward offset from the scrollport right edge so the rail clears the scrollbar. */
1781
- const RAIL_INSET = 14;
2340
+ /**
2341
+ * Extra top margin a new turn group's FIRST dot gets (replaces the old
2342
+ * `data-turn-separator` line): same-group pitch stays DOT_GAP, a group
2343
+ * boundary opens another GROUP_GAP_EXTRA px (14 → 18 at default size),
2344
+ * expressed purely as spacing — no line element.
2345
+ */
2346
+ const GROUP_GAP_EXTRA = 4;
2347
+ /**
2348
+ * Preset accent swatches for the settings 强调色 row (default blue first).
2349
+ * The custom color input accepts any #rrggbb.
2350
+ */
2351
+ const ACCENT_PRESETS = [
2352
+ "#4d7cfd",
2353
+ "#22c55e",
2354
+ "#f59e0b",
2355
+ "#ef4444",
2356
+ "#a855f7",
2357
+ "#06b6d4",
2358
+ "#ec4899",
2359
+ "#f97316"
2360
+ ];
2361
+ /** Known floating-panel widths (px) used to anchor side=left panels to the
2362
+ * rail's free (right) side — the panel components take a viewport `right`
2363
+ * offset, so a left rail must back-calculate it from the panel width. */
2364
+ const PANEL_WIDTH_SEARCH = 220;
2365
+ const PANEL_WIDTH_STANDARD = 280;
2366
+ /** Tooltip anchor width: its maxWidth cap, so a tooltip never overlaps the rail. */
2367
+ const TOOLTIP_ANCHOR_WIDTH = 300;
2368
+ /**
2369
+ * P3 focus mode (0.6.3: user-tuned "聚焦搭配"): when the eye toggle is armed,
2370
+ * an inline <style> (zero-asset, same pattern as the original FOCUS_CSS)
2371
+ * dims/collapses the harness content classes the USER opted into, at the
2372
+ * strength the user picked. Which content to dim and whether to additionally
2373
+ * collapse think is a persisted `prefs.focus` mix; the master on/off switch
2374
+ * stays the toolbar eye button.
2375
+ *
2376
+ * STABLE SELECTORS — researched against the rc.2 official build products:
2377
+ *
2378
+ * - think: `dsh-client-ui-conversation` renders every assistant reasoning
2379
+ * disclosure as a root `div[data-variant="think"][data-state="running|ok"]`
2380
+ * (ReasoningRow). The reasoning body lives INSIDE that root — the
2381
+ * DisclosureRow's children — so the root is a single clampable container:
2382
+ * CSS `max-height` + `overflow: hidden` collapses exactly the body while
2383
+ * the header row ("Think · summary") stays visible. → collapseThink is
2384
+ * feasible with pure CSS (no JS, no harness-internal interaction).
2385
+ *
2386
+ * - tool calls: `dsh-client-ui-tool` wraps EVERY atomic call (all ToolRow
2387
+ * presentation variants AND the bash-sample row) in
2388
+ * `div[data-chat-call-id]` (with `data-chat-anchor-key="call:<callId>"`);
2389
+ * no other card type in the harness uses that attribute (verified across
2390
+ * the whole @deepseek-ai install). → `[data-chat-call-id]` is the stable,
2391
+ * variant-independent tool-call-card selector.
2392
+ *
2393
+ * Hover/open restore mirrors the classic rule: `:hover` restores, and the
2394
+ * DisclosureRow inside the collapsed target sets `[data-open]` when the user
2395
+ * opens it (the DESCENDANT form `target [data-open]` is required — same as
2396
+ * pre-0.6.3). The collapse strip releases on hover AND on `[data-open]` so
2397
+ * an opened think disclosure never stays crushed.
2398
+ */
2399
+ const FOCUS_SELECTOR_THINK = "[data-variant=\"think\"]";
2400
+ const FOCUS_SELECTOR_TOOL = "[data-chat-call-id]";
2401
+ /** Restored height when hovering/opening a collapsed think disclosure. */
2402
+ const FOCUS_THINK_MAX_HEIGHT = "78vh";
2403
+ /**
2404
+ * Compose the focus-mode stylesheet from the persisted focus mix. Pure —
2405
+ * exported so unit tests can pin the exact rule text. `''` when no option is
2406
+ * armed (the master switch simply injects nothing).
2407
+ */
2408
+ function buildFocusCss(focus) {
2409
+ const { dimThink, dimTools, collapseThink } = focus;
2410
+ const strength = focus.opacity.toFixed(1);
2411
+ const rules = [];
2412
+ if (dimThink || collapseThink) {
2413
+ const decls = [];
2414
+ if (dimThink) decls.push(`opacity: ${strength}`);
2415
+ if (collapseThink) decls.push(`max-height: 36px`, "overflow: hidden");
2416
+ rules.push(`${FOCUS_SELECTOR_THINK} { ${decls.join("; ")}; transition: opacity 0.2s${collapseThink ? ", max-height 0.2s" : ""}; }`);
2417
+ rules.push(`${FOCUS_SELECTOR_THINK}:hover, ${FOCUS_SELECTOR_THINK} [data-open] { opacity: 1;${collapseThink ? ` max-height: ${FOCUS_THINK_MAX_HEIGHT};` : ""} }`);
2418
+ }
2419
+ if (dimTools) {
2420
+ rules.push(`${FOCUS_SELECTOR_TOOL} { opacity: ${strength}; transition: opacity 0.2s; }`);
2421
+ rules.push(`${FOCUS_SELECTOR_TOOL}:hover, ${FOCUS_SELECTOR_TOOL} [data-open] { opacity: 1; }`);
2422
+ }
2423
+ return rules.join("\n");
2424
+ }
2425
+ /**
2426
+ * Settings modal shared palette + geometry — one source for the inline
2427
+ * styles AND the static MODAL_CSS block below so they cannot drift. Dark
2428
+ * panel on a dark host, three text tiers (primary / section title / hint +
2429
+ * muted), one border tone, a 12px panel / 8px control radius scale and a
2430
+ * 4-unit spacing scale (4 / 8 / 12 / 16 / 20).
2431
+ */
2432
+ const MODAL_BG = "rgba(20, 24, 32, 0.98)";
2433
+ const MODAL_FG = "#e6e8ee";
2434
+ const MODAL_TITLE = "#c7cede";
2435
+ const MODAL_TEXT = "#b9c2d4";
2436
+ const MODAL_HINT = "#8b96ab";
2437
+ const MODAL_BORDER = "rgba(255, 255, 255, 0.14)";
2438
+ const MODAL_TIP_BG = "#222834";
2439
+ const MODAL_RADIUS_PANEL = 12;
2440
+ const MODAL_RADIUS_CONTROL = 8;
2441
+ /** Near-row description tip: how far its right edge sits from the row's right
2442
+ * edge — clears the 32px switch + its 10px padding. */
2443
+ const MODAL_TIP_RIGHT = 54;
2444
+ /**
2445
+ * Static settings-modal styling that needs `:hover`/`:focus-visible` (which
2446
+ * inline styles cannot express): the support-us card grid (micro-lift +
2447
+ * accent highlight), the pin-row / personal-header hover washes, one accent
2448
+ * focus ring for every modal control, the near-row tip's little arrow, the
2449
+ * personalization body's reveal, and a themed thin scrollbar. Accent values
2450
+ * come from the rail root's CSS variables (`--ms-accent*`), so one block
2451
+ * serves every accent. Inline styles keep these elements background-free
2452
+ * (except where noted) so the `:hover` washes below actually win. The
2453
+ * search-toggle recolor rule makes the EXTERNAL RailSearchUi chrome follow
2454
+ * the accent too (that file is owned by an earlier phase and cannot change);
2455
+ * `!important` is required because that toggle's own inline styles win
2456
+ * otherwise.
2457
+ */
2458
+ const MODAL_CSS = `
2459
+ [data-support-card] {
2460
+ display: flex; align-items: center; justify-content: center; gap: 8px;
2461
+ padding: 10px 12px; border-radius: 8px;
2462
+ background: rgba(255, 255, 255, 0.05); border: 1px solid ${MODAL_BORDER};
2463
+ color: #c7cede; text-decoration: none; font-size: 12.5px; line-height: 1.4;
2464
+ transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
2465
+ }
2466
+ [data-support-card]:hover { transform: translateY(-2px); border-color: var(--ms-accent); background: rgba(255, 255, 255, 0.09); }
2467
+ /* Row and header washes (the inline styles deliberately leave backgrounds
2468
+ unset so these rules win over the default padding-box background). */
2469
+ [data-toolbar-pin-toggle]:hover, [data-toolbar-pin-toggle]:focus-visible { background: rgba(255, 255, 255, 0.06); }
2470
+ [data-personal-toggle]:hover, [data-focus-toggle-settings]:hover { background: rgba(255, 255, 255, 0.05); }
2471
+ [data-focus-option]:hover { background: rgba(255, 255, 255, 0.04); }
2472
+ [data-toolbar-settings-close]:hover { background: rgba(255, 255, 255, 0.08); }
2473
+ [data-toolbar-settings-reset] { background: rgba(255, 255, 255, 0.06); }
2474
+ [data-toolbar-settings-reset]:hover { background: rgba(255, 255, 255, 0.1); }
2475
+ /* ONE accent ring for keyboard focus on every modal control. */
2476
+ [data-toolbar-pin-toggle]:focus-visible, [data-personal-toggle]:focus-visible,
2477
+ [data-focus-toggle-settings]:focus-visible,
2478
+ [data-toolbar-settings-close]:focus-visible, [data-toolbar-settings-reset]:focus-visible {
2479
+ box-shadow: 0 0 0 2px var(--ms-accent-soft);
2480
+ }
2481
+ /* Near-row description tip: a rotated square peeks out of the LEFT edge so
2482
+ the apex points back at the row's label. */
2483
+ [data-settings-tip]::before {
2484
+ content: ''; position: absolute; left: -3px; top: 50%;
2485
+ width: 7px; height: 7px; transform: translateY(-50%) rotate(45deg);
2486
+ background: ${MODAL_TIP_BG};
2487
+ border-left: 1px solid ${MODAL_BORDER};
2488
+ border-bottom: 1px solid ${MODAL_BORDER};
2489
+ }
2490
+ /* Tip + chevron motion lives here (not inline) so reduced-motion can kill it. */
2491
+ [data-settings-tip] { transition: opacity 140ms ease, transform 140ms ease, visibility 140ms; }
2492
+ [data-personal-toggle] svg, [data-focus-toggle-settings] svg { transition: transform 150ms ease; }
2493
+ /* One authored reveal: the personalization/focus bodies fade in on expand. */
2494
+ @keyframes ms-settings-fade { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } }
2495
+ [data-settings-personal-body], [data-settings-focus-body] { animation: ms-settings-fade 140ms ease; }
2496
+ /* Thin themed scrollbar for the scrollable modal panel. */
2497
+ [data-toolbar-settings-panel]::-webkit-scrollbar { width: 10px; }
2498
+ [data-toolbar-settings-panel]::-webkit-scrollbar-thumb {
2499
+ background: rgba(255, 255, 255, 0.16); border-radius: 6px; border: 3px solid transparent; background-clip: padding-box;
2500
+ }
2501
+ [data-toolbar-settings-panel]::-webkit-scrollbar-track { background: transparent; }
2502
+ @media (prefers-reduced-motion: reduce) {
2503
+ [data-settings-tip], [data-personal-toggle] svg, [data-focus-toggle-settings] svg, [data-support-card] { transition: none; }
2504
+ [data-settings-personal-body], [data-settings-focus-body] { animation: none; }
2505
+ }
2506
+ [data-search-toggle] { color: #8b96ab !important; }
2507
+ [data-search-toggle][aria-pressed="true"] { background: var(--ms-accent-bg) !important; color: var(--ms-accent-soft) !important; }
2508
+ `;
2509
+ /**
2510
+ * Shared collapsible-section chrome (personalization + focus blocks): the
2511
+ * header button (chevron + title + live summary) and the summary text span.
2512
+ * One source so the two settings blocks cannot drift.
2513
+ */
2514
+ const SECTION_TOGGLE_STYLE = {
2515
+ display: "flex",
2516
+ alignItems: "center",
2517
+ gap: 8,
2518
+ width: "100%",
2519
+ padding: "8px 10px",
2520
+ border: "none",
2521
+ borderRadius: MODAL_RADIUS_CONTROL,
2522
+ cursor: "pointer",
2523
+ color: MODAL_TITLE,
2524
+ fontSize: 13,
2525
+ fontWeight: 600,
2526
+ textAlign: "left"
2527
+ };
2528
+ const SECTION_SUMMARY_STYLE = {
2529
+ flex: 1,
2530
+ minWidth: 0,
2531
+ textAlign: "right",
2532
+ fontWeight: 400,
2533
+ fontSize: 12,
2534
+ color: MODAL_HINT,
2535
+ overflow: "hidden",
2536
+ textOverflow: "ellipsis",
2537
+ whiteSpace: "nowrap"
2538
+ };
1782
2539
  /**
1783
2540
  * P3 deep links (`#msg=<anchor-key>`): initial delay before the first
1784
2541
  * deep-link attempt — the harness scrolls the conversation to the bottom on
@@ -1846,7 +2603,7 @@ window.__ModuleLoader__.load({
1846
2603
  function MilestoneRail({ useSession, loadOlder, forkAt, useStore, actions, searchSessions = async () => ({
1847
2604
  items: [],
1848
2605
  hasMore: false
1849
- }), openSession = () => {}, t = (key) => key }) {
2606
+ }), openSession = () => {}, t: frameworkT = (key) => key }) {
1850
2607
  const order = useSession((s) => s.chat.order);
1851
2608
  const nodes = useSession((s) => s.chat.nodes);
1852
2609
  const locations = useSession((s) => s.chat.locations);
@@ -1951,46 +2708,85 @@ window.__ModuleLoader__.load({
1951
2708
  }
1952
2709
  return counts;
1953
2710
  }, [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]);
2711
+ const [prefs, setPrefs] = (0, react.useState)(() => loadPrefs());
2712
+ const { pinned, accent, iconSize, inset, side } = prefs;
2713
+ const scale = iconSize / DOT_HIT;
2714
+ const hit = iconSize;
2715
+ const size = DOT_SIZE * scale;
2716
+ const gap = DOT_GAP * scale;
2717
+ const accentSoft = lighten(accent, .42) ?? "#9db8ff";
2718
+ const accentBg = rgbaString(accent, .18) ?? "rgba(77, 124, 254, 0.18)";
2719
+ const accentStrong = rgbaString(accent, .55) ?? "rgba(77, 124, 254, 0.55)";
2720
+ /**
2721
+ * Language override (settings → 语言): `system` delegates to the harness
2722
+ * `t` seat (the framework-synthesized interpreter for the registered
2723
+ * `dsh-milestone` namespace); `zh`/`en` force the plugin's own dictionaries
2724
+ * so the rail copy switches independently of the host UI language. Every
2725
+ * call site below — rail chrome, panels, tooltip and the settings modal —
2726
+ * already resolves through this binding, so the override is global to the
2727
+ * rail without threading a second translate prop anywhere.
2728
+ */
2729
+ const t = prefs.locale === "system" ? frameworkT : prefs.locale === "en" ? (key, params) => translateDict(en, key, params) : (key, params) => translateDict(zh, key, params);
2730
+ /** Write a patch of prefs through to state + localStorage. */
2731
+ const updatePrefs = (patch) => {
2732
+ setPrefs((prev) => {
2733
+ const next = {
2734
+ ...prev,
2735
+ ...patch
2736
+ };
2737
+ savePrefs(next);
2738
+ return next;
2739
+ });
2740
+ };
2741
+ /** 0.6.3: patch ONE focus-mix flag/strength (nested field, same write-through). */
2742
+ const updateFocus = (patch) => {
2743
+ updatePrefs({ focus: {
2744
+ ...prefs.focus,
2745
+ ...patch
2746
+ } });
2747
+ };
2748
+ /**
2749
+ * 0.6.3: the focus block's live summary — the armed options joined into a
2750
+ * "聚焦搭配" line, plus the strength percentage. e.g. `think 淡化 · 强度 40%`.
2751
+ */
2752
+ const focusSummary = (() => {
2753
+ const parts = [
2754
+ prefs.focus.dimThink ? t("settings.focus.summary.think") : null,
2755
+ prefs.focus.dimTools ? t("settings.focus.summary.tools") : null,
2756
+ prefs.focus.collapseThink ? t("settings.focus.summary.collapse") : null
2757
+ ].filter((part) => part !== null);
2758
+ return t("settings.focus.summary", {
2759
+ opts: parts.length > 0 ? parts.join(" · ") : t("settings.focus.summary.none"),
2760
+ opacity: Math.round(prefs.focus.opacity * 100)
2761
+ });
2762
+ })();
2763
+ /** B1: flip one feature's pin — state and the persisted blob update together. */
2764
+ const onTogglePin = (id) => {
2765
+ setPrefs((prev) => {
2766
+ const next = togglePin(prev, id);
2767
+ savePrefs(next);
2768
+ return next;
2769
+ });
2770
+ };
2771
+ /** B-design: 恢复默认 resets EVERYTHING — pins AND personalization. */
2772
+ const onResetAll = () => {
2773
+ const next = { ...DEFAULT_PREFS };
2774
+ setPrefs(next);
2775
+ savePrefs(next);
2776
+ };
1991
2777
  const [toolbarExpanded, setToolbarExpanded] = (0, react.useState)(false);
2778
+ const [expandHovered, setExpandHovered] = (0, react.useState)(false);
2779
+ const [settingsHovered, setSettingsHovered] = (0, react.useState)(false);
1992
2780
  const [settingsOpen, setSettingsOpen] = (0, react.useState)(false);
1993
- const [pinned, setPinned] = (0, react.useState)(() => loadPrefs());
2781
+ /** The feature whose near-row description tip is currently visible
2782
+ * (`null` = none — tips only appear on hover/focus of their own row). */
2783
+ const [descFeature, setDescFeature] = (0, react.useState)(null);
2784
+ /** B-design: the personalization block collapsess by default so only a
2785
+ * value summary leads the section; expanding reveals the controls. */
2786
+ const [personalOpen, setPersonalOpen] = (0, react.useState)(false);
2787
+ /** B-design (0.6.3): the focus block mirrors the personalization block —
2788
+ * collapsed by default, the header leads with a live option summary. */
2789
+ const [focusOpen, setFocusOpen] = (0, react.useState)(false);
1994
2790
  const settingsRef = (0, react.useRef)(null);
1995
2791
  const settingsBtnRef = (0, react.useRef)(null);
1996
2792
  const [updateOpen, setUpdateOpen] = (0, react.useState)(false);
@@ -1998,11 +2794,12 @@ window.__ModuleLoader__.load({
1998
2794
  const updatePanelRef = (0, react.useRef)(null);
1999
2795
  const updateBtnRef = (0, react.useRef)(null);
2000
2796
  /**
2001
- * B1 settings menu: outside-pointerdown dismisses the menu (shared
2797
+ * B1 settings modal: outside-pointerdown dismisses it (shared
2002
2798
  * 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.
2799
+ * The modal's full-screen overlay wraps the dialog, so a pointerdown on the
2800
+ * backdrop (or anywhere outside the dialog) closes it; the gear's own click
2801
+ * keeps its flip semantics through a `[data-toolbar-settings]` exclusion —
2802
+ * pointerdown on an armed gear must not double-close.
2006
2803
  */
2007
2804
  useOutsideDismiss(settingsRef, settingsOpen, () => {
2008
2805
  setSettingsOpen(false);
@@ -2018,6 +2815,10 @@ window.__ModuleLoader__.load({
2018
2815
  window.addEventListener("keydown", onKey);
2019
2816
  return () => window.removeEventListener("keydown", onKey);
2020
2817
  }, [settingsOpen]);
2818
+ (0, react.useEffect)(() => {
2819
+ if (!settingsOpen) return;
2820
+ (settingsRef.current?.querySelector("[data-toolbar-settings-close]"))?.focus();
2821
+ }, [settingsOpen]);
2021
2822
  /**
2022
2823
  * B4: run one update check. Cache-aware (`loadCachedLatest` reuses an
2023
2824
  * unexpired cached result without any network traffic) and never throws:
@@ -2112,6 +2913,64 @@ window.__ModuleLoader__.load({
2112
2913
  window.addEventListener("hashchange", onHashChange);
2113
2914
  return () => window.removeEventListener("hashchange", onHashChange);
2114
2915
  }, []);
2916
+ (0, react.useLayoutEffect)(() => {
2917
+ if (marks.length < MIN_MARKS) {
2918
+ setRailBox(null);
2919
+ return;
2920
+ }
2921
+ const scrollport = document.querySelector("[data-conversation-scroll]");
2922
+ if (scrollport === null) return;
2923
+ const compute = () => {
2924
+ const sp = scrollport.getBoundingClientRect();
2925
+ setRailBox({
2926
+ top: sp.top,
2927
+ height: sp.height,
2928
+ right: Math.max(0, window.innerWidth - sp.right + inset),
2929
+ left: Math.max(0, sp.left + inset)
2930
+ });
2931
+ };
2932
+ compute();
2933
+ const observer = new ResizeObserver(compute);
2934
+ observer.observe(scrollport);
2935
+ window.addEventListener("resize", compute);
2936
+ return () => {
2937
+ observer.disconnect();
2938
+ window.removeEventListener("resize", compute);
2939
+ };
2940
+ }, [marks.length, inset]);
2941
+ (0, react.useLayoutEffect)(() => {
2942
+ setFocusIndex((f) => clampIndex(f, render.items.length));
2943
+ }, [render.items.length]);
2944
+ const lastBadge = (0, react.useMemo)(() => {
2945
+ if (displayMarks.length === 0) return null;
2946
+ const last = displayMarks[displayMarks.length - 1];
2947
+ return deriveBadge({
2948
+ nodeKinds: last.turn === void 0 ? NO_KINDS : kindsByTurn.get(last.turn) ?? NO_KINDS,
2949
+ lastMark: true,
2950
+ running,
2951
+ awaitingInput
2952
+ });
2953
+ }, [
2954
+ displayMarks,
2955
+ kindsByTurn,
2956
+ running,
2957
+ awaitingInput
2958
+ ]);
2959
+ const pulseCss = (0, react.useMemo)(() => {
2960
+ if (lastBadge === null) return null;
2961
+ const style = badgeRingStyle(lastBadge);
2962
+ return style.pulse ? badgePulseCss(style.color) : null;
2963
+ }, [lastBadge]);
2964
+ (0, react.useEffect)(() => {
2965
+ if (!listOpen && !crossOpen) return;
2966
+ const onKey = (e) => {
2967
+ if (e.key !== "Escape") return;
2968
+ setListOpen(false);
2969
+ setCrossOpen(false);
2970
+ };
2971
+ window.addEventListener("keydown", onKey);
2972
+ return () => window.removeEventListener("keydown", onKey);
2973
+ }, [listOpen, crossOpen]);
2115
2974
  if (railBox === null || marks.length < MIN_MARKS) return null;
2116
2975
  const updateQuery = (query) => {
2117
2976
  setSearch({
@@ -2148,28 +3007,45 @@ window.__ModuleLoader__.load({
2148
3007
  if (e.key === "Enter") advanceMatch();
2149
3008
  if (e.key === "Escape") closeSearch();
2150
3009
  };
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([]);
3010
+ /**
3011
+ * B-design: viewport `right` offset for the floating layers. On the classic
3012
+ * right-side rail the panels sit left of the rail (their right edge at
3013
+ * railBox.right + hit + 8); on a LEFT rail every layer flips to the rail's
3014
+ * OTHER side — its left edge at railBox.left + hit + 8, which means its
3015
+ * viewport `right` must be backed out from the (known) panel width.
3016
+ */
3017
+ const panelRightFor = (panelWidth) => side === "left" ? window.innerWidth - (railBox.left + hit + 8 + panelWidth) : railBox.right + hit + 8;
3018
+ /** Close the settings modal via its backdrop/close button paths. */
3019
+ const closeSettings = () => {
3020
+ setSettingsOpen(false);
3021
+ settingsBtnRef.current?.focus();
2163
3022
  };
2164
3023
  /** B1: a feature renders while the toolbar is EXPANDED or while it is pinned. */
2165
3024
  const featureVisible = (id) => toolbarExpanded || pinned.includes(id);
3025
+ /** Base chrome-button style: accent-defined active tint, scaled hit area. */
3026
+ const chromeButtonStyle = (active) => ({
3027
+ width: hit,
3028
+ height: hit,
3029
+ flexShrink: 0,
3030
+ display: "flex",
3031
+ alignItems: "center",
3032
+ justifyContent: "center",
3033
+ background: active ? accentBg : "transparent",
3034
+ border: "none",
3035
+ padding: 0,
3036
+ cursor: "pointer",
3037
+ color: active ? accentSoft : "#8b96ab",
3038
+ transition: "background 120ms ease, color 120ms ease"
3039
+ });
2166
3040
  /**
2167
3041
  * 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 (站内搜索/全部提问/跨会话搜索/只看收藏/聚焦模式).
3042
+ * rail-top chrome (data attributes / aria semantics preserved), moved
3043
+ * verbatim from the previous static button block; `search` is the whole
3044
+ * RailSearchUi (toggle + panel) so its lifecycle stays component-local in
3045
+ * the rail (search state lives in the rail and survives unmount). `settings`
3046
+ * lives LAST in the queue — the gear is a regular, default-unpinned feature;
3047
+ * the modal must stay reachable via 展开→齿轮.
3048
+ * Registry order = settings-menu order (站内搜索/全部提问/跨会话搜索/只看收藏/聚焦模式/检查更新/设置).
2173
3049
  *
2174
3050
  * EXTENSION POINT: push a new feature here (+ its id in toolbar-prefs.ts's
2175
3051
  * TOOLBAR_PIN_IDS and its locale keys) and pinning/settings/expand all
@@ -2181,7 +3057,7 @@ window.__ModuleLoader__.load({
2181
3057
  labelKey: "search.label",
2182
3058
  render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RailSearchUi, {
2183
3059
  panelTop: railBox.top,
2184
- panelRight: railBox.right + DOT_HIT + 8,
3060
+ panelRight: panelRightFor(PANEL_WIDTH_SEARCH),
2185
3061
  query: search.query,
2186
3062
  panelOpen: search.panelOpen,
2187
3063
  matches: matches.length,
@@ -2206,19 +3082,7 @@ window.__ModuleLoader__.load({
2206
3082
  title: listOpen ? t("list.close") : t("list.open"),
2207
3083
  "aria-pressed": listOpen,
2208
3084
  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
- },
3085
+ style: chromeButtonStyle(listOpen),
2222
3086
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2223
3087
  width: "16",
2224
3088
  height: "16",
@@ -2246,19 +3110,7 @@ window.__ModuleLoader__.load({
2246
3110
  title: crossOpen ? t("search.cross.close") : t("search.cross.open"),
2247
3111
  "aria-pressed": crossOpen,
2248
3112
  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
- },
3113
+ style: chromeButtonStyle(crossOpen),
2262
3114
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2263
3115
  width: "16",
2264
3116
  height: "16",
@@ -2293,19 +3145,7 @@ window.__ModuleLoader__.load({
2293
3145
  "aria-pressed": bookmarksOnly,
2294
3146
  "data-active": bookmarksOnly ? "true" : void 0,
2295
3147
  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
- },
3148
+ style: chromeButtonStyle(bookmarksOnly),
2309
3149
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
2310
3150
  width: "16",
2311
3151
  height: "16",
@@ -2329,19 +3169,7 @@ window.__ModuleLoader__.load({
2329
3169
  title: focusActive ? t("focus.off") : t("focus.on"),
2330
3170
  "aria-pressed": focusActive,
2331
3171
  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
- },
3172
+ style: chromeButtonStyle(focusActive),
2345
3173
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2346
3174
  width: "16",
2347
3175
  height: "16",
@@ -2373,17 +3201,17 @@ window.__ModuleLoader__.load({
2373
3201
  onClick: () => setUpdateOpen((v) => !v),
2374
3202
  style: {
2375
3203
  position: "relative",
2376
- width: DOT_HIT,
2377
- height: DOT_HIT,
3204
+ width: hit,
3205
+ height: hit,
2378
3206
  flexShrink: 0,
2379
3207
  display: "flex",
2380
3208
  alignItems: "center",
2381
3209
  justifyContent: "center",
2382
- background: updateOpen || updateCheck.available ? "rgba(245, 197, 66, 0.14)" : "transparent",
3210
+ background: updateCheck.available ? "rgba(245, 197, 66, 0.14)" : updateOpen ? accentBg : "transparent",
2383
3211
  border: "none",
2384
3212
  padding: 0,
2385
3213
  cursor: "pointer",
2386
- color: updateCheck.available ? "#f5c542" : "#8b96ab"
3214
+ color: updateCheck.available ? "#f5c542" : updateOpen ? accentSoft : "#8b96ab"
2387
3215
  },
2388
3216
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2389
3217
  width: "16",
@@ -2416,6 +3244,40 @@ window.__ModuleLoader__.load({
2416
3244
  }
2417
3245
  })]
2418
3246
  })
3247
+ },
3248
+ {
3249
+ id: "settings",
3250
+ labelKey: "settings.label",
3251
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3252
+ type: "button",
3253
+ ref: settingsBtnRef,
3254
+ "data-toolbar-settings": true,
3255
+ "aria-pressed": settingsOpen,
3256
+ "aria-label": settingsOpen ? t("toolbar.settings.close") : t("toolbar.settings.open"),
3257
+ title: settingsOpen ? t("toolbar.settings.close") : t("toolbar.settings.open"),
3258
+ onClick: () => setSettingsOpen((v) => !v),
3259
+ onMouseEnter: () => setSettingsHovered(true),
3260
+ onMouseLeave: () => setSettingsHovered(false),
3261
+ onFocus: () => setSettingsHovered(true),
3262
+ onBlur: () => setSettingsHovered(false),
3263
+ style: chromeButtonStyle(settingsOpen || settingsHovered),
3264
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
3265
+ width: "16",
3266
+ height: "16",
3267
+ viewBox: "0 0 24 24",
3268
+ fill: "none",
3269
+ stroke: "currentColor",
3270
+ strokeWidth: "2",
3271
+ strokeLinecap: "round",
3272
+ strokeLinejoin: "round",
3273
+ "aria-hidden": "true",
3274
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
3275
+ cx: "12",
3276
+ cy: "12",
3277
+ r: "3"
3278
+ }), /* @__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" })]
3279
+ })
3280
+ })
2419
3281
  }
2420
3282
  ];
2421
3283
  /** Focus the dot at `index` (no-op while the list is unmounted). */
@@ -2537,21 +3399,31 @@ window.__ModuleLoader__.load({
2537
3399
  style: {
2538
3400
  position: "fixed",
2539
3401
  top: railBox.top,
2540
- right: railBox.right,
3402
+ ...side === "left" ? { left: railBox.left } : { right: railBox.right },
2541
3403
  height: railBox.height,
2542
- width: DOT_HIT,
3404
+ width: hit,
2543
3405
  pointerEvents: "auto",
2544
3406
  zIndex: 100,
2545
3407
  display: "flex",
2546
3408
  flexDirection: "column",
2547
3409
  gap: 6,
2548
- paddingTop: 6
3410
+ paddingTop: 6,
3411
+ "--ms-accent": accent,
3412
+ "--ms-accent-soft": accentSoft,
3413
+ "--ms-accent-bg": accentBg,
3414
+ "--ms-icon": `${iconSize}px`,
3415
+ "--ms-inset": `${inset}px`
2549
3416
  },
2550
3417
  "aria-label": t("rail.label"),
2551
3418
  "data-focus-active": focusActive ? "true" : void 0,
3419
+ "data-accent": accent,
3420
+ "data-side": side,
3421
+ "data-icon-size": String(iconSize),
3422
+ "data-inset": String(inset),
2552
3423
  children: [
2553
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: BADGE_PULSE_CSS }),
2554
- focusActive && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: FOCUS_CSS }),
3424
+ pulseCss !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: pulseCss }),
3425
+ focusActive && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: buildFocusCss(prefs.focus) }),
3426
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: MODAL_CSS }),
2555
3427
  showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2556
3428
  type: "button",
2557
3429
  "data-load-older": true,
@@ -2563,8 +3435,8 @@ window.__ModuleLoader__.load({
2563
3435
  loadOlder();
2564
3436
  },
2565
3437
  style: {
2566
- width: DOT_HIT,
2567
- height: DOT_HIT,
3438
+ width: hit,
3439
+ height: hit,
2568
3440
  flexShrink: 0,
2569
3441
  display: "flex",
2570
3442
  alignItems: "center",
@@ -2587,19 +3459,11 @@ window.__ModuleLoader__.load({
2587
3459
  "aria-label": toolbarExpanded ? t("toolbar.collapse") : t("toolbar.expand"),
2588
3460
  title: toolbarExpanded ? t("toolbar.collapse") : t("toolbar.expand"),
2589
3461
  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
- },
3462
+ onMouseEnter: () => setExpandHovered(true),
3463
+ onMouseLeave: () => setExpandHovered(false),
3464
+ onFocus: () => setExpandHovered(true),
3465
+ onBlur: () => setExpandHovered(false),
3466
+ style: chromeButtonStyle(expandHovered),
2603
3467
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
2604
3468
  width: "16",
2605
3469
  height: "16",
@@ -2613,221 +3477,822 @@ window.__ModuleLoader__.load({
2613
3477
  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
3478
  })
2615
3479
  }),
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),
3480
+ toolbarFeatures.map((feature) => featureVisible(feature.id) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react.Fragment, { children: feature.render() }, feature.id) : null),
3481
+ settingsOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3482
+ "data-toolbar-settings-overlay": true,
3483
+ onClick: closeSettings,
2624
3484
  style: {
2625
- width: DOT_HIT,
2626
- height: DOT_HIT,
2627
- flexShrink: 0,
3485
+ position: "fixed",
3486
+ inset: 0,
3487
+ background: "rgba(8, 10, 15, 0.55)",
3488
+ zIndex: 105,
2628
3489
  display: "flex",
2629
3490
  alignItems: "center",
2630
3491
  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"
3492
+ padding: 16
2636
3493
  },
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", {
3494
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3495
+ ref: settingsRef,
3496
+ "data-toolbar-settings-panel": true,
3497
+ role: "dialog",
3498
+ "aria-modal": "true",
3499
+ "aria-label": t("settings.title"),
3500
+ onClick: (e) => e.stopPropagation(),
3501
+ style: {
3502
+ width: "min(600px, 92vw)",
3503
+ maxHeight: "78vh",
3504
+ overflowY: "auto",
3505
+ padding: 20,
3506
+ background: MODAL_BG,
3507
+ color: MODAL_FG,
3508
+ borderRadius: MODAL_RADIUS_PANEL,
3509
+ boxShadow: "0 24px 64px rgba(0, 0, 0, 0.55)",
3510
+ scrollbarWidth: "thin",
3511
+ scrollbarColor: "rgba(255, 255, 255, 0.2) transparent"
3512
+ },
3513
+ children: [
3514
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3515
+ style: {
3516
+ display: "flex",
3517
+ alignItems: "center",
3518
+ justifyContent: "space-between",
3519
+ gap: 8,
3520
+ marginBottom: 18
3521
+ },
3522
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3523
+ "data-toolbar-settings-title": true,
3524
+ style: {
3525
+ fontSize: 15,
3526
+ fontWeight: 600,
3527
+ color: MODAL_FG
3528
+ },
3529
+ children: t("settings.title")
3530
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2702
3531
  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),
3532
+ "data-toolbar-settings-close": true,
3533
+ "aria-label": t("settings.close"),
3534
+ title: t("settings.close"),
3535
+ onClick: closeSettings,
2708
3536
  style: {
3537
+ width: 28,
3538
+ height: 28,
3539
+ flexShrink: 0,
2709
3540
  display: "flex",
2710
3541
  alignItems: "center",
2711
- justifyContent: "space-between",
2712
- gap: 8,
2713
- width: "100%",
2714
- padding: "6px 8px",
2715
- background: "transparent",
3542
+ justifyContent: "center",
2716
3543
  border: "none",
2717
- borderRadius: 6,
3544
+ padding: 0,
2718
3545
  cursor: "pointer",
2719
- color: "#e6e8ee",
2720
- fontSize: 13,
2721
- textAlign: "left"
3546
+ color: MODAL_HINT,
3547
+ borderRadius: MODAL_RADIUS_CONTROL,
3548
+ lineHeight: 1
2722
3549
  },
2723
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t(feature.labelKey) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3550
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
3551
+ width: "14",
3552
+ height: "14",
3553
+ viewBox: "0 0 24 24",
3554
+ fill: "none",
3555
+ stroke: "currentColor",
3556
+ strokeWidth: "2",
3557
+ strokeLinecap: "round",
2724
3558
  "aria-hidden": "true",
2725
- 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
- display: "flex",
2733
- alignItems: "center",
2734
- justifyContent: "center",
2735
- color: "#ffffff",
2736
- fontSize: 10,
2737
- lineHeight: 1
2738
- },
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
- },
3559
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 6 6 18" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 6 12 12" })]
3560
+ })
3561
+ })]
3562
+ }),
3563
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3564
+ "data-settings-section": true,
3565
+ style: { marginBottom: 20 },
2782
3566
  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",
3567
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3568
+ "data-settings-section-title": true,
2798
3569
  style: {
2799
- fontSize: 12,
2800
- color: "#9db8ff",
2801
- textDecoration: "none"
3570
+ fontSize: 13,
3571
+ fontWeight: 600,
3572
+ color: MODAL_TITLE,
3573
+ marginBottom: 4
2802
3574
  },
2803
- children: t("settings.star")
3575
+ children: t("settings.section.features")
2804
3576
  }),
2805
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2806
- href: `https://github.com/SnowCrescenter-tech/dsh-milestone/issues`,
2807
- target: "_blank",
2808
- rel: "noreferrer",
3577
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3578
+ "data-settings-pin-hint": true,
2809
3579
  style: {
2810
3580
  fontSize: 12,
2811
- color: "#9db8ff",
2812
- textDecoration: "none"
3581
+ color: MODAL_HINT,
3582
+ lineHeight: 1.5,
3583
+ marginBottom: 10
2813
3584
  },
2814
- children: t("settings.issues")
3585
+ children: t("settings.pin.hint")
2815
3586
  }),
2816
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2817
- href: "https://www.npmjs.com/package/dsh-milestone",
2818
- target: "_blank",
2819
- rel: "noreferrer",
3587
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2820
3588
  style: {
2821
- fontSize: 12,
2822
- color: "#9db8ff",
2823
- textDecoration: "none"
3589
+ display: "flex",
3590
+ flexDirection: "column",
3591
+ gap: 2
2824
3592
  },
2825
- children: t("settings.npm")
3593
+ children: toolbarFeatures.map((feature) => {
3594
+ const checked = pinned.includes(feature.id);
3595
+ const active = descFeature === feature.id;
3596
+ const tipId = `ms-settings-tip-${feature.id}`;
3597
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3598
+ "data-toolbar-pin-row": true,
3599
+ "data-row-id": feature.id,
3600
+ style: { position: "relative" },
3601
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3602
+ type: "button",
3603
+ role: "switch",
3604
+ "data-toolbar-pin-toggle": true,
3605
+ "data-pin-id": feature.id,
3606
+ "aria-checked": checked,
3607
+ "aria-label": t(feature.labelKey),
3608
+ "aria-describedby": tipId,
3609
+ onMouseEnter: () => setDescFeature(feature.id),
3610
+ onMouseLeave: () => setDescFeature((prev) => prev === feature.id ? null : prev),
3611
+ onFocus: () => setDescFeature(feature.id),
3612
+ onBlur: () => setDescFeature((prev) => prev === feature.id ? null : prev),
3613
+ onClick: () => onTogglePin(feature.id),
3614
+ style: {
3615
+ display: "flex",
3616
+ alignItems: "center",
3617
+ justifyContent: "space-between",
3618
+ gap: 10,
3619
+ width: "100%",
3620
+ padding: "8px 10px",
3621
+ border: "none",
3622
+ borderRadius: MODAL_RADIUS_CONTROL,
3623
+ cursor: "pointer",
3624
+ color: MODAL_FG,
3625
+ fontSize: 13,
3626
+ textAlign: "left"
3627
+ },
3628
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3629
+ style: {
3630
+ overflow: "hidden",
3631
+ textOverflow: "ellipsis",
3632
+ whiteSpace: "nowrap"
3633
+ },
3634
+ children: t(feature.labelKey)
3635
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3636
+ "aria-hidden": "true",
3637
+ style: {
3638
+ position: "relative",
3639
+ width: 32,
3640
+ height: 18,
3641
+ flexShrink: 0,
3642
+ borderRadius: 9,
3643
+ background: checked ? accentStrong : "rgba(255, 255, 255, 0.16)",
3644
+ transition: "background 120ms ease"
3645
+ },
3646
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: {
3647
+ position: "absolute",
3648
+ top: 2,
3649
+ left: checked ? 16 : 2,
3650
+ width: 14,
3651
+ height: 14,
3652
+ borderRadius: "50%",
3653
+ background: "#ffffff",
3654
+ transition: "left 120ms ease"
3655
+ } })
3656
+ })]
3657
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3658
+ id: tipId,
3659
+ role: "tooltip",
3660
+ "data-settings-tip": true,
3661
+ "data-tip-for": feature.id,
3662
+ "data-tip-visible": active ? "true" : void 0,
3663
+ style: {
3664
+ position: "absolute",
3665
+ right: MODAL_TIP_RIGHT,
3666
+ top: "50%",
3667
+ maxWidth: "55%",
3668
+ transform: `translateY(-50%) translateX(${active ? 0 : 4}px)`,
3669
+ padding: "5px 10px",
3670
+ borderRadius: MODAL_RADIUS_CONTROL,
3671
+ background: MODAL_TIP_BG,
3672
+ border: `1px solid ${MODAL_BORDER}`,
3673
+ boxShadow: "0 8px 24px rgba(0, 0, 0, 0.35)",
3674
+ color: MODAL_TEXT,
3675
+ fontSize: 12,
3676
+ lineHeight: 1.45,
3677
+ opacity: active ? 1 : 0,
3678
+ visibility: active ? "visible" : "hidden",
3679
+ pointerEvents: "none",
3680
+ zIndex: 4
3681
+ },
3682
+ children: t(`settings.desc.${feature.id}`)
3683
+ })]
3684
+ }, feature.id);
3685
+ })
2826
3686
  })
2827
3687
  ]
2828
- })]
2829
- })
2830
- ]
3688
+ }),
3689
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3690
+ "data-settings-section": true,
3691
+ "data-settings-personal": true,
3692
+ style: { marginBottom: 20 },
3693
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3694
+ type: "button",
3695
+ "data-personal-toggle": true,
3696
+ "aria-expanded": personalOpen,
3697
+ "aria-label": personalOpen ? t("settings.personal.collapse") : t("settings.personal.expand"),
3698
+ title: personalOpen ? t("settings.personal.collapse") : t("settings.personal.expand"),
3699
+ onClick: () => setPersonalOpen((v) => !v),
3700
+ style: SECTION_TOGGLE_STYLE,
3701
+ children: [
3702
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
3703
+ width: "14",
3704
+ height: "14",
3705
+ viewBox: "0 0 24 24",
3706
+ fill: "none",
3707
+ stroke: "currentColor",
3708
+ strokeWidth: "2.5",
3709
+ strokeLinecap: "round",
3710
+ strokeLinejoin: "round",
3711
+ "aria-hidden": "true",
3712
+ style: {
3713
+ flexShrink: 0,
3714
+ transform: personalOpen ? "rotate(90deg)" : "none"
3715
+ },
3716
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m9 18 6-6-6-6" })
3717
+ }),
3718
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3719
+ "data-settings-section-title": true,
3720
+ style: { flexShrink: 0 },
3721
+ children: t("settings.section.personal")
3722
+ }),
3723
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3724
+ "data-settings-personal-summary": true,
3725
+ style: SECTION_SUMMARY_STYLE,
3726
+ children: t("settings.personal.summary", {
3727
+ accent,
3728
+ icon: iconSize,
3729
+ side: side === "left" ? t("settings.side.left") : t("settings.side.right")
3730
+ })
3731
+ })
3732
+ ]
3733
+ }), personalOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3734
+ "data-settings-personal-body": true,
3735
+ style: {
3736
+ padding: "10px 4px 8px",
3737
+ display: "flex",
3738
+ flexDirection: "column",
3739
+ gap: 12
3740
+ },
3741
+ children: [
3742
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3743
+ "data-settings-personal-hint": true,
3744
+ style: {
3745
+ fontSize: 12,
3746
+ color: MODAL_HINT,
3747
+ lineHeight: 1.5,
3748
+ padding: "0 6px"
3749
+ },
3750
+ children: t("settings.personal.hint")
3751
+ }),
3752
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3753
+ style: {
3754
+ display: "flex",
3755
+ alignItems: "center",
3756
+ gap: 10,
3757
+ flexWrap: "wrap"
3758
+ },
3759
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3760
+ style: {
3761
+ fontSize: 12.5,
3762
+ color: MODAL_HINT,
3763
+ width: 90,
3764
+ flexShrink: 0
3765
+ },
3766
+ children: t("settings.accent")
3767
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3768
+ "data-accent-swatches": true,
3769
+ style: {
3770
+ display: "flex",
3771
+ alignItems: "center",
3772
+ gap: 6,
3773
+ flexWrap: "wrap"
3774
+ },
3775
+ children: [ACCENT_PRESETS.map((preset) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3776
+ type: "button",
3777
+ "data-accent-swatch": true,
3778
+ "data-accent": preset,
3779
+ "aria-label": preset,
3780
+ "aria-pressed": accent === preset,
3781
+ onClick: () => updatePrefs({ accent: preset }),
3782
+ style: {
3783
+ width: 22,
3784
+ height: 22,
3785
+ borderRadius: "50%",
3786
+ background: preset,
3787
+ border: accent === preset ? "2px solid #ffffff" : "2px solid rgba(255, 255, 255, 0.25)",
3788
+ boxShadow: accent === preset ? `0 0 0 2px ${preset}` : "none",
3789
+ padding: 0,
3790
+ cursor: "pointer",
3791
+ transition: "border-color 120ms ease, box-shadow 120ms ease"
3792
+ }
3793
+ }, preset)), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3794
+ "data-accent-custom": true,
3795
+ style: {
3796
+ display: "inline-flex",
3797
+ alignItems: "center",
3798
+ gap: 6,
3799
+ fontSize: 12.5,
3800
+ color: MODAL_TEXT,
3801
+ cursor: "pointer"
3802
+ },
3803
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3804
+ type: "color",
3805
+ value: accent,
3806
+ onChange: (e) => updatePrefs({ accent: e.target.value }),
3807
+ "aria-label": `${t("settings.custom")} ${t("settings.accent")}`,
3808
+ style: {
3809
+ width: 26,
3810
+ height: 26,
3811
+ padding: 0,
3812
+ border: "none",
3813
+ background: "transparent",
3814
+ cursor: "pointer"
3815
+ }
3816
+ }), t("settings.custom")]
3817
+ })]
3818
+ })]
3819
+ }),
3820
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3821
+ style: {
3822
+ display: "flex",
3823
+ alignItems: "center",
3824
+ gap: 10,
3825
+ flexWrap: "wrap"
3826
+ },
3827
+ children: [
3828
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3829
+ style: {
3830
+ fontSize: 12.5,
3831
+ color: MODAL_HINT,
3832
+ width: 90,
3833
+ flexShrink: 0
3834
+ },
3835
+ children: t("settings.iconSize")
3836
+ }),
3837
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3838
+ type: "range",
3839
+ "data-icon-size": true,
3840
+ min: 20,
3841
+ max: 36,
3842
+ step: 2,
3843
+ value: iconSize,
3844
+ onChange: (e) => updatePrefs({ iconSize: Number(e.target.value) }),
3845
+ style: {
3846
+ flex: 1,
3847
+ minWidth: 140,
3848
+ maxWidth: 260
3849
+ }
3850
+ }),
3851
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3852
+ "data-icon-size-value": true,
3853
+ style: {
3854
+ fontSize: 12.5,
3855
+ color: MODAL_TEXT,
3856
+ width: 40
3857
+ },
3858
+ children: [iconSize, "px"]
3859
+ })
3860
+ ]
3861
+ }),
3862
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3863
+ style: {
3864
+ display: "flex",
3865
+ alignItems: "center",
3866
+ gap: 10,
3867
+ flexWrap: "wrap"
3868
+ },
3869
+ children: [
3870
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3871
+ style: {
3872
+ fontSize: 12.5,
3873
+ color: MODAL_HINT,
3874
+ width: 90,
3875
+ flexShrink: 0
3876
+ },
3877
+ children: t("settings.inset")
3878
+ }),
3879
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3880
+ type: "range",
3881
+ "data-inset": true,
3882
+ min: 0,
3883
+ max: 40,
3884
+ step: 2,
3885
+ value: inset,
3886
+ onChange: (e) => updatePrefs({ inset: Number(e.target.value) }),
3887
+ style: {
3888
+ flex: 1,
3889
+ minWidth: 140,
3890
+ maxWidth: 260
3891
+ }
3892
+ }),
3893
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3894
+ "data-inset-value": true,
3895
+ style: {
3896
+ fontSize: 12.5,
3897
+ color: MODAL_TEXT,
3898
+ width: 40
3899
+ },
3900
+ children: [inset, "px"]
3901
+ })
3902
+ ]
3903
+ }),
3904
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3905
+ role: "radiogroup",
3906
+ "aria-label": t("settings.side"),
3907
+ style: {
3908
+ display: "flex",
3909
+ alignItems: "center",
3910
+ gap: 10,
3911
+ flexWrap: "wrap"
3912
+ },
3913
+ children: [
3914
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3915
+ style: {
3916
+ fontSize: 12.5,
3917
+ color: MODAL_HINT,
3918
+ width: 90,
3919
+ flexShrink: 0
3920
+ },
3921
+ children: t("settings.side")
3922
+ }),
3923
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3924
+ style: {
3925
+ display: "inline-flex",
3926
+ alignItems: "center",
3927
+ gap: 5,
3928
+ fontSize: 13,
3929
+ color: MODAL_FG,
3930
+ cursor: "pointer"
3931
+ },
3932
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3933
+ type: "radio",
3934
+ name: "ms-rail-side",
3935
+ "data-side-radio": true,
3936
+ value: "left",
3937
+ checked: side === "left",
3938
+ onChange: () => updatePrefs({ side: "left" })
3939
+ }), t("settings.side.left")]
3940
+ }),
3941
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3942
+ style: {
3943
+ display: "inline-flex",
3944
+ alignItems: "center",
3945
+ gap: 5,
3946
+ fontSize: 13,
3947
+ color: MODAL_FG,
3948
+ cursor: "pointer"
3949
+ },
3950
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3951
+ type: "radio",
3952
+ name: "ms-rail-side",
3953
+ "data-side-radio": true,
3954
+ value: "right",
3955
+ checked: side === "right",
3956
+ onChange: () => updatePrefs({ side: "right" })
3957
+ }), t("settings.side.right")]
3958
+ })
3959
+ ]
3960
+ })
3961
+ ]
3962
+ })]
3963
+ }),
3964
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3965
+ "data-settings-section": true,
3966
+ "data-focus-settings": true,
3967
+ style: { marginBottom: 20 },
3968
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3969
+ type: "button",
3970
+ "data-focus-toggle-settings": true,
3971
+ "aria-expanded": focusOpen,
3972
+ "aria-label": focusOpen ? t("settings.focus.collapse") : t("settings.focus.expand"),
3973
+ title: focusOpen ? t("settings.focus.collapse") : t("settings.focus.expand"),
3974
+ onClick: () => setFocusOpen((v) => !v),
3975
+ style: SECTION_TOGGLE_STYLE,
3976
+ children: [
3977
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
3978
+ width: "14",
3979
+ height: "14",
3980
+ viewBox: "0 0 24 24",
3981
+ fill: "none",
3982
+ stroke: "currentColor",
3983
+ strokeWidth: "2.5",
3984
+ strokeLinecap: "round",
3985
+ strokeLinejoin: "round",
3986
+ "aria-hidden": "true",
3987
+ style: {
3988
+ flexShrink: 0,
3989
+ transform: focusOpen ? "rotate(90deg)" : "none"
3990
+ },
3991
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m9 18 6-6-6-6" })
3992
+ }),
3993
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3994
+ "data-settings-section-title": true,
3995
+ style: { flexShrink: 0 },
3996
+ children: t("settings.section.focus")
3997
+ }),
3998
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3999
+ "data-focus-summary": true,
4000
+ style: SECTION_SUMMARY_STYLE,
4001
+ children: focusSummary
4002
+ })
4003
+ ]
4004
+ }), focusOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4005
+ "data-settings-focus-body": true,
4006
+ style: {
4007
+ padding: "10px 4px 8px",
4008
+ display: "flex",
4009
+ flexDirection: "column",
4010
+ gap: 12
4011
+ },
4012
+ children: [
4013
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4014
+ "data-settings-focus-hint": true,
4015
+ style: {
4016
+ fontSize: 12,
4017
+ color: MODAL_HINT,
4018
+ lineHeight: 1.5,
4019
+ padding: "0 6px"
4020
+ },
4021
+ children: t("settings.focus.hint")
4022
+ }),
4023
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4024
+ "data-focus-option": true,
4025
+ style: {
4026
+ display: "flex",
4027
+ alignItems: "center",
4028
+ gap: 8,
4029
+ padding: "6px 8px",
4030
+ borderRadius: MODAL_RADIUS_CONTROL,
4031
+ fontSize: 13,
4032
+ color: MODAL_FG,
4033
+ cursor: "pointer"
4034
+ },
4035
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4036
+ type: "checkbox",
4037
+ "data-focus-dim-think": true,
4038
+ checked: prefs.focus.dimThink,
4039
+ onChange: (e) => updateFocus({ dimThink: e.target.checked })
4040
+ }), t("settings.focus.dimThink")]
4041
+ }),
4042
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4043
+ "data-focus-option": true,
4044
+ style: {
4045
+ display: "flex",
4046
+ alignItems: "center",
4047
+ gap: 8,
4048
+ padding: "6px 8px",
4049
+ borderRadius: MODAL_RADIUS_CONTROL,
4050
+ fontSize: 13,
4051
+ color: MODAL_FG,
4052
+ cursor: "pointer"
4053
+ },
4054
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4055
+ type: "checkbox",
4056
+ "data-focus-dim-tools": true,
4057
+ checked: prefs.focus.dimTools,
4058
+ onChange: (e) => updateFocus({ dimTools: e.target.checked })
4059
+ }), t("settings.focus.dimTools")]
4060
+ }),
4061
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4062
+ "data-focus-option": true,
4063
+ style: {
4064
+ display: "flex",
4065
+ alignItems: "center",
4066
+ gap: 8,
4067
+ padding: "6px 8px",
4068
+ borderRadius: MODAL_RADIUS_CONTROL,
4069
+ fontSize: 13,
4070
+ color: MODAL_FG,
4071
+ cursor: "pointer"
4072
+ },
4073
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4074
+ type: "checkbox",
4075
+ "data-focus-collapse-think": true,
4076
+ checked: prefs.focus.collapseThink,
4077
+ onChange: (e) => updateFocus({ collapseThink: e.target.checked })
4078
+ }), t("settings.focus.collapseThink")]
4079
+ }),
4080
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4081
+ style: {
4082
+ display: "flex",
4083
+ alignItems: "center",
4084
+ gap: 10,
4085
+ flexWrap: "wrap"
4086
+ },
4087
+ children: [
4088
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4089
+ style: {
4090
+ fontSize: 12.5,
4091
+ color: MODAL_HINT,
4092
+ width: 90,
4093
+ flexShrink: 0
4094
+ },
4095
+ children: t("settings.focus.opacity")
4096
+ }),
4097
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4098
+ type: "range",
4099
+ "data-focus-opacity": true,
4100
+ min: .2,
4101
+ max: .8,
4102
+ step: .1,
4103
+ value: prefs.focus.opacity,
4104
+ onChange: (e) => updateFocus({ opacity: Number(e.target.value) }),
4105
+ style: {
4106
+ flex: 1,
4107
+ minWidth: 140,
4108
+ maxWidth: 260
4109
+ }
4110
+ }),
4111
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4112
+ "data-focus-opacity-value": true,
4113
+ style: {
4114
+ fontSize: 12.5,
4115
+ color: MODAL_TEXT,
4116
+ width: 44
4117
+ },
4118
+ children: [Math.round(prefs.focus.opacity * 100), "%"]
4119
+ })
4120
+ ]
4121
+ })
4122
+ ]
4123
+ })]
4124
+ }),
4125
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4126
+ "data-settings-section": true,
4127
+ "data-settings-lang": true,
4128
+ style: { marginBottom: 20 },
4129
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4130
+ "data-settings-section-title": true,
4131
+ style: {
4132
+ fontSize: 13,
4133
+ fontWeight: 600,
4134
+ color: MODAL_TITLE,
4135
+ marginBottom: 8
4136
+ },
4137
+ children: t("settings.language")
4138
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4139
+ role: "radiogroup",
4140
+ "aria-label": t("settings.language"),
4141
+ style: {
4142
+ display: "flex",
4143
+ alignItems: "center",
4144
+ gap: 18,
4145
+ flexWrap: "wrap"
4146
+ },
4147
+ children: [
4148
+ "system",
4149
+ "zh",
4150
+ "en"
4151
+ ].map((value) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4152
+ style: {
4153
+ display: "inline-flex",
4154
+ alignItems: "center",
4155
+ gap: 5,
4156
+ fontSize: 13,
4157
+ color: MODAL_FG,
4158
+ cursor: "pointer"
4159
+ },
4160
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4161
+ type: "radio",
4162
+ name: "ms-rail-locale",
4163
+ "data-locale-pref": true,
4164
+ value,
4165
+ checked: prefs.locale === value,
4166
+ onChange: () => updatePrefs({ locale: value })
4167
+ }), t(`settings.lang.${value}`)]
4168
+ }, value))
4169
+ })]
4170
+ }),
4171
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4172
+ "data-toolbar-settings-footer": true,
4173
+ style: { textAlign: "center" },
4174
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4175
+ style: {
4176
+ fontSize: 12,
4177
+ color: MODAL_HINT,
4178
+ marginBottom: 10
4179
+ },
4180
+ children: t("settings.support")
4181
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4182
+ "data-support-grid": true,
4183
+ style: {
4184
+ display: "grid",
4185
+ gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
4186
+ gap: 10,
4187
+ maxWidth: 460,
4188
+ margin: "0 auto"
4189
+ },
4190
+ children: [
4191
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
4192
+ href: "https://github.com/SnowCrescenter-tech/dsh-milestone",
4193
+ target: "_blank",
4194
+ rel: "noreferrer",
4195
+ "data-support-card": true,
4196
+ "data-card": "repo",
4197
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
4198
+ width: "14",
4199
+ height: "14",
4200
+ viewBox: "0 0 24 24",
4201
+ fill: "none",
4202
+ stroke: "currentColor",
4203
+ strokeWidth: "2",
4204
+ strokeLinecap: "round",
4205
+ strokeLinejoin: "round",
4206
+ "aria-hidden": "true",
4207
+ 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" })
4208
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.repo") })]
4209
+ }),
4210
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
4211
+ href: "https://github.com/SnowCrescenter-tech/dsh-milestone",
4212
+ target: "_blank",
4213
+ rel: "noreferrer",
4214
+ "data-support-card": true,
4215
+ "data-card": "star",
4216
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
4217
+ width: "14",
4218
+ height: "14",
4219
+ viewBox: "0 0 24 24",
4220
+ fill: "currentColor",
4221
+ stroke: "none",
4222
+ "aria-hidden": "true",
4223
+ 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" })
4224
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.star") })]
4225
+ }),
4226
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
4227
+ href: `https://github.com/SnowCrescenter-tech/dsh-milestone/issues`,
4228
+ target: "_blank",
4229
+ rel: "noreferrer",
4230
+ "data-support-card": true,
4231
+ "data-card": "issues",
4232
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
4233
+ width: "14",
4234
+ height: "14",
4235
+ viewBox: "0 0 24 24",
4236
+ fill: "none",
4237
+ stroke: "currentColor",
4238
+ strokeWidth: "2",
4239
+ "aria-hidden": "true",
4240
+ children: [
4241
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
4242
+ cx: "12",
4243
+ cy: "12",
4244
+ r: "9"
4245
+ }),
4246
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4247
+ d: "M12 8v4",
4248
+ strokeLinecap: "round"
4249
+ }),
4250
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
4251
+ cx: "12",
4252
+ cy: "16",
4253
+ r: "0.5",
4254
+ fill: "currentColor"
4255
+ })
4256
+ ]
4257
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.issues") })]
4258
+ }),
4259
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
4260
+ href: "https://www.npmjs.com/package/dsh-milestone",
4261
+ target: "_blank",
4262
+ rel: "noreferrer",
4263
+ "data-support-card": true,
4264
+ "data-card": "npm",
4265
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
4266
+ width: "14",
4267
+ height: "14",
4268
+ viewBox: "0 0 24 24",
4269
+ fill: "currentColor",
4270
+ stroke: "none",
4271
+ "aria-hidden": "true",
4272
+ 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" })
4273
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("settings.npm") })]
4274
+ })
4275
+ ]
4276
+ })]
4277
+ }),
4278
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4279
+ type: "button",
4280
+ "data-toolbar-settings-reset": true,
4281
+ onClick: onResetAll,
4282
+ style: {
4283
+ display: "block",
4284
+ margin: "16px auto 2px",
4285
+ padding: "7px 16px",
4286
+ border: `1px solid ${MODAL_BORDER}`,
4287
+ borderRadius: MODAL_RADIUS_CONTROL,
4288
+ cursor: "pointer",
4289
+ color: MODAL_TEXT,
4290
+ fontSize: 12.5
4291
+ },
4292
+ children: t("settings.reset")
4293
+ })
4294
+ ]
4295
+ })
2831
4296
  }),
2832
4297
  updateOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2833
4298
  ref: updatePanelRef,
@@ -2835,7 +4300,7 @@ window.__ModuleLoader__.load({
2835
4300
  style: {
2836
4301
  position: "fixed",
2837
4302
  top: railBox.top,
2838
- right: railBox.right + DOT_HIT + 8,
4303
+ right: panelRightFor(PANEL_WIDTH_STANDARD),
2839
4304
  width: "min(280px, calc(100vw - 48px))",
2840
4305
  padding: "10px 12px",
2841
4306
  background: "rgba(20, 24, 32, 0.97)",
@@ -2865,7 +4330,7 @@ window.__ModuleLoader__.load({
2865
4330
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2866
4331
  style: { color: "#8b96ab" },
2867
4332
  children: [t("update.current"), ": "]
2868
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "0.6.1" })] }),
4333
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "0.6.3" })] }),
2869
4334
  updateCheck.phase === "ok" && updateCheck.latest !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2870
4335
  "data-update-latest": true,
2871
4336
  children: [
@@ -2908,7 +4373,7 @@ window.__ModuleLoader__.load({
2908
4373
  border: "none",
2909
4374
  padding: 0,
2910
4375
  cursor: "pointer",
2911
- color: "#9db8ff",
4376
+ color: accentSoft,
2912
4377
  fontSize: 12,
2913
4378
  textDecoration: "underline"
2914
4379
  },
@@ -2928,7 +4393,7 @@ window.__ModuleLoader__.load({
2928
4393
  target: "_blank",
2929
4394
  rel: "noreferrer",
2930
4395
  style: {
2931
- color: "#9db8ff",
4396
+ color: accentSoft,
2932
4397
  textDecoration: "none"
2933
4398
  },
2934
4399
  children: t("update.goNpm")
@@ -2952,11 +4417,11 @@ window.__ModuleLoader__.load({
2952
4417
  style: {
2953
4418
  marginTop: 4,
2954
4419
  padding: "6px 10px",
2955
- background: updateCheck.phase === "checking" ? "transparent" : "rgba(77, 124, 254, 0.18)",
4420
+ background: updateCheck.phase === "checking" ? "transparent" : accentBg,
2956
4421
  border: "none",
2957
4422
  borderRadius: 6,
2958
4423
  cursor: updateCheck.phase === "checking" ? "default" : "pointer",
2959
- color: updateCheck.phase === "checking" ? "#5a6375" : "#9db8ff",
4424
+ color: updateCheck.phase === "checking" ? "#5a6375" : accentSoft,
2960
4425
  fontSize: 12,
2961
4426
  alignSelf: "flex-start"
2962
4427
  },
@@ -2967,14 +4432,14 @@ window.__ModuleLoader__.load({
2967
4432
  }),
2968
4433
  listOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneListPanel, {
2969
4434
  panelTop: railBox.top,
2970
- panelRight: railBox.right + DOT_HIT + 8,
4435
+ panelRight: panelRightFor(PANEL_WIDTH_STANDARD),
2971
4436
  marks,
2972
4437
  onJump: jump,
2973
4438
  t
2974
4439
  }),
2975
4440
  crossOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneSessionSearch, {
2976
4441
  panelTop: railBox.top,
2977
- panelRight: railBox.right + DOT_HIT + 8,
4442
+ panelRight: panelRightFor(PANEL_WIDTH_STANDARD),
2978
4443
  onClose: () => setCrossOpen(false),
2979
4444
  searchSessions,
2980
4445
  openSession,
@@ -2994,12 +4459,12 @@ window.__ModuleLoader__.load({
2994
4459
  display: "flex",
2995
4460
  flexDirection: "column",
2996
4461
  alignItems: "center",
2997
- gap: DOT_GAP,
4462
+ gap,
2998
4463
  padding: "6px 0",
2999
4464
  scrollbarWidth: "none"
3000
4465
  },
3001
4466
  children: render.items.map((item, i) => {
3002
- const showSeparator = separatorIndices.has(i);
4467
+ const showGroupGap = separatorIndices.has(i) && i > 0;
3003
4468
  const mark = displayMarks[item.displayIndex];
3004
4469
  const summaryCount = collapsedSummaries.get(mark.key);
3005
4470
  const bookmarked = isBookmarked(bookmarkedKeys, mark.key);
@@ -3011,7 +4476,7 @@ window.__ModuleLoader__.load({
3011
4476
  isCurrent: !hasQuery && mark.key === currentKey
3012
4477
  });
3013
4478
  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";
4479
+ 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
4480
  const badge = deriveBadge({
3016
4481
  nodeKinds: mark.turn === void 0 ? NO_KINDS : kindsByTurn.get(mark.turn) ?? NO_KINDS,
3017
4482
  lastMark: item.displayIndex === displayMarks.length - 1,
@@ -3019,21 +4484,11 @@ window.__ModuleLoader__.load({
3019
4484
  awaitingInput
3020
4485
  });
3021
4486
  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", {
4487
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react.Fragment, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3033
4488
  type: "button",
3034
4489
  style: {
3035
- width: DOT_HIT,
3036
- height: DOT_HIT,
4490
+ width: hit,
4491
+ height: hit,
3037
4492
  flexShrink: 0,
3038
4493
  display: "flex",
3039
4494
  alignItems: "center",
@@ -3041,7 +4496,8 @@ window.__ModuleLoader__.load({
3041
4496
  background: "transparent",
3042
4497
  border: "none",
3043
4498
  padding: 0,
3044
- cursor: "pointer"
4499
+ cursor: "pointer",
4500
+ marginTop: showGroupGap ? GROUP_GAP_EXTRA : 0
3045
4501
  },
3046
4502
  onMouseEnter: (e) => {
3047
4503
  const rect = e.currentTarget.getBoundingClientRect();
@@ -3052,6 +4508,8 @@ window.__ModuleLoader__.load({
3052
4508
  },
3053
4509
  onClick: () => jump(mark.key),
3054
4510
  "data-rail-dot": true,
4511
+ "data-turn-gap": showGroupGap ? "true" : void 0,
4512
+ "data-turn": showGroupGap && mark.turn !== void 0 ? mark.turn : void 0,
3055
4513
  "data-collapsed-summary": summaryCount !== void 0 ? "true" : void 0,
3056
4514
  "data-collapsed-count": summaryCount,
3057
4515
  tabIndex: focusIndex === i ? 0 : -1,
@@ -3063,10 +4521,10 @@ window.__ModuleLoader__.load({
3063
4521
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3064
4522
  style: {
3065
4523
  position: "relative",
3066
- width: DOT_SIZE,
3067
- height: DOT_SIZE,
4524
+ width: size,
4525
+ height: size,
3068
4526
  borderRadius: "50%",
3069
- background: dotColor(item.displayIndex, marks.length),
4527
+ background: dotColor(item.displayIndex, marks.length, accent),
3070
4528
  boxShadow,
3071
4529
  transition: "transform 120ms ease, opacity 120ms ease",
3072
4530
  transform: `scale(${isHovered ? 1.35 : dotState === "active" || dotState === "current" ? 1.25 : 1})`,
@@ -3079,18 +4537,18 @@ window.__ModuleLoader__.load({
3079
4537
  position: "absolute",
3080
4538
  inset: -3,
3081
4539
  borderRadius: "50%",
3082
- border: `2px solid ${ringStyle.color}`,
4540
+ boxShadow: ringStyle.shadow,
3083
4541
  color: ringStyle.color,
3084
4542
  pointerEvents: "none",
3085
- animation: ringStyle.pulse ? "milestone-badge-pulse 1.4s ease-out infinite" : void 0
4543
+ animation: ringStyle.pulse ? "milestone-badge-pulse 2s ease-in-out infinite" : void 0
3086
4544
  }
3087
4545
  })
3088
4546
  })
3089
- })] }, mark.key);
4547
+ }) }, mark.key);
3090
4548
  })
3091
4549
  }),
3092
4550
  hover !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneRailTooltip, {
3093
- panelRight: railBox.right + DOT_HIT + 8,
4551
+ panelRight: panelRightFor(TOOLTIP_ANCHOR_WIDTH),
3094
4552
  hover,
3095
4553
  bookmarked: isBookmarked(bookmarkedKeys, hover.mark.key),
3096
4554
  onToggleBookmark: () => onToggleBookmark(hover.mark.key),
@@ -3109,8 +4567,13 @@ window.__ModuleLoader__.load({
3109
4567
  style: {
3110
4568
  position: "absolute",
3111
4569
  bottom: 6,
3112
- right: "100%",
3113
- marginRight: 8,
4570
+ ...side === "left" ? {
4571
+ left: "100%",
4572
+ marginLeft: 8
4573
+ } : {
4574
+ right: "100%",
4575
+ marginRight: 8
4576
+ },
3114
4577
  whiteSpace: "nowrap",
3115
4578
  fontSize: 12,
3116
4579
  lineHeight: 1,
@@ -3233,213 +4696,6 @@ window.__ModuleLoader__.load({
3233
4696
  });
3234
4697
  }
3235
4698
  //#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
4699
  //#region src/client/index.ts
3444
4700
  /** Required services (cordis fiber inject). */
3445
4701
  const inject = [