@luziyang2026/dsh-question-nav 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,13 +46,19 @@ Package: **`@luziyang2026/dsh-question-nav`** ([npm][npm] · [GitHub][github]).
46
46
  stacked top-to-bottom without overlap and centered on the selected dot. Each
47
47
  card shows the turn's **full question text** (all of them, when one turn
48
48
  batched several) and the question's **sent time** (`HH:MM` on the same day,
49
- `MM-DD HH:MM` across days, `YYYY-MM-DD HH:MM` across years). The **center
50
- card is the brightest** and marked with a brand edge; cards further from it
51
- are slightly narrower, dimmer and show fewer lines. **Clicking any card
52
- jumps to that question** exactly like clicking its dot.
49
+ `MM-DD HH:MM` across days, `YYYY-MM-DD HH:MM` across years). The hierarchy
50
+ is two-tier: the **center card is the focus** full-contrast text, a brand
51
+ accent bar and a raised shadow while the four neighbors are clean
52
+ **context cards** (same surface, narrower, fewer lines) that light up on
53
+ hover to show they are clickable too. **Clicking any card jumps to that
54
+ question** exactly like clicking its dot.
53
55
  - **Click**: jumps to that question. Only then does the jump loop page the
54
56
  window (`loadOlder()`) to bring that specific page into view — never the
55
57
  whole history up front.
58
+ - **Elegant overflow scroll**: when many dots don't fit the 60% band, the
59
+ native scrollbar is hidden and the column fades out at its top and bottom
60
+ edges; hovering either fade zone gently auto-scrolls so hidden dots flow
61
+ into the clear, clickable area (wheel and trackpad still scroll too).
56
62
  - Empty/left areas of the rail pass pointer events through to the conversation
57
63
  (it never blocks the chat).
58
64
 
package/README.zh.md CHANGED
@@ -37,11 +37,16 @@
37
37
  (portal 渲染,无原生 `title` 延迟)——窗口内每个圆点一张卡片,上下排布、
38
38
  **互不遮挡**,以选中点为中心垂直居中。每张卡片显示该轮提问**全文**(一轮
39
39
  多条提问时全部列出)与提问的**发出时间**(当天 `HH:MM`,跨天
40
- `MM-DD HH:MM`,跨年 `YYYY-MM-DD HH:MM`)。**中心卡片最亮**并带品牌色描边,
41
- 离中心越远的卡片稍窄、稍暗、行数更少;**点击任意卡片即跳转到对应提问**,
42
- 与点击圆点效果一致。
40
+ `MM-DD HH:MM`,跨年 `YYYY-MM-DD HH:MM`)。层级采用**两档聚焦**:
41
+ **中心卡片是焦点**——全对比度文字、品牌色左 accent bar、浮起投影;上下
42
+ 四张是干净的**上下文卡**(同背景、更窄、行数更少),hover 时会亮起、
43
+ 明示它们同样可以点击。**点击任意卡片即跳转到对应提问**,与点击圆点
44
+ 效果一致。
43
45
  - **点击**:跳转到该提问;只有这时跳转循环才 `loadOlder()` 扩窗,把**那一页**
44
46
  带进视图——绝不会一次性展开整个历史。
47
+ - **优雅的溢出滚动**:圆点太多超出 60% 限高时,原生滚动条被隐藏,点列在
48
+ 上下边缘渐隐;鼠标悬停任一渐隐区会轻柔自动滚动,把藏起来的圆点流入
49
+ 清晰可点的区域(滚轮/触控板同样可滚)。
45
50
  - 圆点列空白区域**点穿**到对话内容,不会挡住聊天。
46
51
 
47
52
  ## 环境要求
package/lib/client.js CHANGED
@@ -126,23 +126,29 @@ window.__ModuleLoader__.load({
126
126
  case 0: return {
127
127
  widthPx: 380,
128
128
  fontSize: 13,
129
- maxLines: 6,
130
- brightness: 1
129
+ maxLines: 6
131
130
  };
132
131
  case 1: return {
133
132
  widthPx: 300,
134
133
  fontSize: 12.5,
135
- maxLines: 2,
136
- brightness: .82
134
+ maxLines: 2
137
135
  };
138
136
  default: return {
139
137
  widthPx: 240,
140
138
  fontSize: 12,
141
- maxLines: 1,
142
- brightness: .68
139
+ maxLines: 1
143
140
  };
144
141
  }
145
142
  }
143
+ /**
144
+ * Auto-scroll speed (px/s) for a pointer at `ratio` depth into an edge fade
145
+ * zone (0 = at the zone boundary, 1 = at the band's very edge): eases
146
+ * linearly from MIN to MAX so a shallow probe scrolls gently and pushing
147
+ * into the edge moves fast. The sign (direction) is applied by the caller.
148
+ */
149
+ function edgeScrollSpeed(ratio) {
150
+ return 90 + 330 * Math.max(0, Math.min(1, ratio));
151
+ }
146
152
  //#endregion
147
153
  //#region src/core/time.ts
148
154
  /**
@@ -181,7 +187,7 @@ window.__ModuleLoader__.load({
181
187
  }
182
188
  //#endregion
183
189
  //#region \0dsh-css:dsh-question-nav/src/client/question-nav.module.css.mjs
184
- const css = ".TWf_pa_rail{z-index:1;box-sizing:border-box;pointer-events:none;background:0 0;flex-direction:column;align-items:center;width:44px;display:flex;position:absolute;top:0;bottom:0;left:0}.TWf_pa_railRight{left:auto}.TWf_pa_list{scrollbar-width:thin;pointer-events:auto;flex-direction:column;flex:0 auto;align-items:center;gap:6px;width:100%;min-height:0;max-height:60%;margin:auto 0;padding:8px 0;display:flex;overflow:hidden auto}.TWf_pa_list>:first-child{margin-top:auto}.TWf_pa_list>:last-child{margin-bottom:auto}.TWf_pa_dot{pointer-events:auto;background:var(--dsw-alias-border-l3);cursor:pointer;border:none;border-radius:50%;flex:none;width:8px;height:8px;padding:0;transition:transform .12s,background .12s}.TWf_pa_dot:hover,.TWf_pa_dot.TWf_pa_focused,.TWf_pa_dot.TWf_pa_active{background:var(--dsw-alias-brand-primary)}.TWf_pa_count{color:var(--dsw-alias-label-tertiary);user-select:none;flex:none;font-size:10px;font-weight:600;line-height:1}.TWf_pa_dots{flex-direction:column;align-items:center;gap:6px;display:flex}.TWf_pa_empty{color:var(--dsw-alias-label-tertiary);text-align:center;word-break:break-word;padding:10px 4px;font-size:11px}.TWf_pa_hint{z-index:30;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);max-width:260px;color:var(--dsw-alias-label-secondary);pointer-events:none;border-radius:6px;padding:6px 10px;font-size:12px;line-height:16px;position:fixed;box-shadow:0 2px 10px #0000002e}.TWf_pa_cascade{z-index:20;pointer-events:none;flex-direction:column;align-items:flex-start;gap:6px;display:flex;position:fixed}.TWf_pa_card{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);width:380px;color:var(--dsw-alias-label-primary);white-space:normal;word-break:break-word;pointer-events:auto;cursor:pointer;border-radius:10px;padding:8px 12px 10px;font-size:13px;line-height:18px;transition:border-color .12s,box-shadow .12s;box-shadow:0 4px 14px #0000002e}.TWf_pa_card:hover{border-color:var(--dsw-alias-brand-primary);box-shadow:0 4px 16px #00000042}.TWf_pa_cardSelected{border-color:var(--dsw-alias-brand-primary)}.TWf_pa_cardTitle{color:var(--dsw-alias-label-tertiary);margin-bottom:4px;font-size:11px;font-weight:600}.TWf_pa_cardBody{scrollbar-width:thin;max-height:96px;overflow-y:auto}.TWf_pa_cardClamp{color:var(--dsw-alias-label-primary);word-break:break-word;-webkit-box-orient:vertical;font-size:13px;line-height:18px;display:-webkit-box;overflow:hidden}.TWf_pa_cardLine+.TWf_pa_cardLine{border-top:1px solid var(--dsw-alias-border-l1);margin-top:6px;padding-top:6px}.TWf_pa_cardTime{color:var(--dsw-alias-label-tertiary);margin-top:6px;font-size:11px}.TWf_pa_settings{flex-direction:column;gap:8px;padding:4px 0;display:flex}.TWf_pa_settingsTitle{color:var(--dsw-alias-label-primary);margin:0;font-size:13px;font-weight:600}.TWf_pa_settingsDesc{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px;line-height:18px}.TWf_pa_segmented{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;align-self:flex-start;display:inline-flex;overflow:hidden}.TWf_pa_segment{color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:8px 16px;font-size:13px;line-height:1;transition:background .12s,color .12s}.TWf_pa_segment+.TWf_pa_segment{border-left:1px solid var(--dsw-alias-border-l1)}.TWf_pa_segment:hover{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.TWf_pa_segmentActive,.TWf_pa_segmentActive:hover{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-inverse)}";
190
+ const css = ".TWf_pa_rail{z-index:1;box-sizing:border-box;pointer-events:none;background:0 0;flex-direction:column;align-items:center;width:44px;display:flex;position:absolute;top:0;bottom:0;left:0}.TWf_pa_railRight{left:auto}.TWf_pa_list{scrollbar-width:none;pointer-events:auto;flex-direction:column;flex:0 auto;align-items:center;gap:6px;width:100%;min-height:0;max-height:60%;margin:auto 0;padding:8px 0;display:flex;overflow:hidden auto}.TWf_pa_list::-webkit-scrollbar{width:0;height:0;display:none}.TWf_pa_listScrollable{-webkit-mask-image:linear-gradient(#0000,#000 22px calc(100% - 22px),#0000);mask-image:linear-gradient(#0000,#000 22px calc(100% - 22px),#0000)}.TWf_pa_list>:first-child{margin-top:auto}.TWf_pa_list>:last-child{margin-bottom:auto}.TWf_pa_dot{pointer-events:auto;background:var(--dsw-alias-border-l3);cursor:pointer;border:none;border-radius:50%;flex:none;width:8px;height:8px;padding:0;transition:transform .12s,background .12s}.TWf_pa_dot:hover,.TWf_pa_dot.TWf_pa_focused,.TWf_pa_dot.TWf_pa_active{background:var(--dsw-alias-brand-primary)}.TWf_pa_count{color:var(--dsw-alias-label-tertiary);user-select:none;flex:none;font-size:10px;font-weight:600;line-height:1}.TWf_pa_dots{flex-direction:column;align-items:center;gap:6px;display:flex}.TWf_pa_empty{color:var(--dsw-alias-label-tertiary);text-align:center;word-break:break-word;padding:10px 4px;font-size:11px}.TWf_pa_hint{z-index:30;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);max-width:260px;color:var(--dsw-alias-label-secondary);pointer-events:none;border-radius:6px;padding:6px 10px;font-size:12px;line-height:16px;position:fixed;box-shadow:0 2px 10px #0000002e}.TWf_pa_cascade{z-index:20;pointer-events:none;flex-direction:column;align-items:flex-start;gap:6px;display:flex;position:fixed}.TWf_pa_card{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);width:380px;color:var(--dsw-alias-label-secondary);white-space:normal;word-break:break-word;pointer-events:auto;cursor:pointer;border-radius:10px;padding:8px 12px 10px;font-size:13px;line-height:18px;transition:border-color .12s,box-shadow .12s;box-shadow:0 1px 6px #0000001a}.TWf_pa_card:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary);box-shadow:0 3px 12px #0000002e}.TWf_pa_cardSelected{border-color:var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);box-shadow:inset 3px 0 0 0 var(--dsw-alias-brand-primary), 0 6px 20px #00000038}.TWf_pa_cardSelected:hover{border-color:var(--dsw-alias-brand-primary);box-shadow:inset 3px 0 0 0 var(--dsw-alias-brand-primary), 0 6px 22px #00000042}.TWf_pa_cardTitle{color:var(--dsw-alias-label-tertiary);margin-bottom:4px;font-size:11px;font-weight:600}.TWf_pa_cardBody{scrollbar-width:thin;max-height:96px;overflow-y:auto}.TWf_pa_cardClamp{word-break:break-word;-webkit-box-orient:vertical;font-size:13px;line-height:18px;display:-webkit-box;overflow:hidden}.TWf_pa_cardLine+.TWf_pa_cardLine{border-top:1px solid var(--dsw-alias-border-l1);margin-top:6px;padding-top:6px}.TWf_pa_cardTime{color:var(--dsw-alias-label-tertiary);margin-top:6px;font-size:11px}.TWf_pa_settings{flex-direction:column;gap:8px;padding:4px 0;display:flex}.TWf_pa_settingsTitle{color:var(--dsw-alias-label-primary);margin:0;font-size:13px;font-weight:600}.TWf_pa_settingsDesc{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px;line-height:18px}.TWf_pa_segmented{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;align-self:flex-start;display:inline-flex;overflow:hidden}.TWf_pa_segment{color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:8px 16px;font-size:13px;line-height:1;transition:background .12s,color .12s}.TWf_pa_segment+.TWf_pa_segment{border-left:1px solid var(--dsw-alias-border-l1)}.TWf_pa_segment:hover{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.TWf_pa_segmentActive,.TWf_pa_segmentActive:hover{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-inverse)}";
185
191
  const tagId = "@luziyang2026/dsh-question-nav/question-nav.module.css";
186
192
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
187
193
  const tag = document.createElement("style");
@@ -207,6 +213,7 @@ window.__ModuleLoader__.load({
207
213
  "focused": "TWf_pa_focused",
208
214
  "hint": "TWf_pa_hint",
209
215
  "list": "TWf_pa_list",
216
+ "listScrollable": "TWf_pa_listScrollable",
210
217
  "rail": "TWf_pa_rail",
211
218
  "railRight": "TWf_pa_railRight",
212
219
  "segment": "TWf_pa_segment",
@@ -228,11 +235,12 @@ window.__ModuleLoader__.load({
228
235
  * within that band when the session has more dots than fit.
229
236
  *
230
237
  * Hovering a dot "selects" it: the selected dot plus its two immediate
231
- * neighbors on each side magnify (progressively smaller with distance), the
232
- * selected dot reveals a frosted question card (turn label, full question
233
- * text, sent time) with a decorative stack of blurred cards fanning out
234
- * below it, and the rail auto-centers the selected dot. Clicking jumps the
235
- * chat to that turn's first question.
238
+ * neighbors on each side magnify (progressively smaller with distance), and a
239
+ * vertical cascade of question cards opens the selected (center) card is the
240
+ * focus (brand accent, elevated, full question text), the four neighbors are
241
+ * narrower context cards clamped to fewer lines. Every card is clickable and
242
+ * jumps to its question, exactly like clicking the dot; the rail auto-centers
243
+ * the selected dot.
236
244
  *
237
245
  * Data source: the host-folded `questionIndex` session projection (whole
238
246
  * history, persisted host-side, pushed live through session/projection
@@ -288,6 +296,52 @@ window.__ModuleLoader__.load({
288
296
  const clearFocusTimerRef = (0, react.useRef)(null);
289
297
  const lastDotsRef = (0, react.useRef)([]);
290
298
  const lastFocusedKeyRef = (0, react.useRef)(null);
299
+ const [scrollable, setScrollable] = (0, react.useState)(false);
300
+ const edgeScrollRef = (0, react.useRef)({
301
+ speed: 0,
302
+ raf: 0,
303
+ last: 0
304
+ });
305
+ const stopEdgeScroll = () => {
306
+ edgeScrollRef.current.speed = 0;
307
+ if (edgeScrollRef.current.raf !== 0) {
308
+ cancelAnimationFrame(edgeScrollRef.current.raf);
309
+ edgeScrollRef.current.raf = 0;
310
+ }
311
+ };
312
+ const edgeTick = (now) => {
313
+ const list = listRef.current;
314
+ const state = edgeScrollRef.current;
315
+ state.raf = 0;
316
+ if (list === null || state.speed === 0) return;
317
+ const dt = Math.min(64, now - state.last) / 1e3;
318
+ state.last = now;
319
+ list.scrollTop = list.scrollTop + state.speed * dt;
320
+ const atTop = list.scrollTop <= 0 && state.speed < 0;
321
+ const atBottom = list.scrollTop >= list.scrollHeight - list.clientHeight - 1 && state.speed > 0;
322
+ if (atTop || atBottom) {
323
+ state.speed = 0;
324
+ return;
325
+ }
326
+ state.raf = requestAnimationFrame(edgeTick);
327
+ };
328
+ const onListMouseMove = (e) => {
329
+ const list = listRef.current;
330
+ if (list === null) return;
331
+ const rect = list.getBoundingClientRect();
332
+ const y = e.clientY - rect.top;
333
+ const depthTop = 26 - y;
334
+ const depthBottom = y - (rect.height - 26);
335
+ let speed = 0;
336
+ if (depthTop > 0 && list.scrollTop > 0) speed = -edgeScrollSpeed(depthTop / 26);
337
+ else if (depthBottom > 0 && list.scrollTop < list.scrollHeight - list.clientHeight - 1) speed = edgeScrollSpeed(depthBottom / 26);
338
+ const state = edgeScrollRef.current;
339
+ state.speed = speed;
340
+ if (speed !== 0 && state.raf === 0) {
341
+ state.last = performance.now();
342
+ state.raf = requestAnimationFrame(edgeTick);
343
+ }
344
+ };
291
345
  const cancelClearFocus = () => {
292
346
  if (clearFocusTimerRef.current !== null) {
293
347
  window.clearTimeout(clearFocusTimerRef.current);
@@ -434,9 +488,20 @@ window.__ModuleLoader__.load({
434
488
  const center = target.offsetTop - list.offsetTop - (list.clientHeight - target.offsetHeight) / 2;
435
489
  list.scrollTop = Math.max(0, Math.min(center, list.scrollHeight - list.clientHeight));
436
490
  }, [focus]);
491
+ (0, react.useLayoutEffect)(() => {
492
+ const list = listRef.current;
493
+ if (list === null) return;
494
+ const check = () => setScrollable(list.scrollHeight > list.clientHeight + 1);
495
+ check();
496
+ if (typeof ResizeObserver === "undefined") return;
497
+ const observer = new ResizeObserver(check);
498
+ observer.observe(list);
499
+ return () => observer.disconnect();
500
+ }, [dots, align]);
437
501
  (0, react.useEffect)(() => () => {
438
502
  if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
439
503
  if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current);
504
+ stopEdgeScroll();
440
505
  }, []);
441
506
  if (!visible) return null;
442
507
  const onJump = (dot) => {
@@ -474,8 +539,12 @@ window.__ModuleLoader__.load({
474
539
  children: [
475
540
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
476
541
  ref: listRef,
477
- className: question_nav_module_css_default.list,
478
- onMouseLeave: scheduleClearFocus,
542
+ className: scrollable ? `${question_nav_module_css_default.list} ${question_nav_module_css_default.listScrollable}` : question_nav_module_css_default.list,
543
+ onMouseMove: scrollable ? onListMouseMove : void 0,
544
+ onMouseLeave: () => {
545
+ stopEdgeScroll();
546
+ scheduleClearFocus();
547
+ },
479
548
  children: dots.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
480
549
  className: question_nav_module_css_default.empty,
481
550
  children: t("strip.empty")
@@ -534,10 +603,7 @@ window.__ModuleLoader__.load({
534
603
  className: cls,
535
604
  role: "button",
536
605
  tabIndex: -1,
537
- style: {
538
- width: metrics.widthPx,
539
- filter: `brightness(${metrics.brightness})`
540
- },
606
+ style: { width: metrics.widthPx },
541
607
  onClick: () => onJump(item.dot),
542
608
  children: [
543
609
  isSelected && item.dot.turn !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
package/lib/client.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","names":["kind","useState","useRef","styles","createPortal","useState","styles"],"sources":["../src/core/turn-dots.ts","../src/core/focus.ts","../src/core/time.ts","../src/client/QuestionNavStrip.tsx","../src/core/align.ts","../src/client/QuestionNavSettingsTab.tsx","../src/client/settings.ts","../src/client/locales.ts","../src/core/nodes.ts","../src/core/jump.ts","../src/client/index.ts"],"sourcesContent":["/**\n * Turn-aligned dot model for the question-nav strip. Pure transforms over the\n * `questionIndex` projection value plus the live chat window — no React, no\n * DOM — so the grouping and merging rules are unit-testable in isolation.\n *\n * One dot per turn that claimed at least one user question; turns without a\n * question (retry, goal continuation, cancelled empty turns) produce no dot,\n * so dot turn labels may skip numbers but always match the Trajectory view.\n */\n\nimport type { QuestionEntry } from './question-entry.ts'\nimport type { QuestionNode } from './nodes.ts'\n\n/** The conversation Definition kind whose key a user question node uses. */\nexport const MESSAGE_DEFINITION_KIND = 'input-message'\n\n/**\n * The engine-owned stable chat key for a user question — mirrors\n * `conversationContextKey('input-message', String(id))` from the DSH runtime\n * (verified against that formula in the unit test).\n */\nexport function questionKey(id: unknown): string {\n const kind = MESSAGE_DEFINITION_KIND\n return `${kind.length}:${kind}${String(id)}`\n}\n\n/** One strip dot: a turn's questions (grouped) or one ungrouped live question. */\nexport interface TurnDot {\n /** Owning turn number; null for live questions the projection has not seen. */\n readonly turn: number | null\n /** Jump anchor: the chat key of the turn's FIRST question. */\n readonly key: string\n /** Anchor seq of the first question (ordering + jump target). */\n readonly anchorSeq: number\n /** Unix ms of the first question. */\n readonly time: number\n /** Every question text of this dot, in order (tooltip lists them all). */\n readonly texts: readonly string[]\n /** Chat keys of every question folded into this dot (live-merge dedupe). */\n readonly memberKeys: readonly string[]\n}\n\n/** True when entries are already in non-decreasing seq order (the projection\n * appends in event order, so this is the common case and skips the sort). */\nfunction isSortedBySeq(entries: readonly QuestionEntry[]): boolean {\n for (let i = 1; i < entries.length; i++) {\n if (entries[i].seq < entries[i - 1].seq) return false\n }\n return true\n}\n\n/**\n * Fold the projection's question list into one dot per turn. Entries arrive\n * in event order; consecutive same-turn entries merge into a single dot whose\n * anchor is the turn's first question. The input is expected to be sorted by\n * seq; the defensive sort is skipped when it already is, so a long session\n * never pays an O(n log n) sort on every content update.\n */\nexport function groupQuestionsByTurn(entries: readonly QuestionEntry[]): TurnDot[] {\n const sorted = isSortedBySeq(entries) ? entries : [...entries].sort((a, b) => a.seq - b.seq)\n const dots: TurnDot[] = []\n for (const entry of sorted) {\n const key = questionKey(entry.id)\n const last = dots.at(-1)\n if (last !== undefined && last.turn === entry.turn) {\n dots[dots.length - 1] = {\n ...last,\n texts: [...last.texts, entry.text],\n memberKeys: [...last.memberKeys, key],\n }\n continue\n }\n dots.push({\n turn: entry.turn,\n key,\n anchorSeq: entry.seq,\n time: entry.time,\n texts: [entry.text],\n memberKeys: [key],\n })\n }\n return dots\n}\n\n/**\n * Merge live-window questions the projection has not recorded yet (the brief\n * window before the session/projection push frame lands). A live question\n * whose key is already folded into a dot is dropped (the projected copy\n * wins); the rest become single-question dots with `turn: null`, inserted in\n * anchor-seq order so the strip stays strictly chronological.\n *\n * Fast path: when nothing new arrives the SAME array is returned (no copy),\n * so the caller can bail out of a re-render on identical reference.\n */\nexport function mergeLiveQuestions(\n dots: readonly TurnDot[],\n live: readonly QuestionNode[],\n): TurnDot[] {\n if (live.length === 0) return dots as TurnDot[]\n const known = new Set<string>()\n for (const dot of dots) {\n for (const key of dot.memberKeys) known.add(key)\n }\n const extras: TurnDot[] = []\n for (const question of live) {\n if (known.has(question.key)) continue\n extras.push({\n turn: null,\n key: question.key,\n anchorSeq: question.anchorSeq,\n time: question.time,\n texts: [question.text],\n memberKeys: [question.key],\n })\n }\n // Nothing new from the live window — reuse the input array unchanged.\n if (extras.length === 0) return dots as TurnDot[]\n // Both `dots` and `extras` are sorted by anchorSeq (dots from the projection\n // order, extras from the live window order): merge linearly instead of\n // re-sorting the whole list. Ties keep the projected dot first (stable\n // sort semantics), matching the previous [...dots, ...extras].sort().\n const out: TurnDot[] = []\n let i = 0\n for (const extra of extras) {\n while (i < dots.length && dots[i].anchorSeq <= extra.anchorSeq) {\n out.push(dots[i])\n i += 1\n }\n out.push(extra)\n }\n while (i < dots.length) {\n out.push(dots[i])\n i += 1\n }\n return out\n}\n","/**\n * Focus-magnification model for the question-nav rail. Pure index/scale math\n * for the hover-selected dot's neighborhood window, its progressive\n * magnification tiers, and the vertical cascade of question cards (one per\n * window dot, non-overlapping, sizes descending away from the selected one).\n * No React, no DOM — every function here is unit-testable in isolation and\n * safe to inline into the client bundle.\n *\n * @module dsh-question-nav/focus\n */\n\n/** How many dots stay enlarged on each side of the selected dot. */\nexport const FOCUS_RADIUS = 2\n\n/** Magnification scale per tier, by distance from the selected dot\n * (0 = selected, 1 = immediate neighbor, 2 = outer window edge). */\nexport const FOCUS_SCALES = [2.0, 1.55, 1.25] as const\n\n/**\n * The focus tier of a dot at `distance` from the selected dot: 0..FOCUS_RADIUS\n * while inside the magnification window, null beyond it (base scale).\n */\nexport function focusTier(distance: number): number | null {\n const d = Math.abs(distance)\n if (d > FOCUS_RADIUS) return null\n return d\n}\n\n/** Magnification scale for a dot at `distance`; 1 (base) outside the window. */\nexport function focusScale(distance: number): number {\n const tier = focusTier(distance)\n return tier === null ? 1 : FOCUS_SCALES[tier]\n}\n\n/** Presentation metrics for the question card of a dot at `distance` from the\n * selected one. Cards stay crisp (no blur): the selected card is the widest,\n * brightest and shows every text line; each of its two neighbors on each side\n * is a slightly narrower, dimmer card clamped to fewer lines. Null outside\n * the window (no card). */\nexport interface FocusCardMetrics {\n /** Card width in px (narrows away from the selected card). */\n widthPx: number\n /** Text font size (px); cascade cards are slightly smaller. */\n fontSize: number\n /** Max text lines before clamping (cascade cards). */\n maxLines: number\n /** Brightness (1 = full, the selected card; dimmer away from it). */\n brightness: number\n}\n\nexport function focusCardMetrics(distance: number): FocusCardMetrics | null {\n const tier = focusTier(distance)\n if (tier === null) return null\n switch (tier) {\n case 0:\n return { widthPx: 380, fontSize: 13, maxLines: 6, brightness: 1 }\n case 1:\n return { widthPx: 300, fontSize: 12.5, maxLines: 2, brightness: 0.82 }\n default:\n return { widthPx: 240, fontSize: 12, maxLines: 1, brightness: 0.68 }\n }\n}\n\n/**\n * Indices of the focus window: the selected dot plus `radius` on each side,\n * clipped to the dot list. Empty when the list is empty or `selected` is out\n * of range.\n */\nexport function magnificationWindow(total: number, selected: number, radius: number = FOCUS_RADIUS): number[] {\n if (total <= 0 || selected < 0 || selected >= total || radius < 0) return []\n const out: number[] = []\n const lo = Math.max(0, selected - radius)\n const hi = Math.min(total - 1, selected + radius)\n for (let i = lo; i <= hi; i++) out.push(i)\n return out\n}\n","/**\n * Question sent-time formatting for the question-nav surface. Pure: no DSH\n * imports, deterministic for a given (time, now) pair — unit-testable in\n * isolation and safe to inline into the client bundle.\n *\n * @module dsh-question-nav/time\n */\n\nfunction pad2(n: number): string {\n return n < 10 ? `0${n}` : String(n)\n}\n\n/** Whether `time` falls on the same calendar day as `now`. */\nexport function isSameDay(time: number, now: number): boolean {\n const a = new Date(time)\n const b = new Date(now)\n return a.getFullYear() === b.getFullYear()\n && a.getMonth() === b.getMonth()\n && a.getDate() === b.getDate()\n}\n\n/**\n * Smart question sent-time, relative to `now`:\n * - same calendar day → `HH:MM`\n * - same calendar year → `MM-DD HH:MM`\n * - otherwise → `YYYY-MM-DD HH:MM`\n *\n * Returns `''` for a missing/invalid timestamp (e.g. a live node that never\n * reported one), so callers can hide the time line without branching.\n */\nexport function formatQuestionTime(time: number, now: number): string {\n if (!Number.isFinite(time) || time <= 0) return ''\n const d = new Date(time)\n const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`\n if (isSameDay(time, now)) return hm\n const md = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`\n if (d.getFullYear() === new Date(now).getFullYear()) return `${md} ${hm}`\n return `${d.getFullYear()}-${md} ${hm}`\n}\n","/**\n * Question-nav minimap. Renders a vertical column of small round dots overlaid\n * on the LEFT edge of the conversation column (via the frame-wide\n * `shell.overlay` floating layer): one dot per turn that claimed at least one\n * user question — strictly aligned with the Trajectory view's turn numbering\n * (turns without a question produce no dot). The dot column is vertically\n * centered and clamped to at most 60% of the conversation height, scrolling\n * within that band when the session has more dots than fit.\n *\n * Hovering a dot \"selects\" it: the selected dot plus its two immediate\n * neighbors on each side magnify (progressively smaller with distance), the\n * selected dot reveals a frosted question card (turn label, full question\n * text, sent time) with a decorative stack of blurred cards fanning out\n * below it, and the rail auto-centers the selected dot. Clicking jumps the\n * chat to that turn's first question.\n *\n * Data source: the host-folded `questionIndex` session projection (whole\n * history, persisted host-side, pushed live through session/projection\n * frames) read through the injected `questionProjection` face, plus the live\n * chat window's questions merged on top for the brief window before a\n * just-sent question lands in the projection. No render-window expansion, no\n * client-side history paging.\n *\n * Data arrives through the props shares: the framework `useSessions` hook\n * (current session), the registrant inject face (read/subscribe/project/\n * jump), and the bound locale translator.\n */\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react'\nimport { createPortal } from 'react-dom'\nimport type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls the ui-layout SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\nimport type { QuestionNode } from '../core/nodes.ts'\nimport type { QuestionEntry } from '../core/question-entry.ts'\nimport { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'\nimport type { AlignPreference } from '../core/align.ts'\nimport type { JumpFailureCode } from '../core/jump.ts'\nimport { FOCUS_RADIUS, focusCardMetrics, focusScale, focusTier } from '../core/focus.ts'\nimport { formatQuestionTime } from '../core/time.ts'\nimport type { QuestionNavKey } from './locales.ts'\nimport styles from './question-nav.module.css'\n\n/** Minimal observable shape of a session projection face. */\nexport interface ObservableFace {\n /** Current projection value (unknown — validated structurally at read). */\n getSnapshot: () => unknown\n /** Subscribe to value changes; returns an unsubscribe. */\n subscribe: (listener: () => void) => () => void\n}\n\n/** Values the registrant inject face supplies (wired in src/client/index.ts). */\nexport interface QuestionNavInjected {\n /** Extract the user questions of a session's currently loaded window. */\n readQuestions: (sessionId: SessionId) => QuestionNode[]\n /** Subscribe to the session list; returns an unsubscribe. */\n subscribeList: (cb: () => void) => () => void\n /** Subscribe to a session's content; returns an unsubscribe. */\n subscribeContent: (sessionId: SessionId, cb: () => void) => () => void\n /** The session's `questionIndex` projection face, when the host unit is registered. */\n questionProjection: (sessionId: SessionId) => ObservableFace | undefined\n /** Jump the chat to a question row (pages the window on demand). */\n jump: (sessionId: SessionId, key: string) => void\n /** Current rail anchor edge (defaults to 'left' before the settings section is ready). */\n align: () => AlignPreference\n /** Observe anchor-edge changes; returns an unsubscribe. */\n subscribeAlign: (cb: () => void) => () => void\n /** Persist a new anchor edge. */\n setAlign: (align: AlignPreference) => void\n}\n\ntype ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>\n\nconst FAILURE_HINTS: Record<JumpFailureCode, QuestionNavKey> = {\n VIEW_INACTIVE: 'jump.inactive',\n TARGET_HIDDEN: 'jump.hidden',\n NOT_FOUND: 'jump.notfound',\n TIMEOUT: 'jump.timeout',\n}\n\n/** One card of the focus cascade: a dot inside the magnification window. */\ninterface FocusWindowItem {\n dot: TurnDot\n /** Distance from the selected dot (0 = selected, 1/2 = cascade neighbors). */\n distance: number\n}\n\n/** The focus cascade: a vertical column of one card per window dot,\n * non-overlapping, centered on the selected dot. */\ninterface FocusState {\n /** The selected (hovered) dot's key. */\n key: string\n /** Every dot of the focus window, in dot order. */\n items: FocusWindowItem[]\n /** Vertical center (viewport px) of the selected dot — the column centers on it. */\n centerY: number\n /** Horizontal anchor — left edge (left-aligned rail) or right edge (right-aligned). */\n left?: number\n right?: number\n}\n\n/** Read the projection face value as a question-entry list (structural guard). */\nfunction projectionEntries(face: ObservableFace | undefined): QuestionEntry[] {\n const value = face?.getSnapshot()\n if (!Array.isArray(value)) return []\n return value.filter((item): item is QuestionEntry =>\n typeof item === 'object' && item !== null\n && typeof (item as QuestionEntry).id === 'string'\n && typeof (item as QuestionEntry).seq === 'number'\n && typeof (item as QuestionEntry).turn === 'number')\n}\n\nfunction findConvRoot(): HTMLElement | null {\n return document.querySelector<HTMLElement>('[data-slot=\"conversation\"] > div[data-phase]')\n}\n\n/** Structural equality of two dot lists (member keys fully capture a dot's\n * folded questions, so identical key sequences mean identical content).\n * Lets the strip skip a re-render when a refresh produced no change. */\nfunction sameDots(a: readonly TurnDot[], b: readonly TurnDot[]): boolean {\n if (a.length !== b.length) return false\n for (let i = 0; i < a.length; i++) {\n const da = a[i]\n const db = b[i]\n if (da.key !== db.key || da.memberKeys.length !== db.memberKeys.length) return false\n for (let j = 0; j < da.memberKeys.length; j++) {\n if (da.memberKeys[j] !== db.memberKeys[j]) return false\n }\n }\n return true\n}\n\nexport function QuestionNavStrip(props: ComponentProps): React.JSX.Element | null {\n const current = props.useSessions((s) => s.current)\n const summary = props.useSessions((s) => (s.current === undefined ? undefined : s.byId[s.current]))\n const visible = current !== undefined && summary !== undefined && summary.blank !== true\n\n const [dots, setDots] = useState<TurnDot[]>([])\n const [jumpingKey, setJumpingKey] = useState<string | null>(null)\n const [hint, setHint] = useState<string | null>(null)\n const [focus, setFocus] = useState<FocusState | null>(null)\n const [align, setAlign] = useState<AlignPreference>(() => props.align())\n const panelRef = useRef<HTMLDivElement | null>(null)\n const listRef = useRef<HTMLDivElement | null>(null)\n const hintTimerRef = useRef<number | null>(null)\n // Pending focus-clear timer: lets the mouse move from the rail onto a\n // cascade card without dropping the cascade mid-flight.\n const clearFocusTimerRef = useRef<number | null>(null)\n // Last rendered dot list, for the change-detection bail-out below.\n const lastDotsRef = useRef<TurnDot[]>([])\n // Last focused dot key, so re-hovering the same dot after a gap re-centers it.\n const lastFocusedKeyRef = useRef<string | null>(null)\n\n // Focus persists briefly after leaving the rail, so the mouse can reach the\n // clickable cascade cards; entering a card cancels the clear, leaving\n // everything re-arms it.\n const cancelClearFocus = (): void => {\n if (clearFocusTimerRef.current !== null) {\n window.clearTimeout(clearFocusTimerRef.current)\n clearFocusTimerRef.current = null\n }\n }\n const scheduleClearFocus = (): void => {\n cancelClearFocus()\n clearFocusTimerRef.current = window.setTimeout(() => setFocus(null), 240)\n }\n\n // Follow the rail anchor edge from the settings scope (a change re-anchors\n // the rail through the layout effect below).\n useEffect(() => props.subscribeAlign(() => setAlign(props.align())), [props])\n\n const showHint = (message: string): void => {\n setHint(message)\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n hintTimerRef.current = window.setTimeout(() => setHint(null), 1800)\n }\n\n // Recompute the dot list from the projection + live window. Subscribed to\n // the projection push frames and the session's content only — session\n // switches are covered by `current` below (the effect re-runs on change),\n // so the session-list feed is not subscribed: it would re-run the full\n // recompute for unrelated list churn.\n useEffect(() => {\n if (!visible || current === undefined) {\n setDots([])\n lastDotsRef.current = []\n return\n }\n const sessionId = current\n const face = props.questionProjection(sessionId)\n const refresh = (): void => {\n const grouped = groupQuestionsByTurn(projectionEntries(face))\n const next = mergeLiveQuestions(grouped, props.readQuestions(sessionId))\n // Identical content (a streaming update that added no question): re-use\n // the previous array reference so React bails out of re-rendering the\n // strip — the common case during assistant streaming.\n if (sameDots(next, lastDotsRef.current)) {\n setDots(lastDotsRef.current)\n return\n }\n lastDotsRef.current = next\n setDots(next)\n }\n refresh()\n const unsubProjection = face?.subscribe(refresh) ?? (() => {})\n const unsubContent = props.subscribeContent(sessionId, refresh)\n return () => {\n unsubProjection()\n unsubContent()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [visible, current, props])\n\n // Listen for jump-failure events and surface the hint.\n useEffect(() => {\n const onJumpFailed = (event: Event): void => {\n const code = (event as CustomEvent<JumpFailureCode>).detail\n showHint(props.t(FAILURE_HINTS[code] ?? 'jump.timeout'))\n }\n window.addEventListener('question-nav:jump-failed', onJumpFailed)\n return () => window.removeEventListener('question-nav:jump-failed', onJumpFailed)\n }, [props])\n\n // Anchor the minimap to the conversation column: position it at the left\n // edge of the conversation root and reserve a thin rail with padding-left.\n //\n // A layout-correction loop keeps the rail pinned to the conversation even\n // when an outside panel (e.g. a browser-extension sidebar like Doubao) moves\n // or resizes the frame without resizing the browser window: `window resize`\n // never fires for in-page panels, and a ResizeObserver on the conversation\n // root alone misses remounts and the tail of the frame's grid-column\n // transition, which used to leave the rail drifting into the session list.\n // The loop runs while the layout is still moving, then parks itself after a\n // few stable frames; observers re-wake it on the next change.\n useLayoutEffect(() => {\n if (!visible) return\n let raf = 0\n let idle = 0\n let stopped = false\n let observer: ResizeObserver | null = null\n const observed = { frame: null as Element | null, convRoot: null as Element | null }\n\n const applyLayout = (): boolean => {\n const panel = panelRef.current\n if (panel === null) return false\n const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n // Keep looping while the anchors are not both present (conversation not\n // mounted yet / mid-reflow), so a late mount still aligns.\n if (frame === null || convRoot === null) return true\n // Keep observing the live nodes: the conversation root may remount\n // (e.g. after a panel-triggered reflow), which silently detaches an\n // earlier ResizeObserver target.\n if (observer !== null) {\n if (observed.frame !== frame) {\n observer.observe(frame, { box: 'border-box' })\n observed.frame = frame\n }\n if (observed.convRoot !== convRoot) {\n observer.observe(convRoot, { box: 'border-box' })\n observed.convRoot = convRoot\n }\n }\n const frameRect = frame.getBoundingClientRect()\n const convRect = convRoot.getBoundingClientRect()\n // Never snap onto a transient box: keep correcting until the\n // conversation has a real footprint again.\n if (convRect.height <= 0 || convRect.width <= 0) return true\n const top = `${convRect.top - frameRect.top}px`\n const height = `${convRect.height}px`\n // Anchor to the configured edge: keep the other edge's inline style\n // cleared so the CSS class (left: auto on .railRight) owns it.\n if (align === 'right') {\n const right = `${frameRect.right - convRect.right}px`\n if (panel.style.top === top && panel.style.height === height\n && panel.style.right === right && panel.style.left === '') {\n return false\n }\n panel.style.top = top\n panel.style.height = height\n panel.style.right = right\n panel.style.left = ''\n return true\n }\n const left = `${convRect.left - frameRect.left}px`\n if (panel.style.top === top && panel.style.height === height\n && panel.style.left === left && panel.style.right === '') {\n return false\n }\n panel.style.top = top\n panel.style.height = height\n panel.style.left = left\n panel.style.right = ''\n return true\n }\n\n const loop = (): void => {\n if (stopped) return\n raf = 0\n idle = applyLayout() ? 0 : idle + 1\n if (idle < 3) raf = requestAnimationFrame(loop)\n }\n const wake = (): void => {\n if (stopped) return\n idle = 0\n if (raf === 0) raf = requestAnimationFrame(loop)\n }\n\n observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(wake)\n if (observer !== null) {\n const panel = panelRef.current\n const frame = panel?.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n if (frame !== null) { observer.observe(frame, { box: 'border-box' }); observed.frame = frame }\n if (convRoot !== null) { observer.observe(convRoot, { box: 'border-box' }); observed.convRoot = convRoot }\n }\n\n // Initial alignment; the loop keeps correcting through layout transitions.\n wake()\n window.addEventListener('resize', wake)\n return () => {\n stopped = true\n if (raf !== 0) cancelAnimationFrame(raf)\n observer?.disconnect()\n window.removeEventListener('resize', wake)\n }\n }, [visible, align])\n\n // Keep the focused dot centered in the (≤60% tall, scrollable) band so its\n // two neighbors on each side stay visible. Re-centers only when the focused\n // key changes; scrolls instantly to avoid chasing a fast-moving hover.\n useLayoutEffect(() => {\n const key = focus?.key ?? null\n if (lastFocusedKeyRef.current === key) return\n lastFocusedKeyRef.current = key\n if (key === null) return\n const list = listRef.current\n if (list === null) return\n const target = list.querySelector<HTMLElement>('[data-question-nav-focused=\"true\"]')\n if (target === null) return\n const delta = target.offsetTop - list.offsetTop\n const center = delta - (list.clientHeight - target.offsetHeight) / 2\n list.scrollTop = Math.max(0, Math.min(center, list.scrollHeight - list.clientHeight))\n }, [focus])\n\n // Clear any pending timers on unmount.\n useEffect(() => () => {\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current)\n }, [])\n\n if (!visible) return null\n\n const onJump = (dot: TurnDot): void => {\n if (current === undefined) return\n setJumpingKey(dot.key)\n props.jump(current, dot.key)\n window.setTimeout(() => setJumpingKey((k) => (k === dot.key ? null : k)), 600)\n }\n\n const openFocus = (dot: TurnDot, target: HTMLElement): void => {\n const index = dots.findIndex((d) => d.key === dot.key)\n if (index < 0) return\n cancelClearFocus()\n const r = target.getBoundingClientRect()\n // The cascade opens away from the rail: to the right of the dots on a\n // left-anchored rail, to the left on a right-anchored one. The column is\n // vertically centered on the selected dot (top: centerY + translateY(-50%)).\n const horizontal = align === 'right'\n ? { right: window.innerWidth - r.left + 10 }\n : { left: r.right + 10 }\n const lo = Math.max(0, index - FOCUS_RADIUS)\n const hi = Math.min(dots.length - 1, index + FOCUS_RADIUS)\n const items: FocusWindowItem[] = []\n for (let i = lo; i <= hi; i++) {\n items.push({ dot: dots[i], distance: Math.abs(i - index) })\n }\n setFocus({ key: dot.key, items, centerY: r.top + r.height / 2, ...horizontal })\n }\n\n const t = props.t\n const selectedIndex = focus === null ? -1 : dots.findIndex((d) => d.key === focus.key)\n\n return (\n <div\n ref={panelRef}\n className={align === 'right' ? `${styles.rail} ${styles.railRight}` : styles.rail}\n data-question-nav=\"rail\"\n >\n <div\n ref={listRef}\n className={styles.list}\n onMouseLeave={scheduleClearFocus}\n >\n {dots.length === 0 ? (\n <div className={styles.empty}>{t('strip.empty')}</div>\n ) : (\n <div className={styles.dots}>\n <span className={styles.count}>{dots.length}</span>\n {dots.map((dot, index) => {\n const isFocused = focus !== null && focus.key === dot.key\n // Progressive magnification: selected largest, ±1 smaller, ±2\n // smaller still, the rest base scale.\n const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex)\n const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1\n const cls = [styles.dot]\n if (isFocused) cls.push(styles.focused)\n if (jumpingKey === dot.key) cls.push(styles.active)\n return (\n <button\n key={dot.key}\n className={cls.join(' ')}\n style={{ transform: `scale(${scale})` }}\n data-question-nav-focused={isFocused ? 'true' : undefined}\n data-question-nav-index={index}\n aria-label={dot.texts[0] ?? ''}\n onMouseEnter={(e) => openFocus(dot, e.currentTarget)}\n onClick={() => onJump(dot)}\n />\n )\n })}\n </div>\n )}\n </div>\n {hint !== null\n ? createPortal(\n <div\n className={styles.hint}\n style={align === 'right' ? { right: 60, top: 20 } : { left: 60, top: 20 }}\n >\n {hint}\n </div>,\n document.body,\n )\n : null}\n {focus !== null\n ? createPortal(\n /* The vertical cascade: one crisp card per window dot, stacked\n non-overlapping, vertically centered on the selected dot.\n Hovering the cascade keeps it alive (the rail only re-arms its\n clear once the mouse leaves both). */\n <div\n className={styles.cascade}\n style={{\n ...(focus.left !== undefined ? { left: focus.left } : {}),\n ...(focus.right !== undefined ? { right: focus.right } : {}),\n top: focus.centerY,\n transform: 'translateY(-50%)',\n }}\n onMouseEnter={cancelClearFocus}\n onMouseLeave={scheduleClearFocus}\n >\n {focus.items.map((item) => {\n const metrics = focusCardMetrics(item.distance)\n if (metrics === null) return null\n const isSelected = item.distance === 0\n // Cascade cards join their dot's texts into one clamped line;\n // the selected card keeps every line. Every card shows the\n // question's sent time, and clicking any card jumps to its\n // question exactly like clicking the dot.\n const text = item.dot.texts.join(' · ')\n const cls = isSelected ? `${styles.card} ${styles.cardSelected}` : styles.card\n return (\n <div\n key={item.dot.key}\n className={cls}\n role=\"button\"\n tabIndex={-1}\n style={{\n width: metrics.widthPx,\n filter: `brightness(${metrics.brightness})`,\n }}\n onClick={() => onJump(item.dot)}\n >\n {isSelected && item.dot.turn !== null ? (\n <div className={styles.cardTitle}>Turn {item.dot.turn}</div>\n ) : null}\n <div\n className={isSelected ? styles.cardBody : styles.cardClamp}\n style={\n isSelected\n ? undefined\n : ({ WebkitLineClamp: metrics.maxLines, fontSize: metrics.fontSize } as React.CSSProperties)\n }\n >\n {isSelected\n ? item.dot.texts.map((line, lineIndex) => (\n <div key={lineIndex} className={styles.cardLine}>{line}</div>\n ))\n : text}\n </div>\n <div className={styles.cardTime}>{formatQuestionTime(item.dot.time, Date.now())}</div>\n </div>\n )\n })}\n </div>,\n document.body,\n )\n : null}\n </div>\n )\n}\n","/**\n * Rail anchor-edge constants shared by the host schema and the browser\n * settings scope. Pure data: no DSH imports, so the client bundle may inline\n * this module (a Host import here would leak into the browser half).\n *\n * @module dsh-question-nav/align\n */\n\n/** Supported rail anchor edges. */\nexport const ALIGN_OPTIONS = ['left', 'right'] as const\n\n/** Rail anchor edge preference. */\nexport type AlignPreference = typeof ALIGN_OPTIONS[number]\n\n/** Default anchor edge when the user-settings document has no override. */\nexport const DEFAULT_ALIGN: AlignPreference = 'left'\n\n/** Settings namespace owned by this plugin (spelled here rather than\n * imported: the client bundle must not depend on a Host package). */\nexport const QUESTION_NAV_SETTINGS_NS = 'question-nav'\n\n/** Field carrying the selected anchor edge. */\nexport const ALIGN_FIELD = 'align'\n","/**\n * The plugin's settings page inside the shell's Plugins section\n * (`settings.plugins.tab`): a segmented control choosing which edge of the\n * conversation column the dot rail anchors to. The choice is written to the\n * `question-nav` settings namespace (registered by the host half); the strip\n * re-anchors live when the snapshot changes.\n *\n * @module dsh-question-nav/client/settings-tab\n */\n\nimport { useEffect, useState } from 'react'\nimport type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\n// Type-only: pulls the settings shell's SlotMap merge ('settings.plugins.tab').\nimport type {} from '@deepseek-ai/dsh-client-ui-settings/client'\nimport { ALIGN_OPTIONS } from '../core/align.ts'\nimport type { QuestionNavInjected } from './QuestionNavStrip.tsx'\nimport type { QuestionNavKey } from './locales.ts'\nimport styles from './question-nav.module.css'\n\ntype ComponentProps = PropsRuntime<'settings.plugins.tab'> & QuestionNavInjected & PropsLocale<'question-nav'>\n\n/** Re-render on settings snapshot changes (the register inject face is static). */\nfunction useAlignTick(subscribe: QuestionNavInjected['subscribeAlign']): void {\n const [, bump] = useState(0)\n useEffect(() => subscribe(() => bump((n) => n + 1)), [subscribe])\n}\n\nexport function QuestionNavSettingsTab(props: ComponentProps): React.JSX.Element | null {\n useAlignTick(props.subscribeAlign)\n const align = props.align()\n const t = props.t\n\n return (\n <div className={styles.settings}>\n <p className={styles.settingsTitle}>{t('settings.align.title')}</p>\n <p className={styles.settingsDesc}>{t('settings.align.desc')}</p>\n <div className={styles.segmented} role=\"radiogroup\" aria-label={t('settings.align.title')}>\n {ALIGN_OPTIONS.map((option) => (\n <button\n key={option}\n type=\"button\"\n role=\"radio\"\n aria-checked={align === option}\n className={align === option ? `${styles.segment} ${styles.segmentActive}` : styles.segment}\n onClick={() => props.setAlign(option)}\n >\n {t(option === 'left' ? 'settings.align.left' : 'settings.align.right')}\n </button>\n ))}\n </div>\n </div>\n )\n}\n","/**\n * Browser mirror of the `question-nav` settings namespace: reads the rail\n * anchor edge from the settings scope (`ctx.settingsScope.bind`) and routes\n * the user's choice back through `scope.set`. The namespace itself is\n * registered by the host half (src/settings.ts).\n *\n * The settings surface is optional and may apply after this plugin, so the\n * controller starts unbound and degrades to the default alignment until\n * {@link attach} binds the scope (called from a fiber that injects\n * `settingsScope`). The plugin keeps working everywhere it already did.\n *\n * @module dsh-question-nav/client/settings\n */\n\nimport type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'\nimport {\n ALIGN_FIELD, ALIGN_OPTIONS, DEFAULT_ALIGN, QUESTION_NAV_SETTINGS_NS,\n type AlignPreference,\n} from '../core/align.ts'\n\n/** Narrow a raw section to the anchor field. */\nfunction isAlignPreference(value: unknown): value is AlignPreference {\n return ALIGN_OPTIONS.some((option) => option === value)\n}\n\n/** The minimal face of the settings scope service this controller needs.\n * Kept structural (bind only) so the controller stays decoupled from the\n * full service and is unit-testable with a stub binder. */\nexport interface SettingsScopeBinderLike {\n bind<T>(spec: SettingsScopeSpec<T>): SettingsScope<T>\n}\n\n/** Wire section this plugin owns (the Host schema's `align` field). */\ninterface QuestionNavSection {\n align?: unknown\n}\n\n/** Snapshot consumed by the strip and the settings row. */\nexport interface QuestionNavSettingsState {\n /** Last accepted anchor edge (default while the scope is absent/loading). */\n align: AlignPreference\n /** Whether the user layer overrides the composition default. */\n overridden: boolean\n}\n\n/** Reactive handle over the plugin's durable settings section. */\nexport class QuestionNavSettingsController {\n private scope: SettingsScope<QuestionNavSection> | undefined\n private readonly listeners = new Set<() => void>()\n private unsubscribe: () => void = () => {}\n private state: QuestionNavSettingsState = { align: DEFAULT_ALIGN, overridden: false }\n\n /**\n * Bind the namespace scope once the settings surface is present. Called\n * from a fiber that injects `settingsScope`, so the scope subscription\n * lives on that fiber and is released with it. A no-op after the first\n * bind.\n * @param binder - the settings scope service.\n */\n attach(binder: SettingsScopeBinderLike): void {\n if (this.scope !== undefined) return\n this.scope = binder.bind<QuestionNavSection>({ namespace: QUESTION_NAV_SETTINGS_NS })\n this.state = this.derive(this.scope.getSnapshot())\n this.unsubscribe = this.scope.subscribe(() => {\n if (this.scope === undefined) return\n const next = this.derive(this.scope.getSnapshot())\n if (next.align === this.state.align && next.overridden === this.state.overridden) return\n this.state = next\n for (const listener of this.listeners) listener()\n })\n }\n\n private derive(snapshot: SettingsScopeSnapshot<QuestionNavSection>): QuestionNavSettingsState {\n const user = snapshot.user as { align?: unknown } | undefined\n return {\n align: snapshot.status === 'ready' && isAlignPreference(snapshot.value?.align)\n ? snapshot.value.align\n : DEFAULT_ALIGN,\n overridden: user !== undefined && user.align !== undefined,\n }\n }\n\n /** Release the scope subscription (bound on the settings fiber's lifecycle). */\n dispose(): void {\n this.unsubscribe()\n this.listeners.clear()\n }\n\n /** @returns the current state (stable reference until the next change). */\n getSnapshot(): QuestionNavSettingsState {\n return this.state\n }\n\n /** Observe state replacements; returns the disposer. */\n subscribe(listener: () => void): () => void {\n this.listeners.add(listener)\n return () => { this.listeners.delete(listener) }\n }\n\n /** Route the user's anchor-edge choice to the Host document. */\n setAlign(align: AlignPreference): void {\n if (this.scope === undefined) return\n void this.scope.set(ALIGN_FIELD, align)\n }\n}\n","/**\n * Locale dictionaries for the question-nav surface (zh/en). Registered under\n * the `question-nav` namespace; keys are consumed through the bound translator.\n */\nexport const zh = {\n 'strip.empty': '本会话还没有提问',\n 'jump.inactive': '聊天视图未激活',\n 'jump.hidden': '目标无独立气泡,已定位到邻近内容',\n 'jump.notfound': '目标未加载或不存在(可能已压缩)',\n 'jump.timeout': '加载历史超时,可重试',\n 'settings.tab': '提问导航',\n 'settings.align.title': '导航条对齐',\n 'settings.align.desc': '选择圆点导航条锚定在对话栏的哪一侧。',\n 'settings.align.left': '左侧',\n 'settings.align.right': '右侧',\n} as const\n\nexport const en = {\n 'strip.empty': 'No questions in this session yet',\n 'jump.inactive': 'Chat view is not active',\n 'jump.hidden': 'No dedicated bubble; landed on nearby content',\n 'jump.notfound': 'Target not loaded or missing (maybe compacted)',\n 'jump.timeout': 'Timed out loading history; retry',\n 'settings.tab': 'Question Nav',\n 'settings.align.title': 'Rail alignment',\n 'settings.align.desc': 'Choose which edge of the conversation column the dot rail anchors to.',\n 'settings.align.left': 'Left',\n 'settings.align.right': 'Right',\n} as const\n\nexport type QuestionNavKey = keyof typeof zh\n","/**\n * Pure node-indexing logic for the question-nav plugin. No React, no DOM, no\n * Cordis — every function here is a pure transform over chat-node data so it\n * can be unit-tested in isolation (and reused by the browser half).\n */\n\n/** One user question as shown in the strip and targeted by a jump. */\nexport interface QuestionNode {\n /** Chat anchor key, matches a `[data-chat-anchor-key]` row in the scrollport. */\n key: string\n /** Monotone anchor sequence for ordering and window-min detection. */\n anchorSeq: number\n /** Event seq of the user message. */\n seq: number\n /** Unix ms timestamp. */\n time: number\n /** Full question text — shown in the hover tooltip (not truncated). */\n text: string\n}\n\n/** Minimal shape a chat node must expose for indexing (structural, not SDK-bound). */\nexport interface ChatNodeLike {\n key: string\n anchorSeq: number\n visibility?: string\n kind?: string\n /** Kind-specific payload (a UserMessageNode for `user`/`steering`). */\n data?: unknown\n}\n\n/** Kinds counted as a user question (turn-opening and steering admissions). */\nexport const QUESTION_KINDS = ['user', 'steering'] as const\n\n/** Narrow `node.data` to the user-message payload we read. */\ninterface UserDataLike {\n content?: readonly { type?: string; text?: string }[]\n seq?: number\n time?: number\n}\n\nfunction userData(data: unknown): UserDataLike | undefined {\n if (typeof data !== 'object' || data === null) return undefined\n return data as UserDataLike\n}\n\n/** First text block of a user message; falls back to the raw first block. */\nexport function messageText(content: readonly { type?: string; text?: string }[] | undefined): string {\n if (content === undefined || content.length === 0) return ''\n const first = content[0]\n if (typeof first?.text === 'string') return first.text\n return ''\n}\n\n/** Extract the user questions from a chat-node window, ordered by anchorSeq. */\nexport function extractQuestions(nodes: Iterable<ChatNodeLike>): QuestionNode[] {\n const out: QuestionNode[] = []\n for (const node of nodes) {\n if (!QUESTION_KINDS.includes(node.kind as (typeof QUESTION_KINDS)[number])) continue\n const payload = userData(node.data)\n out.push({\n key: node.key,\n anchorSeq: node.anchorSeq,\n seq: payload?.seq ?? -1,\n time: payload?.time ?? 0,\n // Full question text: shown in the hover tooltip (not truncated).\n text: messageText(payload?.content),\n })\n }\n out.sort((a, b) => a.anchorSeq - b.anchorSeq)\n return out\n}\n\n/** Whether a node is actually rendered (visible rows only are scroll targets). */\nexport function isRenderable(node: ChatNodeLike): boolean {\n return node.visibility !== 'hidden'\n}\n\n/** The row of the window that renders the given key (exact match). */\nexport function findQuestionRow(nodes: Iterable<ChatNodeLike>, key: string): ChatNodeLike | null {\n for (const node of nodes) {\n if (node.key === key) return node\n }\n return null\n}\n\n/** Nearest renderable row for a key that is absent or hidden (compaction, windowing). */\nexport function nearestRenderable(\n nodes: Iterable<{ key: string; anchorSeq: number; visibility?: string }>,\n excludeKey: string | undefined,\n): { key: string; anchorSeq: number } | null {\n let best: { key: string; anchorSeq: number } | null = null\n for (const node of nodes) {\n if (node.visibility === 'hidden') continue\n if (node.key === excludeKey) continue\n if (best === null || node.anchorSeq < best.anchorSeq) best = { key: node.key, anchorSeq: node.anchorSeq }\n }\n return best\n}\n","/**\n * Jump-to-question orchestration. Pure-ish: takes injected ports (snapshot\n * read, loadOlder, DOM row lookup, view liveness, scroll, timers) so the\n * paging/timeout/fallback loop is unit-testable without a real browser or\n * session. The browser half wires these ports to ctx.sessions + the DOM.\n */\n\nimport { nearestRenderable } from './nodes.ts'\n\n/** The bits of a session snapshot the jump loop needs. */\nexport interface JumpSnapshot {\n openState: string\n hasMore: boolean\n loadingOlder: boolean\n /** Renderable chat rows as a key->renderable map (or iterable of rows). */\n rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>\n}\n\nexport interface JumpPorts {\n /** Read the current snapshot; undefined when the session/view is unavailable. */\n snapshot: () => JumpSnapshot | undefined\n /** Expand the window backwards; rejects/throws on failure. */\n loadOlder: () => Promise<void>\n /** True while the chat view is active (a `[data-chat-flow]` is mounted). */\n isViewActive: () => boolean\n /** Find the DOM row for a chat anchor key; null when not rendered. */\n findRow: (key: string) => HTMLElement | null\n /** Scroll a row into view at the top. */\n scrollIntoView: (row: HTMLElement) => void\n /** Monotonic ms clock. */\n now: () => number\n /** Async sleep. */\n sleep: (ms: number) => Promise<void>\n /** Report a terminal failure to the caller (for a hint). */\n report?: (code: JumpFailureCode, fallback?: boolean) => void\n}\n\nexport type JumpFailureCode = 'VIEW_INACTIVE' | 'TARGET_HIDDEN' | 'NOT_FOUND' | 'TIMEOUT'\n\nexport interface JumpResult {\n ok: boolean\n code?: JumpFailureCode\n /** True when we landed on a fallback row rather than the exact target. */\n fallback?: boolean\n}\n\nexport interface JumpOptions {\n /** Total wall-clock budget for loadOlder paging. */\n totalTimeoutMs?: number\n /** Max loadOlder pages before giving up. */\n maxPages?: number\n /** Poll interval for the row to render after it is known to be in the window. */\n rowWaitMs?: number\n /** Poll interval for state transitions (loadingOlder / openState). */\n pollMs?: number\n}\n\nconst DEFAULTS = {\n totalTimeoutMs: 15_000,\n maxPages: 100,\n rowWaitMs: 8_000,\n pollMs: 60,\n}\n\nfunction minAnchorSeq(rows: Iterable<{ anchorSeq: number }>): number | null {\n let min: number | null = null\n for (const row of rows) {\n if (min === null || row.anchorSeq < min) min = row.anchorSeq\n }\n return min\n}\n\nfunction renderable(rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>): { key: string; anchorSeq: number }[] {\n const out: { key: string; anchorSeq: number }[] = []\n for (const row of rows) {\n if (row.visibility === 'hidden') continue\n out.push({ key: row.key, anchorSeq: row.anchorSeq })\n }\n return out\n}\n\n/**\n * Jump to the row for `key`, paging older content until it is rendered (or the\n * budget is exhausted). Falls back to the nearest renderable row when the\n * exact row is hidden/absent.\n */\nexport async function jumpToQuestion(ports: JumpPorts, key: string, options: JumpOptions = {}): Promise<JumpResult> {\n const cfg = { ...DEFAULTS, ...options }\n const fail = (code: JumpFailureCode, fallback = false): JumpResult => {\n ports.report?.(code, fallback)\n return fallback ? { ok: false, code, fallback: true } : { ok: false, code }\n }\n\n if (!ports.isViewActive()) return fail('VIEW_INACTIVE')\n\n const deadline = ports.now() + cfg.totalTimeoutMs\n let pages = 0\n\n // Phase 1: page older until the key appears in the loaded window.\n while (true) {\n const snap = ports.snapshot()\n if (snap === undefined) return fail('VIEW_INACTIVE')\n const rows = renderable(snap.rows)\n if (rows.some((r) => r.key === key)) break\n if (snap.openState !== 'open') {\n if (snap.openState === 'error' || ports.now() > deadline) {\n return fail(snap.openState === 'error' ? 'VIEW_INACTIVE' : 'TIMEOUT')\n }\n await ports.sleep(cfg.pollMs)\n continue\n }\n if (snap.hasMore !== true) return fail('NOT_FOUND')\n if (pages >= cfg.maxPages || ports.now() > deadline) return fail('TIMEOUT')\n if (snap.loadingOlder) {\n await ports.sleep(cfg.pollMs)\n continue\n }\n const before = minAnchorSeq(rows)\n await ports.loadOlder()\n pages += 1\n const afterSnap = ports.snapshot()\n const after = minAnchorSeq(afterSnap === undefined ? [] : afterSnap.rows)\n if (after === null || (before !== null && after >= before)) return fail('NOT_FOUND')\n }\n\n // Phase 2: wait for the row to render, then scroll. Fall back if hidden.\n const waitedFor = async (rowKey: string): Promise<HTMLElement | null> => {\n for (let waited = 0; waited <= cfg.rowWaitMs; waited += cfg.pollMs) {\n if (!ports.isViewActive()) return null\n const row = ports.findRow(rowKey)\n if (row !== null) return row\n await ports.sleep(cfg.pollMs)\n }\n return null\n }\n\n const row = await waitedFor(key)\n if (row !== null) {\n ports.scrollIntoView(row)\n return { ok: true }\n }\n\n const snap = ports.snapshot()\n const fallback = nearestRenderable(snap === undefined ? [] : snap.rows, key)\n if (fallback !== null) {\n const fbRow = await waitedFor(fallback.key)\n if (fbRow !== null) {\n ports.scrollIntoView(fbRow)\n return fail('TARGET_HIDDEN', true)\n }\n }\n return fail('TARGET_HIDDEN', false)\n}\n","/**\n * Browser-half entry for the dsh-question-nav plugin.\n *\n * Registers one surface into the frame-wide floating layer (`shell.overlay`):\n * a vertical strip on the LEFT edge of the conversation column listing every\n * user question in the current session as a small button, one dot per turn.\n * Clicking a button scrolls the chat to that turn's first question.\n *\n * The dots are driven by the host-folded `questionIndex` session projection\n * (registered by the plugin's host half): the projection registry folds the\n * WHOLE event log without touching the chat's paged render window, the\n * projection cache persists it, and the standard carriers (history tail-page\n * baseline + session/projection push frames) keep it live. Live-window\n * questions not yet recorded by the projection are merged on top so a\n * just-sent question appears immediately.\n *\n * Failure policy: nothing here throws at apply time — an external plugin must\n * never take the GUI down.\n */\nimport type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls ui-layout's SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\n// Type-only: pulls the locale plugin's Context merge (ctx.locale).\nimport type {} from '@deepseek-ai/dsh-client-locale/client'\n// Type-only: pulls the settings shell's SlotMap merge ('settings.plugins.tab')\n// and the ctx.settingsScope Context merge.\nimport type {} from '@deepseek-ai/dsh-client-ui-settings/client'\nimport type { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'\nimport { QuestionNavStrip, type ObservableFace, type QuestionNavInjected } from './QuestionNavStrip.tsx'\nimport { QuestionNavSettingsTab } from './QuestionNavSettingsTab.tsx'\nimport { QuestionNavSettingsController } from './settings.ts'\nimport { en, zh, type QuestionNavKey } from './locales.ts'\nimport { extractQuestions } from '../core/nodes.ts'\nimport { jumpToQuestion, type JumpFailureCode, type JumpPorts } from '../core/jump.ts'\n\n/** Locale namespace this plugin owns. */\nconst NS = 'question-nav'\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n /** question-nav surface copy. */\n 'question-nav': QuestionNavKey\n }\n}\n\n/** Services required by this plugin. */\nexport const inject = ['slots', 'locale', 'sessions']\n\n/** Single-instance guard: a duplicated client injection must not mount twice. */\ndeclare global {\n // eslint-disable-next-line no-var\n var __dshQuestionNavApplied: boolean | undefined\n}\n\nfunction claimApply(): boolean {\n if (globalThis.__dshQuestionNavApplied === true) return false\n globalThis.__dshQuestionNavApplied = true\n return true\n}\n\nfunction releaseApply(): void {\n globalThis.__dshQuestionNavApplied = undefined\n}\n\n/** Map the session snapshot to the jump-loop port surface. */\nfunction jumpPortsFor(ctx: ClientContext, sessionId: SessionId): JumpPorts {\n return {\n snapshot: () => {\n const binding = ctx.sessions.binding(sessionId)\n const snap = binding?.session.getSnapshot()\n if (snap === undefined) return undefined\n return {\n openState: snap.openState,\n hasMore: snap.hasMore,\n loadingOlder: snap.loadingOlder,\n rows: snap.chat.nodes.values(),\n }\n },\n loadOlder: async () => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) throw new Error('session unavailable')\n await binding.session.loadOlder()\n },\n isViewActive: () => document.querySelector('[data-chat-flow]') !== null,\n findRow: (key: string) => {\n for (const candidate of Array.from(document.querySelectorAll<HTMLElement>('[data-chat-anchor-key]'))) {\n if (candidate.dataset.chatAnchorKey === key) return candidate\n }\n return null\n },\n scrollIntoView: (row) => row.scrollIntoView({ block: 'start' }),\n now: () => Date.now(),\n sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)),\n }\n}\n\n/**\n * The session's `questionIndex` projection face (getSnapshot + subscribe).\n * Undefined when the session is not bound or the host unit is not registered\n * (e.g. a headless composition) — the strip then shows live-window dots only.\n */\nfunction questionProjectionOf(ctx: ClientContext, sessionId: SessionId): ObservableFace | undefined {\n const face = ctx.sessions.binding(sessionId)?.session.projections.faceOf('questionIndex')\n if (face === undefined) return undefined\n return {\n getSnapshot: () => face.getSnapshot(),\n subscribe: (listener) => face.subscribe(listener),\n }\n}\n\nfunction createInject(ctx: ClientContext, settings: QuestionNavSettingsController): QuestionNavInjected {\n return {\n readQuestions: (sessionId) => {\n const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot()\n if (snap === undefined) return []\n return extractQuestions(snap.chat.nodes.values())\n },\n subscribeList: (cb) => ctx.sessions.list.subscribe(cb),\n subscribeContent: (sessionId, cb) => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) return () => {}\n return binding.session.subscribe(cb)\n },\n questionProjection: (sessionId) => questionProjectionOf(ctx, sessionId),\n jump: (sessionId, key) => {\n const ports = jumpPortsFor(ctx, sessionId)\n ports.report = (code: JumpFailureCode) => {\n // Surface the failure through the component via a DOM event the\n // strip listens for; simplest reliable cross-boundary channel here.\n window.dispatchEvent(new CustomEvent('question-nav:jump-failed', { detail: code }))\n }\n void jumpToQuestion(ports, key)\n },\n align: () => settings.getSnapshot().align,\n subscribeAlign: (cb) => settings.subscribe(cb),\n setAlign: (align) => settings.setAlign(align),\n }\n}\n\n/**\n * Register the question-nav surface.\n * @param ctx - client root context.\n */\nexport function apply(ctx: ClientContext): void {\n if (!claimApply()) return\n ctx.effect(() => releaseApply, 'question-nav: apply claim')\n\n ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'question-nav: dictionaries')\n const t = ctx.locale.bind(NS)\n\n const settings = new QuestionNavSettingsController()\n const injected = createInject(ctx, settings)\n\n ctx.slots.inject('shell.overlay', () => ctx.slots.register({\n name: 'shell.overlay',\n id: 'question-nav',\n order: 900,\n locale: NS,\n inject: () => injected,\n }, QuestionNavStrip))\n\n // The settings surface is optional and may apply after this plugin, so the\n // namespace binds and the settings page mounts in a fiber that waits for\n // it. Without it the strip simply keeps the default left alignment.\n ctx.inject(['settingsScope'], (scopeCtx) => {\n settings.attach(scopeCtx.get('settingsScope') as SettingsScopeBinder)\n ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({\n name: 'settings.plugins.tab',\n id: 'question-nav',\n order: 100,\n label: () => t('settings.tab'),\n locale: NS,\n inject: () => injected,\n }, QuestionNavSettingsTab))\n })\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"client.js","names":["kind","useState","useRef","styles","createPortal","useState","styles"],"sources":["../src/core/turn-dots.ts","../src/core/focus.ts","../src/core/time.ts","../src/client/QuestionNavStrip.tsx","../src/core/align.ts","../src/client/QuestionNavSettingsTab.tsx","../src/client/settings.ts","../src/client/locales.ts","../src/core/nodes.ts","../src/core/jump.ts","../src/client/index.ts"],"sourcesContent":["/**\n * Turn-aligned dot model for the question-nav strip. Pure transforms over the\n * `questionIndex` projection value plus the live chat window — no React, no\n * DOM — so the grouping and merging rules are unit-testable in isolation.\n *\n * One dot per turn that claimed at least one user question; turns without a\n * question (retry, goal continuation, cancelled empty turns) produce no dot,\n * so dot turn labels may skip numbers but always match the Trajectory view.\n */\n\nimport type { QuestionEntry } from './question-entry.ts'\nimport type { QuestionNode } from './nodes.ts'\n\n/** The conversation Definition kind whose key a user question node uses. */\nexport const MESSAGE_DEFINITION_KIND = 'input-message'\n\n/**\n * The engine-owned stable chat key for a user question — mirrors\n * `conversationContextKey('input-message', String(id))` from the DSH runtime\n * (verified against that formula in the unit test).\n */\nexport function questionKey(id: unknown): string {\n const kind = MESSAGE_DEFINITION_KIND\n return `${kind.length}:${kind}${String(id)}`\n}\n\n/** One strip dot: a turn's questions (grouped) or one ungrouped live question. */\nexport interface TurnDot {\n /** Owning turn number; null for live questions the projection has not seen. */\n readonly turn: number | null\n /** Jump anchor: the chat key of the turn's FIRST question. */\n readonly key: string\n /** Anchor seq of the first question (ordering + jump target). */\n readonly anchorSeq: number\n /** Unix ms of the first question. */\n readonly time: number\n /** Every question text of this dot, in order (tooltip lists them all). */\n readonly texts: readonly string[]\n /** Chat keys of every question folded into this dot (live-merge dedupe). */\n readonly memberKeys: readonly string[]\n}\n\n/** True when entries are already in non-decreasing seq order (the projection\n * appends in event order, so this is the common case and skips the sort). */\nfunction isSortedBySeq(entries: readonly QuestionEntry[]): boolean {\n for (let i = 1; i < entries.length; i++) {\n if (entries[i].seq < entries[i - 1].seq) return false\n }\n return true\n}\n\n/**\n * Fold the projection's question list into one dot per turn. Entries arrive\n * in event order; consecutive same-turn entries merge into a single dot whose\n * anchor is the turn's first question. The input is expected to be sorted by\n * seq; the defensive sort is skipped when it already is, so a long session\n * never pays an O(n log n) sort on every content update.\n */\nexport function groupQuestionsByTurn(entries: readonly QuestionEntry[]): TurnDot[] {\n const sorted = isSortedBySeq(entries) ? entries : [...entries].sort((a, b) => a.seq - b.seq)\n const dots: TurnDot[] = []\n for (const entry of sorted) {\n const key = questionKey(entry.id)\n const last = dots.at(-1)\n if (last !== undefined && last.turn === entry.turn) {\n dots[dots.length - 1] = {\n ...last,\n texts: [...last.texts, entry.text],\n memberKeys: [...last.memberKeys, key],\n }\n continue\n }\n dots.push({\n turn: entry.turn,\n key,\n anchorSeq: entry.seq,\n time: entry.time,\n texts: [entry.text],\n memberKeys: [key],\n })\n }\n return dots\n}\n\n/**\n * Merge live-window questions the projection has not recorded yet (the brief\n * window before the session/projection push frame lands). A live question\n * whose key is already folded into a dot is dropped (the projected copy\n * wins); the rest become single-question dots with `turn: null`, inserted in\n * anchor-seq order so the strip stays strictly chronological.\n *\n * Fast path: when nothing new arrives the SAME array is returned (no copy),\n * so the caller can bail out of a re-render on identical reference.\n */\nexport function mergeLiveQuestions(\n dots: readonly TurnDot[],\n live: readonly QuestionNode[],\n): TurnDot[] {\n if (live.length === 0) return dots as TurnDot[]\n const known = new Set<string>()\n for (const dot of dots) {\n for (const key of dot.memberKeys) known.add(key)\n }\n const extras: TurnDot[] = []\n for (const question of live) {\n if (known.has(question.key)) continue\n extras.push({\n turn: null,\n key: question.key,\n anchorSeq: question.anchorSeq,\n time: question.time,\n texts: [question.text],\n memberKeys: [question.key],\n })\n }\n // Nothing new from the live window — reuse the input array unchanged.\n if (extras.length === 0) return dots as TurnDot[]\n // Both `dots` and `extras` are sorted by anchorSeq (dots from the projection\n // order, extras from the live window order): merge linearly instead of\n // re-sorting the whole list. Ties keep the projected dot first (stable\n // sort semantics), matching the previous [...dots, ...extras].sort().\n const out: TurnDot[] = []\n let i = 0\n for (const extra of extras) {\n while (i < dots.length && dots[i].anchorSeq <= extra.anchorSeq) {\n out.push(dots[i])\n i += 1\n }\n out.push(extra)\n }\n while (i < dots.length) {\n out.push(dots[i])\n i += 1\n }\n return out\n}\n","/**\n * Focus-magnification model for the question-nav rail. Pure index/scale math\n * for the hover-selected dot's neighborhood window, its progressive\n * magnification tiers, and the vertical cascade of question cards (one per\n * window dot, non-overlapping, sizes descending away from the selected one).\n * No React, no DOM — every function here is unit-testable in isolation and\n * safe to inline into the client bundle.\n *\n * @module dsh-question-nav/focus\n */\n\n/** How many dots stay enlarged on each side of the selected dot. */\nexport const FOCUS_RADIUS = 2\n\n/** Magnification scale per tier, by distance from the selected dot\n * (0 = selected, 1 = immediate neighbor, 2 = outer window edge). */\nexport const FOCUS_SCALES = [2.0, 1.55, 1.25] as const\n\n/**\n * The focus tier of a dot at `distance` from the selected dot: 0..FOCUS_RADIUS\n * while inside the magnification window, null beyond it (base scale).\n */\nexport function focusTier(distance: number): number | null {\n const d = Math.abs(distance)\n if (d > FOCUS_RADIUS) return null\n return d\n}\n\n/** Magnification scale for a dot at `distance`; 1 (base) outside the window. */\nexport function focusScale(distance: number): number {\n const tier = focusTier(distance)\n return tier === null ? 1 : FOCUS_SCALES[tier]\n}\n\n/** Presentation metrics for the question card of a dot at `distance` from the\n * selected one. The hierarchy is two-tier: the selected card is the focus\n * (widest, full text, brand accent — styled via the selected CSS class), the\n * four neighbors are context cards (same clean surface, narrower, clamped to\n * fewer lines). Cards are never dimmed or blurred: context must stay crisp\n * and readable so it can be recognized — and clicked. Null outside the\n * window (no card). */\nexport interface FocusCardMetrics {\n /** Card width in px (narrows away from the selected card). */\n widthPx: number\n /** Text font size (px); cascade cards are slightly smaller. */\n fontSize: number\n /** Max text lines before clamping (cascade cards). */\n maxLines: number\n}\n\nexport function focusCardMetrics(distance: number): FocusCardMetrics | null {\n const tier = focusTier(distance)\n if (tier === null) return null\n switch (tier) {\n case 0:\n return { widthPx: 380, fontSize: 13, maxLines: 6 }\n case 1:\n return { widthPx: 300, fontSize: 12.5, maxLines: 2 }\n default:\n return { widthPx: 240, fontSize: 12, maxLines: 1 }\n }\n}\n\n/**\n * Indices of the focus window: the selected dot plus `radius` on each side,\n * clipped to the dot list. Empty when the list is empty or `selected` is out\n * of range.\n */\nexport function magnificationWindow(total: number, selected: number, radius: number = FOCUS_RADIUS): number[] {\n if (total <= 0 || selected < 0 || selected >= total || radius < 0) return []\n const out: number[] = []\n const lo = Math.max(0, selected - radius)\n const hi = Math.min(total - 1, selected + radius)\n for (let i = lo; i <= hi; i++) out.push(i)\n return out\n}\n\n/** Height (px) of the dot band's top/bottom fade zones, which double as hover\n * auto-scroll areas (slightly larger than the 22px CSS mask fade). */\nexport const EDGE_SCROLL_ZONE = 26\n\n/** Edge auto-scroll speed range (px/s): MIN at the zone boundary, MAX at the\n * very edge of the band. */\nexport const EDGE_SCROLL_MIN_SPEED = 90\nexport const EDGE_SCROLL_MAX_SPEED = 420\n\n/**\n * Auto-scroll speed (px/s) for a pointer at `ratio` depth into an edge fade\n * zone (0 = at the zone boundary, 1 = at the band's very edge): eases\n * linearly from MIN to MAX so a shallow probe scrolls gently and pushing\n * into the edge moves fast. The sign (direction) is applied by the caller.\n */\nexport function edgeScrollSpeed(ratio: number): number {\n const r = Math.max(0, Math.min(1, ratio))\n return EDGE_SCROLL_MIN_SPEED + (EDGE_SCROLL_MAX_SPEED - EDGE_SCROLL_MIN_SPEED) * r\n}\n","/**\n * Question sent-time formatting for the question-nav surface. Pure: no DSH\n * imports, deterministic for a given (time, now) pair — unit-testable in\n * isolation and safe to inline into the client bundle.\n *\n * @module dsh-question-nav/time\n */\n\nfunction pad2(n: number): string {\n return n < 10 ? `0${n}` : String(n)\n}\n\n/** Whether `time` falls on the same calendar day as `now`. */\nexport function isSameDay(time: number, now: number): boolean {\n const a = new Date(time)\n const b = new Date(now)\n return a.getFullYear() === b.getFullYear()\n && a.getMonth() === b.getMonth()\n && a.getDate() === b.getDate()\n}\n\n/**\n * Smart question sent-time, relative to `now`:\n * - same calendar day → `HH:MM`\n * - same calendar year → `MM-DD HH:MM`\n * - otherwise → `YYYY-MM-DD HH:MM`\n *\n * Returns `''` for a missing/invalid timestamp (e.g. a live node that never\n * reported one), so callers can hide the time line without branching.\n */\nexport function formatQuestionTime(time: number, now: number): string {\n if (!Number.isFinite(time) || time <= 0) return ''\n const d = new Date(time)\n const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`\n if (isSameDay(time, now)) return hm\n const md = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`\n if (d.getFullYear() === new Date(now).getFullYear()) return `${md} ${hm}`\n return `${d.getFullYear()}-${md} ${hm}`\n}\n","/**\n * Question-nav minimap. Renders a vertical column of small round dots overlaid\n * on the LEFT edge of the conversation column (via the frame-wide\n * `shell.overlay` floating layer): one dot per turn that claimed at least one\n * user question — strictly aligned with the Trajectory view's turn numbering\n * (turns without a question produce no dot). The dot column is vertically\n * centered and clamped to at most 60% of the conversation height, scrolling\n * within that band when the session has more dots than fit.\n *\n * Hovering a dot \"selects\" it: the selected dot plus its two immediate\n * neighbors on each side magnify (progressively smaller with distance), and a\n * vertical cascade of question cards opens — the selected (center) card is the\n * focus (brand accent, elevated, full question text), the four neighbors are\n * narrower context cards clamped to fewer lines. Every card is clickable and\n * jumps to its question, exactly like clicking the dot; the rail auto-centers\n * the selected dot.\n *\n * Data source: the host-folded `questionIndex` session projection (whole\n * history, persisted host-side, pushed live through session/projection\n * frames) read through the injected `questionProjection` face, plus the live\n * chat window's questions merged on top for the brief window before a\n * just-sent question lands in the projection. No render-window expansion, no\n * client-side history paging.\n *\n * Data arrives through the props shares: the framework `useSessions` hook\n * (current session), the registrant inject face (read/subscribe/project/\n * jump), and the bound locale translator.\n */\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react'\nimport { createPortal } from 'react-dom'\nimport type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls the ui-layout SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\nimport type { QuestionNode } from '../core/nodes.ts'\nimport type { QuestionEntry } from '../core/question-entry.ts'\nimport { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'\nimport type { AlignPreference } from '../core/align.ts'\nimport type { JumpFailureCode } from '../core/jump.ts'\nimport { EDGE_SCROLL_ZONE, FOCUS_RADIUS, edgeScrollSpeed, focusCardMetrics, focusScale, focusTier } from '../core/focus.ts'\nimport { formatQuestionTime } from '../core/time.ts'\nimport type { QuestionNavKey } from './locales.ts'\nimport styles from './question-nav.module.css'\n\n/** Minimal observable shape of a session projection face. */\nexport interface ObservableFace {\n /** Current projection value (unknown — validated structurally at read). */\n getSnapshot: () => unknown\n /** Subscribe to value changes; returns an unsubscribe. */\n subscribe: (listener: () => void) => () => void\n}\n\n/** Values the registrant inject face supplies (wired in src/client/index.ts). */\nexport interface QuestionNavInjected {\n /** Extract the user questions of a session's currently loaded window. */\n readQuestions: (sessionId: SessionId) => QuestionNode[]\n /** Subscribe to the session list; returns an unsubscribe. */\n subscribeList: (cb: () => void) => () => void\n /** Subscribe to a session's content; returns an unsubscribe. */\n subscribeContent: (sessionId: SessionId, cb: () => void) => () => void\n /** The session's `questionIndex` projection face, when the host unit is registered. */\n questionProjection: (sessionId: SessionId) => ObservableFace | undefined\n /** Jump the chat to a question row (pages the window on demand). */\n jump: (sessionId: SessionId, key: string) => void\n /** Current rail anchor edge (defaults to 'left' before the settings section is ready). */\n align: () => AlignPreference\n /** Observe anchor-edge changes; returns an unsubscribe. */\n subscribeAlign: (cb: () => void) => () => void\n /** Persist a new anchor edge. */\n setAlign: (align: AlignPreference) => void\n}\n\ntype ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>\n\nconst FAILURE_HINTS: Record<JumpFailureCode, QuestionNavKey> = {\n VIEW_INACTIVE: 'jump.inactive',\n TARGET_HIDDEN: 'jump.hidden',\n NOT_FOUND: 'jump.notfound',\n TIMEOUT: 'jump.timeout',\n}\n\n/** One card of the focus cascade: a dot inside the magnification window. */\ninterface FocusWindowItem {\n dot: TurnDot\n /** Distance from the selected dot (0 = selected, 1/2 = cascade neighbors). */\n distance: number\n}\n\n/** The focus cascade: a vertical column of one card per window dot,\n * non-overlapping, centered on the selected dot. */\ninterface FocusState {\n /** The selected (hovered) dot's key. */\n key: string\n /** Every dot of the focus window, in dot order. */\n items: FocusWindowItem[]\n /** Vertical center (viewport px) of the selected dot — the column centers on it. */\n centerY: number\n /** Horizontal anchor — left edge (left-aligned rail) or right edge (right-aligned). */\n left?: number\n right?: number\n}\n\n/** Read the projection face value as a question-entry list (structural guard). */\nfunction projectionEntries(face: ObservableFace | undefined): QuestionEntry[] {\n const value = face?.getSnapshot()\n if (!Array.isArray(value)) return []\n return value.filter((item): item is QuestionEntry =>\n typeof item === 'object' && item !== null\n && typeof (item as QuestionEntry).id === 'string'\n && typeof (item as QuestionEntry).seq === 'number'\n && typeof (item as QuestionEntry).turn === 'number')\n}\n\nfunction findConvRoot(): HTMLElement | null {\n return document.querySelector<HTMLElement>('[data-slot=\"conversation\"] > div[data-phase]')\n}\n\n/** Structural equality of two dot lists (member keys fully capture a dot's\n * folded questions, so identical key sequences mean identical content).\n * Lets the strip skip a re-render when a refresh produced no change. */\nfunction sameDots(a: readonly TurnDot[], b: readonly TurnDot[]): boolean {\n if (a.length !== b.length) return false\n for (let i = 0; i < a.length; i++) {\n const da = a[i]\n const db = b[i]\n if (da.key !== db.key || da.memberKeys.length !== db.memberKeys.length) return false\n for (let j = 0; j < da.memberKeys.length; j++) {\n if (da.memberKeys[j] !== db.memberKeys[j]) return false\n }\n }\n return true\n}\n\nexport function QuestionNavStrip(props: ComponentProps): React.JSX.Element | null {\n const current = props.useSessions((s) => s.current)\n const summary = props.useSessions((s) => (s.current === undefined ? undefined : s.byId[s.current]))\n const visible = current !== undefined && summary !== undefined && summary.blank !== true\n\n const [dots, setDots] = useState<TurnDot[]>([])\n const [jumpingKey, setJumpingKey] = useState<string | null>(null)\n const [hint, setHint] = useState<string | null>(null)\n const [focus, setFocus] = useState<FocusState | null>(null)\n const [align, setAlign] = useState<AlignPreference>(() => props.align())\n const panelRef = useRef<HTMLDivElement | null>(null)\n const listRef = useRef<HTMLDivElement | null>(null)\n const hintTimerRef = useRef<number | null>(null)\n // Pending focus-clear timer: lets the mouse move from the rail onto a\n // cascade card without dropping the cascade mid-flight.\n const clearFocusTimerRef = useRef<number | null>(null)\n // Last rendered dot list, for the change-detection bail-out below.\n const lastDotsRef = useRef<TurnDot[]>([])\n // Last focused dot key, so re-hovering the same dot after a gap re-centers it.\n const lastFocusedKeyRef = useRef<string | null>(null)\n // Whether the dot band overflows its 60% clamp (drives the edge-fade mask\n // and the hover auto-scroll).\n const [scrollable, setScrollable] = useState(false)\n // Edge auto-scroll state: the fade zones at the band's top/bottom edges are\n // scroll affordances — hovering them scrolls the band so the faded dots\n // flow into the clear area. Speed follows how deep the pointer is inside\n // the zone; a rAF loop applies it smoothly while it is non-zero.\n const edgeScrollRef = useRef<{ speed: number; raf: number; last: number }>({ speed: 0, raf: 0, last: 0 })\n\n const stopEdgeScroll = (): void => {\n edgeScrollRef.current.speed = 0\n if (edgeScrollRef.current.raf !== 0) {\n cancelAnimationFrame(edgeScrollRef.current.raf)\n edgeScrollRef.current.raf = 0\n }\n }\n\n const edgeTick = (now: number): void => {\n const list = listRef.current\n const state = edgeScrollRef.current\n state.raf = 0\n if (list === null || state.speed === 0) return\n const dt = Math.min(64, now - state.last) / 1000\n state.last = now\n const before = list.scrollTop\n list.scrollTop = before + state.speed * dt\n // Stop at the scroll bounds — there is nothing more to reveal.\n const atTop = list.scrollTop <= 0 && state.speed < 0\n const atBottom = list.scrollTop >= list.scrollHeight - list.clientHeight - 1 && state.speed > 0\n if (atTop || atBottom) {\n state.speed = 0\n return\n }\n state.raf = requestAnimationFrame(edgeTick)\n }\n\n const onListMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {\n const list = listRef.current\n if (list === null) return\n const rect = list.getBoundingClientRect()\n const y = e.clientY - rect.top\n const depthTop = EDGE_SCROLL_ZONE - y\n const depthBottom = y - (rect.height - EDGE_SCROLL_ZONE)\n let speed = 0\n if (depthTop > 0 && list.scrollTop > 0) {\n speed = -edgeScrollSpeed(depthTop / EDGE_SCROLL_ZONE)\n } else if (depthBottom > 0 && list.scrollTop < list.scrollHeight - list.clientHeight - 1) {\n speed = edgeScrollSpeed(depthBottom / EDGE_SCROLL_ZONE)\n }\n const state = edgeScrollRef.current\n state.speed = speed\n if (speed !== 0 && state.raf === 0) {\n state.last = performance.now()\n state.raf = requestAnimationFrame(edgeTick)\n }\n }\n\n // Focus persists briefly after leaving the rail, so the mouse can reach the\n // clickable cascade cards; entering a card cancels the clear, leaving\n // everything re-arms it.\n const cancelClearFocus = (): void => {\n if (clearFocusTimerRef.current !== null) {\n window.clearTimeout(clearFocusTimerRef.current)\n clearFocusTimerRef.current = null\n }\n }\n const scheduleClearFocus = (): void => {\n cancelClearFocus()\n clearFocusTimerRef.current = window.setTimeout(() => setFocus(null), 240)\n }\n\n // Follow the rail anchor edge from the settings scope (a change re-anchors\n // the rail through the layout effect below).\n useEffect(() => props.subscribeAlign(() => setAlign(props.align())), [props])\n\n const showHint = (message: string): void => {\n setHint(message)\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n hintTimerRef.current = window.setTimeout(() => setHint(null), 1800)\n }\n\n // Recompute the dot list from the projection + live window. Subscribed to\n // the projection push frames and the session's content only — session\n // switches are covered by `current` below (the effect re-runs on change),\n // so the session-list feed is not subscribed: it would re-run the full\n // recompute for unrelated list churn.\n useEffect(() => {\n if (!visible || current === undefined) {\n setDots([])\n lastDotsRef.current = []\n return\n }\n const sessionId = current\n const face = props.questionProjection(sessionId)\n const refresh = (): void => {\n const grouped = groupQuestionsByTurn(projectionEntries(face))\n const next = mergeLiveQuestions(grouped, props.readQuestions(sessionId))\n // Identical content (a streaming update that added no question): re-use\n // the previous array reference so React bails out of re-rendering the\n // strip — the common case during assistant streaming.\n if (sameDots(next, lastDotsRef.current)) {\n setDots(lastDotsRef.current)\n return\n }\n lastDotsRef.current = next\n setDots(next)\n }\n refresh()\n const unsubProjection = face?.subscribe(refresh) ?? (() => {})\n const unsubContent = props.subscribeContent(sessionId, refresh)\n return () => {\n unsubProjection()\n unsubContent()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [visible, current, props])\n\n // Listen for jump-failure events and surface the hint.\n useEffect(() => {\n const onJumpFailed = (event: Event): void => {\n const code = (event as CustomEvent<JumpFailureCode>).detail\n showHint(props.t(FAILURE_HINTS[code] ?? 'jump.timeout'))\n }\n window.addEventListener('question-nav:jump-failed', onJumpFailed)\n return () => window.removeEventListener('question-nav:jump-failed', onJumpFailed)\n }, [props])\n\n // Anchor the minimap to the conversation column: position it at the left\n // edge of the conversation root and reserve a thin rail with padding-left.\n //\n // A layout-correction loop keeps the rail pinned to the conversation even\n // when an outside panel (e.g. a browser-extension sidebar like Doubao) moves\n // or resizes the frame without resizing the browser window: `window resize`\n // never fires for in-page panels, and a ResizeObserver on the conversation\n // root alone misses remounts and the tail of the frame's grid-column\n // transition, which used to leave the rail drifting into the session list.\n // The loop runs while the layout is still moving, then parks itself after a\n // few stable frames; observers re-wake it on the next change.\n useLayoutEffect(() => {\n if (!visible) return\n let raf = 0\n let idle = 0\n let stopped = false\n let observer: ResizeObserver | null = null\n const observed = { frame: null as Element | null, convRoot: null as Element | null }\n\n const applyLayout = (): boolean => {\n const panel = panelRef.current\n if (panel === null) return false\n const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n // Keep looping while the anchors are not both present (conversation not\n // mounted yet / mid-reflow), so a late mount still aligns.\n if (frame === null || convRoot === null) return true\n // Keep observing the live nodes: the conversation root may remount\n // (e.g. after a panel-triggered reflow), which silently detaches an\n // earlier ResizeObserver target.\n if (observer !== null) {\n if (observed.frame !== frame) {\n observer.observe(frame, { box: 'border-box' })\n observed.frame = frame\n }\n if (observed.convRoot !== convRoot) {\n observer.observe(convRoot, { box: 'border-box' })\n observed.convRoot = convRoot\n }\n }\n const frameRect = frame.getBoundingClientRect()\n const convRect = convRoot.getBoundingClientRect()\n // Never snap onto a transient box: keep correcting until the\n // conversation has a real footprint again.\n if (convRect.height <= 0 || convRect.width <= 0) return true\n const top = `${convRect.top - frameRect.top}px`\n const height = `${convRect.height}px`\n // Anchor to the configured edge: keep the other edge's inline style\n // cleared so the CSS class (left: auto on .railRight) owns it.\n if (align === 'right') {\n const right = `${frameRect.right - convRect.right}px`\n if (panel.style.top === top && panel.style.height === height\n && panel.style.right === right && panel.style.left === '') {\n return false\n }\n panel.style.top = top\n panel.style.height = height\n panel.style.right = right\n panel.style.left = ''\n return true\n }\n const left = `${convRect.left - frameRect.left}px`\n if (panel.style.top === top && panel.style.height === height\n && panel.style.left === left && panel.style.right === '') {\n return false\n }\n panel.style.top = top\n panel.style.height = height\n panel.style.left = left\n panel.style.right = ''\n return true\n }\n\n const loop = (): void => {\n if (stopped) return\n raf = 0\n idle = applyLayout() ? 0 : idle + 1\n if (idle < 3) raf = requestAnimationFrame(loop)\n }\n const wake = (): void => {\n if (stopped) return\n idle = 0\n if (raf === 0) raf = requestAnimationFrame(loop)\n }\n\n observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(wake)\n if (observer !== null) {\n const panel = panelRef.current\n const frame = panel?.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n if (frame !== null) { observer.observe(frame, { box: 'border-box' }); observed.frame = frame }\n if (convRoot !== null) { observer.observe(convRoot, { box: 'border-box' }); observed.convRoot = convRoot }\n }\n\n // Initial alignment; the loop keeps correcting through layout transitions.\n wake()\n window.addEventListener('resize', wake)\n return () => {\n stopped = true\n if (raf !== 0) cancelAnimationFrame(raf)\n observer?.disconnect()\n window.removeEventListener('resize', wake)\n }\n }, [visible, align])\n\n // Keep the focused dot centered in the (≤60% tall, scrollable) band so its\n // two neighbors on each side stay visible. Re-centers only when the focused\n // key changes; scrolls instantly to avoid chasing a fast-moving hover.\n useLayoutEffect(() => {\n const key = focus?.key ?? null\n if (lastFocusedKeyRef.current === key) return\n lastFocusedKeyRef.current = key\n if (key === null) return\n const list = listRef.current\n if (list === null) return\n const target = list.querySelector<HTMLElement>('[data-question-nav-focused=\"true\"]')\n if (target === null) return\n const delta = target.offsetTop - list.offsetTop\n const center = delta - (list.clientHeight - target.offsetHeight) / 2\n list.scrollTop = Math.max(0, Math.min(center, list.scrollHeight - list.clientHeight))\n }, [focus])\n\n // Detect band overflow: drives the edge-fade mask + hover auto-scroll.\n // Re-checked when the dots change and whenever the band itself resizes\n // (the layout loop above clamps it to the conversation height).\n useLayoutEffect(() => {\n const list = listRef.current\n if (list === null) return\n const check = (): void => setScrollable(list.scrollHeight > list.clientHeight + 1)\n check()\n if (typeof ResizeObserver === 'undefined') return\n const observer = new ResizeObserver(check)\n observer.observe(list)\n return () => observer.disconnect()\n }, [dots, align])\n\n // Clear any pending timers and the edge auto-scroll on unmount.\n useEffect(() => () => {\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current)\n stopEdgeScroll()\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n if (!visible) return null\n\n const onJump = (dot: TurnDot): void => {\n if (current === undefined) return\n setJumpingKey(dot.key)\n props.jump(current, dot.key)\n window.setTimeout(() => setJumpingKey((k) => (k === dot.key ? null : k)), 600)\n }\n\n const openFocus = (dot: TurnDot, target: HTMLElement): void => {\n const index = dots.findIndex((d) => d.key === dot.key)\n if (index < 0) return\n cancelClearFocus()\n const r = target.getBoundingClientRect()\n // The cascade opens away from the rail: to the right of the dots on a\n // left-anchored rail, to the left on a right-anchored one. The column is\n // vertically centered on the selected dot (top: centerY + translateY(-50%)).\n const horizontal = align === 'right'\n ? { right: window.innerWidth - r.left + 10 }\n : { left: r.right + 10 }\n const lo = Math.max(0, index - FOCUS_RADIUS)\n const hi = Math.min(dots.length - 1, index + FOCUS_RADIUS)\n const items: FocusWindowItem[] = []\n for (let i = lo; i <= hi; i++) {\n items.push({ dot: dots[i], distance: Math.abs(i - index) })\n }\n setFocus({ key: dot.key, items, centerY: r.top + r.height / 2, ...horizontal })\n }\n\n const t = props.t\n const selectedIndex = focus === null ? -1 : dots.findIndex((d) => d.key === focus.key)\n\n return (\n <div\n ref={panelRef}\n className={align === 'right' ? `${styles.rail} ${styles.railRight}` : styles.rail}\n data-question-nav=\"rail\"\n >\n <div\n ref={listRef}\n className={scrollable ? `${styles.list} ${styles.listScrollable}` : styles.list}\n onMouseMove={scrollable ? onListMouseMove : undefined}\n onMouseLeave={() => {\n stopEdgeScroll()\n scheduleClearFocus()\n }}\n >\n {dots.length === 0 ? (\n <div className={styles.empty}>{t('strip.empty')}</div>\n ) : (\n <div className={styles.dots}>\n <span className={styles.count}>{dots.length}</span>\n {dots.map((dot, index) => {\n const isFocused = focus !== null && focus.key === dot.key\n // Progressive magnification: selected largest, ±1 smaller, ±2\n // smaller still, the rest base scale.\n const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex)\n const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1\n const cls = [styles.dot]\n if (isFocused) cls.push(styles.focused)\n if (jumpingKey === dot.key) cls.push(styles.active)\n return (\n <button\n key={dot.key}\n className={cls.join(' ')}\n style={{ transform: `scale(${scale})` }}\n data-question-nav-focused={isFocused ? 'true' : undefined}\n data-question-nav-index={index}\n aria-label={dot.texts[0] ?? ''}\n onMouseEnter={(e) => openFocus(dot, e.currentTarget)}\n onClick={() => onJump(dot)}\n />\n )\n })}\n </div>\n )}\n </div>\n {hint !== null\n ? createPortal(\n <div\n className={styles.hint}\n style={align === 'right' ? { right: 60, top: 20 } : { left: 60, top: 20 }}\n >\n {hint}\n </div>,\n document.body,\n )\n : null}\n {focus !== null\n ? createPortal(\n /* The vertical cascade: one crisp card per window dot, stacked\n non-overlapping, vertically centered on the selected dot.\n Hovering the cascade keeps it alive (the rail only re-arms its\n clear once the mouse leaves both). */\n <div\n className={styles.cascade}\n style={{\n ...(focus.left !== undefined ? { left: focus.left } : {}),\n ...(focus.right !== undefined ? { right: focus.right } : {}),\n top: focus.centerY,\n transform: 'translateY(-50%)',\n }}\n onMouseEnter={cancelClearFocus}\n onMouseLeave={scheduleClearFocus}\n >\n {focus.items.map((item) => {\n const metrics = focusCardMetrics(item.distance)\n if (metrics === null) return null\n const isSelected = item.distance === 0\n // Cascade cards join their dot's texts into one clamped line;\n // the selected card keeps every line. Every card shows the\n // question's sent time, and clicking any card jumps to its\n // question exactly like clicking the dot.\n const text = item.dot.texts.join(' · ')\n const cls = isSelected ? `${styles.card} ${styles.cardSelected}` : styles.card\n return (\n <div\n key={item.dot.key}\n className={cls}\n role=\"button\"\n tabIndex={-1}\n style={{ width: metrics.widthPx }}\n onClick={() => onJump(item.dot)}\n >\n {isSelected && item.dot.turn !== null ? (\n <div className={styles.cardTitle}>Turn {item.dot.turn}</div>\n ) : null}\n <div\n className={isSelected ? styles.cardBody : styles.cardClamp}\n style={\n isSelected\n ? undefined\n : ({ WebkitLineClamp: metrics.maxLines, fontSize: metrics.fontSize } as React.CSSProperties)\n }\n >\n {isSelected\n ? item.dot.texts.map((line, lineIndex) => (\n <div key={lineIndex} className={styles.cardLine}>{line}</div>\n ))\n : text}\n </div>\n <div className={styles.cardTime}>{formatQuestionTime(item.dot.time, Date.now())}</div>\n </div>\n )\n })}\n </div>,\n document.body,\n )\n : null}\n </div>\n )\n}\n","/**\n * Rail anchor-edge constants shared by the host schema and the browser\n * settings scope. Pure data: no DSH imports, so the client bundle may inline\n * this module (a Host import here would leak into the browser half).\n *\n * @module dsh-question-nav/align\n */\n\n/** Supported rail anchor edges. */\nexport const ALIGN_OPTIONS = ['left', 'right'] as const\n\n/** Rail anchor edge preference. */\nexport type AlignPreference = typeof ALIGN_OPTIONS[number]\n\n/** Default anchor edge when the user-settings document has no override. */\nexport const DEFAULT_ALIGN: AlignPreference = 'left'\n\n/** Settings namespace owned by this plugin (spelled here rather than\n * imported: the client bundle must not depend on a Host package). */\nexport const QUESTION_NAV_SETTINGS_NS = 'question-nav'\n\n/** Field carrying the selected anchor edge. */\nexport const ALIGN_FIELD = 'align'\n","/**\n * The plugin's settings page inside the shell's Plugins section\n * (`settings.plugins.tab`): a segmented control choosing which edge of the\n * conversation column the dot rail anchors to. The choice is written to the\n * `question-nav` settings namespace (registered by the host half); the strip\n * re-anchors live when the snapshot changes.\n *\n * @module dsh-question-nav/client/settings-tab\n */\n\nimport { useEffect, useState } from 'react'\nimport type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\n// Type-only: pulls the settings shell's SlotMap merge ('settings.plugins.tab').\nimport type {} from '@deepseek-ai/dsh-client-ui-settings/client'\nimport { ALIGN_OPTIONS } from '../core/align.ts'\nimport type { QuestionNavInjected } from './QuestionNavStrip.tsx'\nimport type { QuestionNavKey } from './locales.ts'\nimport styles from './question-nav.module.css'\n\ntype ComponentProps = PropsRuntime<'settings.plugins.tab'> & QuestionNavInjected & PropsLocale<'question-nav'>\n\n/** Re-render on settings snapshot changes (the register inject face is static). */\nfunction useAlignTick(subscribe: QuestionNavInjected['subscribeAlign']): void {\n const [, bump] = useState(0)\n useEffect(() => subscribe(() => bump((n) => n + 1)), [subscribe])\n}\n\nexport function QuestionNavSettingsTab(props: ComponentProps): React.JSX.Element | null {\n useAlignTick(props.subscribeAlign)\n const align = props.align()\n const t = props.t\n\n return (\n <div className={styles.settings}>\n <p className={styles.settingsTitle}>{t('settings.align.title')}</p>\n <p className={styles.settingsDesc}>{t('settings.align.desc')}</p>\n <div className={styles.segmented} role=\"radiogroup\" aria-label={t('settings.align.title')}>\n {ALIGN_OPTIONS.map((option) => (\n <button\n key={option}\n type=\"button\"\n role=\"radio\"\n aria-checked={align === option}\n className={align === option ? `${styles.segment} ${styles.segmentActive}` : styles.segment}\n onClick={() => props.setAlign(option)}\n >\n {t(option === 'left' ? 'settings.align.left' : 'settings.align.right')}\n </button>\n ))}\n </div>\n </div>\n )\n}\n","/**\n * Browser mirror of the `question-nav` settings namespace: reads the rail\n * anchor edge from the settings scope (`ctx.settingsScope.bind`) and routes\n * the user's choice back through `scope.set`. The namespace itself is\n * registered by the host half (src/settings.ts).\n *\n * The settings surface is optional and may apply after this plugin, so the\n * controller starts unbound and degrades to the default alignment until\n * {@link attach} binds the scope (called from a fiber that injects\n * `settingsScope`). The plugin keeps working everywhere it already did.\n *\n * @module dsh-question-nav/client/settings\n */\n\nimport type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'\nimport {\n ALIGN_FIELD, ALIGN_OPTIONS, DEFAULT_ALIGN, QUESTION_NAV_SETTINGS_NS,\n type AlignPreference,\n} from '../core/align.ts'\n\n/** Narrow a raw section to the anchor field. */\nfunction isAlignPreference(value: unknown): value is AlignPreference {\n return ALIGN_OPTIONS.some((option) => option === value)\n}\n\n/** The minimal face of the settings scope service this controller needs.\n * Kept structural (bind only) so the controller stays decoupled from the\n * full service and is unit-testable with a stub binder. */\nexport interface SettingsScopeBinderLike {\n bind<T>(spec: SettingsScopeSpec<T>): SettingsScope<T>\n}\n\n/** Wire section this plugin owns (the Host schema's `align` field). */\ninterface QuestionNavSection {\n align?: unknown\n}\n\n/** Snapshot consumed by the strip and the settings row. */\nexport interface QuestionNavSettingsState {\n /** Last accepted anchor edge (default while the scope is absent/loading). */\n align: AlignPreference\n /** Whether the user layer overrides the composition default. */\n overridden: boolean\n}\n\n/** Reactive handle over the plugin's durable settings section. */\nexport class QuestionNavSettingsController {\n private scope: SettingsScope<QuestionNavSection> | undefined\n private readonly listeners = new Set<() => void>()\n private unsubscribe: () => void = () => {}\n private state: QuestionNavSettingsState = { align: DEFAULT_ALIGN, overridden: false }\n\n /**\n * Bind the namespace scope once the settings surface is present. Called\n * from a fiber that injects `settingsScope`, so the scope subscription\n * lives on that fiber and is released with it. A no-op after the first\n * bind.\n * @param binder - the settings scope service.\n */\n attach(binder: SettingsScopeBinderLike): void {\n if (this.scope !== undefined) return\n this.scope = binder.bind<QuestionNavSection>({ namespace: QUESTION_NAV_SETTINGS_NS })\n this.state = this.derive(this.scope.getSnapshot())\n this.unsubscribe = this.scope.subscribe(() => {\n if (this.scope === undefined) return\n const next = this.derive(this.scope.getSnapshot())\n if (next.align === this.state.align && next.overridden === this.state.overridden) return\n this.state = next\n for (const listener of this.listeners) listener()\n })\n }\n\n private derive(snapshot: SettingsScopeSnapshot<QuestionNavSection>): QuestionNavSettingsState {\n const user = snapshot.user as { align?: unknown } | undefined\n return {\n align: snapshot.status === 'ready' && isAlignPreference(snapshot.value?.align)\n ? snapshot.value.align\n : DEFAULT_ALIGN,\n overridden: user !== undefined && user.align !== undefined,\n }\n }\n\n /** Release the scope subscription (bound on the settings fiber's lifecycle). */\n dispose(): void {\n this.unsubscribe()\n this.listeners.clear()\n }\n\n /** @returns the current state (stable reference until the next change). */\n getSnapshot(): QuestionNavSettingsState {\n return this.state\n }\n\n /** Observe state replacements; returns the disposer. */\n subscribe(listener: () => void): () => void {\n this.listeners.add(listener)\n return () => { this.listeners.delete(listener) }\n }\n\n /** Route the user's anchor-edge choice to the Host document. */\n setAlign(align: AlignPreference): void {\n if (this.scope === undefined) return\n void this.scope.set(ALIGN_FIELD, align)\n }\n}\n","/**\n * Locale dictionaries for the question-nav surface (zh/en). Registered under\n * the `question-nav` namespace; keys are consumed through the bound translator.\n */\nexport const zh = {\n 'strip.empty': '本会话还没有提问',\n 'jump.inactive': '聊天视图未激活',\n 'jump.hidden': '目标无独立气泡,已定位到邻近内容',\n 'jump.notfound': '目标未加载或不存在(可能已压缩)',\n 'jump.timeout': '加载历史超时,可重试',\n 'settings.tab': '提问导航',\n 'settings.align.title': '导航条对齐',\n 'settings.align.desc': '选择圆点导航条锚定在对话栏的哪一侧。',\n 'settings.align.left': '左侧',\n 'settings.align.right': '右侧',\n} as const\n\nexport const en = {\n 'strip.empty': 'No questions in this session yet',\n 'jump.inactive': 'Chat view is not active',\n 'jump.hidden': 'No dedicated bubble; landed on nearby content',\n 'jump.notfound': 'Target not loaded or missing (maybe compacted)',\n 'jump.timeout': 'Timed out loading history; retry',\n 'settings.tab': 'Question Nav',\n 'settings.align.title': 'Rail alignment',\n 'settings.align.desc': 'Choose which edge of the conversation column the dot rail anchors to.',\n 'settings.align.left': 'Left',\n 'settings.align.right': 'Right',\n} as const\n\nexport type QuestionNavKey = keyof typeof zh\n","/**\n * Pure node-indexing logic for the question-nav plugin. No React, no DOM, no\n * Cordis — every function here is a pure transform over chat-node data so it\n * can be unit-tested in isolation (and reused by the browser half).\n */\n\n/** One user question as shown in the strip and targeted by a jump. */\nexport interface QuestionNode {\n /** Chat anchor key, matches a `[data-chat-anchor-key]` row in the scrollport. */\n key: string\n /** Monotone anchor sequence for ordering and window-min detection. */\n anchorSeq: number\n /** Event seq of the user message. */\n seq: number\n /** Unix ms timestamp. */\n time: number\n /** Full question text — shown in the hover tooltip (not truncated). */\n text: string\n}\n\n/** Minimal shape a chat node must expose for indexing (structural, not SDK-bound). */\nexport interface ChatNodeLike {\n key: string\n anchorSeq: number\n visibility?: string\n kind?: string\n /** Kind-specific payload (a UserMessageNode for `user`/`steering`). */\n data?: unknown\n}\n\n/** Kinds counted as a user question (turn-opening and steering admissions). */\nexport const QUESTION_KINDS = ['user', 'steering'] as const\n\n/** Narrow `node.data` to the user-message payload we read. */\ninterface UserDataLike {\n content?: readonly { type?: string; text?: string }[]\n seq?: number\n time?: number\n}\n\nfunction userData(data: unknown): UserDataLike | undefined {\n if (typeof data !== 'object' || data === null) return undefined\n return data as UserDataLike\n}\n\n/** First text block of a user message; falls back to the raw first block. */\nexport function messageText(content: readonly { type?: string; text?: string }[] | undefined): string {\n if (content === undefined || content.length === 0) return ''\n const first = content[0]\n if (typeof first?.text === 'string') return first.text\n return ''\n}\n\n/** Extract the user questions from a chat-node window, ordered by anchorSeq. */\nexport function extractQuestions(nodes: Iterable<ChatNodeLike>): QuestionNode[] {\n const out: QuestionNode[] = []\n for (const node of nodes) {\n if (!QUESTION_KINDS.includes(node.kind as (typeof QUESTION_KINDS)[number])) continue\n const payload = userData(node.data)\n out.push({\n key: node.key,\n anchorSeq: node.anchorSeq,\n seq: payload?.seq ?? -1,\n time: payload?.time ?? 0,\n // Full question text: shown in the hover tooltip (not truncated).\n text: messageText(payload?.content),\n })\n }\n out.sort((a, b) => a.anchorSeq - b.anchorSeq)\n return out\n}\n\n/** Whether a node is actually rendered (visible rows only are scroll targets). */\nexport function isRenderable(node: ChatNodeLike): boolean {\n return node.visibility !== 'hidden'\n}\n\n/** The row of the window that renders the given key (exact match). */\nexport function findQuestionRow(nodes: Iterable<ChatNodeLike>, key: string): ChatNodeLike | null {\n for (const node of nodes) {\n if (node.key === key) return node\n }\n return null\n}\n\n/** Nearest renderable row for a key that is absent or hidden (compaction, windowing). */\nexport function nearestRenderable(\n nodes: Iterable<{ key: string; anchorSeq: number; visibility?: string }>,\n excludeKey: string | undefined,\n): { key: string; anchorSeq: number } | null {\n let best: { key: string; anchorSeq: number } | null = null\n for (const node of nodes) {\n if (node.visibility === 'hidden') continue\n if (node.key === excludeKey) continue\n if (best === null || node.anchorSeq < best.anchorSeq) best = { key: node.key, anchorSeq: node.anchorSeq }\n }\n return best\n}\n","/**\n * Jump-to-question orchestration. Pure-ish: takes injected ports (snapshot\n * read, loadOlder, DOM row lookup, view liveness, scroll, timers) so the\n * paging/timeout/fallback loop is unit-testable without a real browser or\n * session. The browser half wires these ports to ctx.sessions + the DOM.\n */\n\nimport { nearestRenderable } from './nodes.ts'\n\n/** The bits of a session snapshot the jump loop needs. */\nexport interface JumpSnapshot {\n openState: string\n hasMore: boolean\n loadingOlder: boolean\n /** Renderable chat rows as a key->renderable map (or iterable of rows). */\n rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>\n}\n\nexport interface JumpPorts {\n /** Read the current snapshot; undefined when the session/view is unavailable. */\n snapshot: () => JumpSnapshot | undefined\n /** Expand the window backwards; rejects/throws on failure. */\n loadOlder: () => Promise<void>\n /** True while the chat view is active (a `[data-chat-flow]` is mounted). */\n isViewActive: () => boolean\n /** Find the DOM row for a chat anchor key; null when not rendered. */\n findRow: (key: string) => HTMLElement | null\n /** Scroll a row into view at the top. */\n scrollIntoView: (row: HTMLElement) => void\n /** Monotonic ms clock. */\n now: () => number\n /** Async sleep. */\n sleep: (ms: number) => Promise<void>\n /** Report a terminal failure to the caller (for a hint). */\n report?: (code: JumpFailureCode, fallback?: boolean) => void\n}\n\nexport type JumpFailureCode = 'VIEW_INACTIVE' | 'TARGET_HIDDEN' | 'NOT_FOUND' | 'TIMEOUT'\n\nexport interface JumpResult {\n ok: boolean\n code?: JumpFailureCode\n /** True when we landed on a fallback row rather than the exact target. */\n fallback?: boolean\n}\n\nexport interface JumpOptions {\n /** Total wall-clock budget for loadOlder paging. */\n totalTimeoutMs?: number\n /** Max loadOlder pages before giving up. */\n maxPages?: number\n /** Poll interval for the row to render after it is known to be in the window. */\n rowWaitMs?: number\n /** Poll interval for state transitions (loadingOlder / openState). */\n pollMs?: number\n}\n\nconst DEFAULTS = {\n totalTimeoutMs: 15_000,\n maxPages: 100,\n rowWaitMs: 8_000,\n pollMs: 60,\n}\n\nfunction minAnchorSeq(rows: Iterable<{ anchorSeq: number }>): number | null {\n let min: number | null = null\n for (const row of rows) {\n if (min === null || row.anchorSeq < min) min = row.anchorSeq\n }\n return min\n}\n\nfunction renderable(rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>): { key: string; anchorSeq: number }[] {\n const out: { key: string; anchorSeq: number }[] = []\n for (const row of rows) {\n if (row.visibility === 'hidden') continue\n out.push({ key: row.key, anchorSeq: row.anchorSeq })\n }\n return out\n}\n\n/**\n * Jump to the row for `key`, paging older content until it is rendered (or the\n * budget is exhausted). Falls back to the nearest renderable row when the\n * exact row is hidden/absent.\n */\nexport async function jumpToQuestion(ports: JumpPorts, key: string, options: JumpOptions = {}): Promise<JumpResult> {\n const cfg = { ...DEFAULTS, ...options }\n const fail = (code: JumpFailureCode, fallback = false): JumpResult => {\n ports.report?.(code, fallback)\n return fallback ? { ok: false, code, fallback: true } : { ok: false, code }\n }\n\n if (!ports.isViewActive()) return fail('VIEW_INACTIVE')\n\n const deadline = ports.now() + cfg.totalTimeoutMs\n let pages = 0\n\n // Phase 1: page older until the key appears in the loaded window.\n while (true) {\n const snap = ports.snapshot()\n if (snap === undefined) return fail('VIEW_INACTIVE')\n const rows = renderable(snap.rows)\n if (rows.some((r) => r.key === key)) break\n if (snap.openState !== 'open') {\n if (snap.openState === 'error' || ports.now() > deadline) {\n return fail(snap.openState === 'error' ? 'VIEW_INACTIVE' : 'TIMEOUT')\n }\n await ports.sleep(cfg.pollMs)\n continue\n }\n if (snap.hasMore !== true) return fail('NOT_FOUND')\n if (pages >= cfg.maxPages || ports.now() > deadline) return fail('TIMEOUT')\n if (snap.loadingOlder) {\n await ports.sleep(cfg.pollMs)\n continue\n }\n const before = minAnchorSeq(rows)\n await ports.loadOlder()\n pages += 1\n const afterSnap = ports.snapshot()\n const after = minAnchorSeq(afterSnap === undefined ? [] : afterSnap.rows)\n if (after === null || (before !== null && after >= before)) return fail('NOT_FOUND')\n }\n\n // Phase 2: wait for the row to render, then scroll. Fall back if hidden.\n const waitedFor = async (rowKey: string): Promise<HTMLElement | null> => {\n for (let waited = 0; waited <= cfg.rowWaitMs; waited += cfg.pollMs) {\n if (!ports.isViewActive()) return null\n const row = ports.findRow(rowKey)\n if (row !== null) return row\n await ports.sleep(cfg.pollMs)\n }\n return null\n }\n\n const row = await waitedFor(key)\n if (row !== null) {\n ports.scrollIntoView(row)\n return { ok: true }\n }\n\n const snap = ports.snapshot()\n const fallback = nearestRenderable(snap === undefined ? [] : snap.rows, key)\n if (fallback !== null) {\n const fbRow = await waitedFor(fallback.key)\n if (fbRow !== null) {\n ports.scrollIntoView(fbRow)\n return fail('TARGET_HIDDEN', true)\n }\n }\n return fail('TARGET_HIDDEN', false)\n}\n","/**\n * Browser-half entry for the dsh-question-nav plugin.\n *\n * Registers one surface into the frame-wide floating layer (`shell.overlay`):\n * a vertical strip on the LEFT edge of the conversation column listing every\n * user question in the current session as a small button, one dot per turn.\n * Clicking a button scrolls the chat to that turn's first question.\n *\n * The dots are driven by the host-folded `questionIndex` session projection\n * (registered by the plugin's host half): the projection registry folds the\n * WHOLE event log without touching the chat's paged render window, the\n * projection cache persists it, and the standard carriers (history tail-page\n * baseline + session/projection push frames) keep it live. Live-window\n * questions not yet recorded by the projection are merged on top so a\n * just-sent question appears immediately.\n *\n * Failure policy: nothing here throws at apply time — an external plugin must\n * never take the GUI down.\n */\nimport type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls ui-layout's SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\n// Type-only: pulls the locale plugin's Context merge (ctx.locale).\nimport type {} from '@deepseek-ai/dsh-client-locale/client'\n// Type-only: pulls the settings shell's SlotMap merge ('settings.plugins.tab')\n// and the ctx.settingsScope Context merge.\nimport type {} from '@deepseek-ai/dsh-client-ui-settings/client'\nimport type { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'\nimport { QuestionNavStrip, type ObservableFace, type QuestionNavInjected } from './QuestionNavStrip.tsx'\nimport { QuestionNavSettingsTab } from './QuestionNavSettingsTab.tsx'\nimport { QuestionNavSettingsController } from './settings.ts'\nimport { en, zh, type QuestionNavKey } from './locales.ts'\nimport { extractQuestions } from '../core/nodes.ts'\nimport { jumpToQuestion, type JumpFailureCode, type JumpPorts } from '../core/jump.ts'\n\n/** Locale namespace this plugin owns. */\nconst NS = 'question-nav'\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n /** question-nav surface copy. */\n 'question-nav': QuestionNavKey\n }\n}\n\n/** Services required by this plugin. */\nexport const inject = ['slots', 'locale', 'sessions']\n\n/** Single-instance guard: a duplicated client injection must not mount twice. */\ndeclare global {\n // eslint-disable-next-line no-var\n var __dshQuestionNavApplied: boolean | undefined\n}\n\nfunction claimApply(): boolean {\n if (globalThis.__dshQuestionNavApplied === true) return false\n globalThis.__dshQuestionNavApplied = true\n return true\n}\n\nfunction releaseApply(): void {\n globalThis.__dshQuestionNavApplied = undefined\n}\n\n/** Map the session snapshot to the jump-loop port surface. */\nfunction jumpPortsFor(ctx: ClientContext, sessionId: SessionId): JumpPorts {\n return {\n snapshot: () => {\n const binding = ctx.sessions.binding(sessionId)\n const snap = binding?.session.getSnapshot()\n if (snap === undefined) return undefined\n return {\n openState: snap.openState,\n hasMore: snap.hasMore,\n loadingOlder: snap.loadingOlder,\n rows: snap.chat.nodes.values(),\n }\n },\n loadOlder: async () => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) throw new Error('session unavailable')\n await binding.session.loadOlder()\n },\n isViewActive: () => document.querySelector('[data-chat-flow]') !== null,\n findRow: (key: string) => {\n for (const candidate of Array.from(document.querySelectorAll<HTMLElement>('[data-chat-anchor-key]'))) {\n if (candidate.dataset.chatAnchorKey === key) return candidate\n }\n return null\n },\n scrollIntoView: (row) => row.scrollIntoView({ block: 'start' }),\n now: () => Date.now(),\n sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)),\n }\n}\n\n/**\n * The session's `questionIndex` projection face (getSnapshot + subscribe).\n * Undefined when the session is not bound or the host unit is not registered\n * (e.g. a headless composition) — the strip then shows live-window dots only.\n */\nfunction questionProjectionOf(ctx: ClientContext, sessionId: SessionId): ObservableFace | undefined {\n const face = ctx.sessions.binding(sessionId)?.session.projections.faceOf('questionIndex')\n if (face === undefined) return undefined\n return {\n getSnapshot: () => face.getSnapshot(),\n subscribe: (listener) => face.subscribe(listener),\n }\n}\n\nfunction createInject(ctx: ClientContext, settings: QuestionNavSettingsController): QuestionNavInjected {\n return {\n readQuestions: (sessionId) => {\n const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot()\n if (snap === undefined) return []\n return extractQuestions(snap.chat.nodes.values())\n },\n subscribeList: (cb) => ctx.sessions.list.subscribe(cb),\n subscribeContent: (sessionId, cb) => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) return () => {}\n return binding.session.subscribe(cb)\n },\n questionProjection: (sessionId) => questionProjectionOf(ctx, sessionId),\n jump: (sessionId, key) => {\n const ports = jumpPortsFor(ctx, sessionId)\n ports.report = (code: JumpFailureCode) => {\n // Surface the failure through the component via a DOM event the\n // strip listens for; simplest reliable cross-boundary channel here.\n window.dispatchEvent(new CustomEvent('question-nav:jump-failed', { detail: code }))\n }\n void jumpToQuestion(ports, key)\n },\n align: () => settings.getSnapshot().align,\n subscribeAlign: (cb) => settings.subscribe(cb),\n setAlign: (align) => settings.setAlign(align),\n }\n}\n\n/**\n * Register the question-nav surface.\n * @param ctx - client root context.\n */\nexport function apply(ctx: ClientContext): void {\n if (!claimApply()) return\n ctx.effect(() => releaseApply, 'question-nav: apply claim')\n\n ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'question-nav: dictionaries')\n const t = ctx.locale.bind(NS)\n\n const settings = new QuestionNavSettingsController()\n const injected = createInject(ctx, settings)\n\n ctx.slots.inject('shell.overlay', () => ctx.slots.register({\n name: 'shell.overlay',\n id: 'question-nav',\n order: 900,\n locale: NS,\n inject: () => injected,\n }, QuestionNavStrip))\n\n // The settings surface is optional and may apply after this plugin, so the\n // namespace binds and the settings page mounts in a fiber that waits for\n // it. Without it the strip simply keeps the default left alignment.\n ctx.inject(['settingsScope'], (scopeCtx) => {\n settings.attach(scopeCtx.get('settingsScope') as SettingsScopeBinder)\n ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({\n name: 'settings.plugins.tab',\n id: 'question-nav',\n order: 100,\n label: () => t('settings.tab'),\n locale: NS,\n inject: () => injected,\n }, QuestionNavSettingsTab))\n })\n}\n"],"mappings":"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"}
@@ -21,10 +21,12 @@ export declare function focusTier(distance: number): number | null;
21
21
  /** Magnification scale for a dot at `distance`; 1 (base) outside the window. */
22
22
  export declare function focusScale(distance: number): number;
23
23
  /** Presentation metrics for the question card of a dot at `distance` from the
24
- * selected one. Cards stay crisp (no blur): the selected card is the widest,
25
- * brightest and shows every text line; each of its two neighbors on each side
26
- * is a slightly narrower, dimmer card clamped to fewer lines. Null outside
27
- * the window (no card). */
24
+ * selected one. The hierarchy is two-tier: the selected card is the focus
25
+ * (widest, full text, brand accent styled via the selected CSS class), the
26
+ * four neighbors are context cards (same clean surface, narrower, clamped to
27
+ * fewer lines). Cards are never dimmed or blurred: context must stay crisp
28
+ * and readable so it can be recognized — and clicked. Null outside the
29
+ * window (no card). */
28
30
  export interface FocusCardMetrics {
29
31
  /** Card width in px (narrows away from the selected card). */
30
32
  widthPx: number;
@@ -32,8 +34,6 @@ export interface FocusCardMetrics {
32
34
  fontSize: number;
33
35
  /** Max text lines before clamping (cascade cards). */
34
36
  maxLines: number;
35
- /** Brightness (1 = full, the selected card; dimmer away from it). */
36
- brightness: number;
37
37
  }
38
38
  export declare function focusCardMetrics(distance: number): FocusCardMetrics | null;
39
39
  /**
@@ -42,3 +42,17 @@ export declare function focusCardMetrics(distance: number): FocusCardMetrics | n
42
42
  * of range.
43
43
  */
44
44
  export declare function magnificationWindow(total: number, selected: number, radius?: number): number[];
45
+ /** Height (px) of the dot band's top/bottom fade zones, which double as hover
46
+ * auto-scroll areas (slightly larger than the 22px CSS mask fade). */
47
+ export declare const EDGE_SCROLL_ZONE = 26;
48
+ /** Edge auto-scroll speed range (px/s): MIN at the zone boundary, MAX at the
49
+ * very edge of the band. */
50
+ export declare const EDGE_SCROLL_MIN_SPEED = 90;
51
+ export declare const EDGE_SCROLL_MAX_SPEED = 420;
52
+ /**
53
+ * Auto-scroll speed (px/s) for a pointer at `ratio` depth into an edge fade
54
+ * zone (0 = at the zone boundary, 1 = at the band's very edge): eases
55
+ * linearly from MIN to MAX so a shallow probe scrolls gently and pushing
56
+ * into the edge moves fast. The sign (direction) is applied by the caller.
57
+ */
58
+ export declare function edgeScrollSpeed(ratio: number): number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@luziyang2026/dsh-question-nav",
3
3
  "description": "In-session question navigator for the DSH web GUI: a vertical minimap of round dots overlaid on the edge of the conversation column (left or right, configurable in settings), one dot per user question — hovering focuses a magnified window and a vertical cascade of crisp question cards showing the full text and sent time of each (any card is clickable), click jumps to that message.",
4
- "version": "0.6.0",
4
+ "version": "0.7.0",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.7.0",
7
7
  "engines": {
@@ -8,11 +8,12 @@
8
8
  * within that band when the session has more dots than fit.
9
9
  *
10
10
  * Hovering a dot "selects" it: the selected dot plus its two immediate
11
- * neighbors on each side magnify (progressively smaller with distance), the
12
- * selected dot reveals a frosted question card (turn label, full question
13
- * text, sent time) with a decorative stack of blurred cards fanning out
14
- * below it, and the rail auto-centers the selected dot. Clicking jumps the
15
- * chat to that turn's first question.
11
+ * neighbors on each side magnify (progressively smaller with distance), and a
12
+ * vertical cascade of question cards opens the selected (center) card is the
13
+ * focus (brand accent, elevated, full question text), the four neighbors are
14
+ * narrower context cards clamped to fewer lines. Every card is clickable and
15
+ * jumps to its question, exactly like clicking the dot; the rail auto-centers
16
+ * the selected dot.
16
17
  *
17
18
  * Data source: the host-folded `questionIndex` session projection (whole
18
19
  * history, persisted host-side, pushed live through session/projection
@@ -36,7 +37,7 @@ import type { QuestionEntry } from '../core/question-entry.ts'
36
37
  import { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'
37
38
  import type { AlignPreference } from '../core/align.ts'
38
39
  import type { JumpFailureCode } from '../core/jump.ts'
39
- import { FOCUS_RADIUS, focusCardMetrics, focusScale, focusTier } from '../core/focus.ts'
40
+ import { EDGE_SCROLL_ZONE, FOCUS_RADIUS, edgeScrollSpeed, focusCardMetrics, focusScale, focusTier } from '../core/focus.ts'
40
41
  import { formatQuestionTime } from '../core/time.ts'
41
42
  import type { QuestionNavKey } from './locales.ts'
42
43
  import styles from './question-nav.module.css'
@@ -150,6 +151,62 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
150
151
  const lastDotsRef = useRef<TurnDot[]>([])
151
152
  // Last focused dot key, so re-hovering the same dot after a gap re-centers it.
152
153
  const lastFocusedKeyRef = useRef<string | null>(null)
154
+ // Whether the dot band overflows its 60% clamp (drives the edge-fade mask
155
+ // and the hover auto-scroll).
156
+ const [scrollable, setScrollable] = useState(false)
157
+ // Edge auto-scroll state: the fade zones at the band's top/bottom edges are
158
+ // scroll affordances — hovering them scrolls the band so the faded dots
159
+ // flow into the clear area. Speed follows how deep the pointer is inside
160
+ // the zone; a rAF loop applies it smoothly while it is non-zero.
161
+ const edgeScrollRef = useRef<{ speed: number; raf: number; last: number }>({ speed: 0, raf: 0, last: 0 })
162
+
163
+ const stopEdgeScroll = (): void => {
164
+ edgeScrollRef.current.speed = 0
165
+ if (edgeScrollRef.current.raf !== 0) {
166
+ cancelAnimationFrame(edgeScrollRef.current.raf)
167
+ edgeScrollRef.current.raf = 0
168
+ }
169
+ }
170
+
171
+ const edgeTick = (now: number): void => {
172
+ const list = listRef.current
173
+ const state = edgeScrollRef.current
174
+ state.raf = 0
175
+ if (list === null || state.speed === 0) return
176
+ const dt = Math.min(64, now - state.last) / 1000
177
+ state.last = now
178
+ const before = list.scrollTop
179
+ list.scrollTop = before + state.speed * dt
180
+ // Stop at the scroll bounds — there is nothing more to reveal.
181
+ const atTop = list.scrollTop <= 0 && state.speed < 0
182
+ const atBottom = list.scrollTop >= list.scrollHeight - list.clientHeight - 1 && state.speed > 0
183
+ if (atTop || atBottom) {
184
+ state.speed = 0
185
+ return
186
+ }
187
+ state.raf = requestAnimationFrame(edgeTick)
188
+ }
189
+
190
+ const onListMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
191
+ const list = listRef.current
192
+ if (list === null) return
193
+ const rect = list.getBoundingClientRect()
194
+ const y = e.clientY - rect.top
195
+ const depthTop = EDGE_SCROLL_ZONE - y
196
+ const depthBottom = y - (rect.height - EDGE_SCROLL_ZONE)
197
+ let speed = 0
198
+ if (depthTop > 0 && list.scrollTop > 0) {
199
+ speed = -edgeScrollSpeed(depthTop / EDGE_SCROLL_ZONE)
200
+ } else if (depthBottom > 0 && list.scrollTop < list.scrollHeight - list.clientHeight - 1) {
201
+ speed = edgeScrollSpeed(depthBottom / EDGE_SCROLL_ZONE)
202
+ }
203
+ const state = edgeScrollRef.current
204
+ state.speed = speed
205
+ if (speed !== 0 && state.raf === 0) {
206
+ state.last = performance.now()
207
+ state.raf = requestAnimationFrame(edgeTick)
208
+ }
209
+ }
153
210
 
154
211
  // Focus persists briefly after leaving the rail, so the mouse can reach the
155
212
  // clickable cascade cards; entering a card cancels the clear, leaving
@@ -343,10 +400,26 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
343
400
  list.scrollTop = Math.max(0, Math.min(center, list.scrollHeight - list.clientHeight))
344
401
  }, [focus])
345
402
 
346
- // Clear any pending timers on unmount.
403
+ // Detect band overflow: drives the edge-fade mask + hover auto-scroll.
404
+ // Re-checked when the dots change and whenever the band itself resizes
405
+ // (the layout loop above clamps it to the conversation height).
406
+ useLayoutEffect(() => {
407
+ const list = listRef.current
408
+ if (list === null) return
409
+ const check = (): void => setScrollable(list.scrollHeight > list.clientHeight + 1)
410
+ check()
411
+ if (typeof ResizeObserver === 'undefined') return
412
+ const observer = new ResizeObserver(check)
413
+ observer.observe(list)
414
+ return () => observer.disconnect()
415
+ }, [dots, align])
416
+
417
+ // Clear any pending timers and the edge auto-scroll on unmount.
347
418
  useEffect(() => () => {
348
419
  if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)
349
420
  if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current)
421
+ stopEdgeScroll()
422
+ // eslint-disable-next-line react-hooks/exhaustive-deps
350
423
  }, [])
351
424
 
352
425
  if (!visible) return null
@@ -389,8 +462,12 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
389
462
  >
390
463
  <div
391
464
  ref={listRef}
392
- className={styles.list}
393
- onMouseLeave={scheduleClearFocus}
465
+ className={scrollable ? `${styles.list} ${styles.listScrollable}` : styles.list}
466
+ onMouseMove={scrollable ? onListMouseMove : undefined}
467
+ onMouseLeave={() => {
468
+ stopEdgeScroll()
469
+ scheduleClearFocus()
470
+ }}
394
471
  >
395
472
  {dots.length === 0 ? (
396
473
  <div className={styles.empty}>{t('strip.empty')}</div>
@@ -466,10 +543,7 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
466
543
  className={cls}
467
544
  role="button"
468
545
  tabIndex={-1}
469
- style={{
470
- width: metrics.widthPx,
471
- filter: `brightness(${metrics.brightness})`,
472
- }}
546
+ style={{ width: metrics.widthPx }}
473
547
  onClick={() => onJump(item.dot)}
474
548
  >
475
549
  {isSelected && item.dot.turn !== null ? (
@@ -25,7 +25,9 @@
25
25
  clamped to at most 60% of the conversation height (auto margins center the
26
26
  band) and scrolls from the top (auto margins collapse to 0 on overflow).
27
27
  The band itself is interactive so wheel-scrolling works over the gaps too
28
- (the rail above is pointer-events:none). */
28
+ (the rail above is pointer-events:none). The native scrollbar is always
29
+ hidden: this is a minimap scrolled by wheel/trackpad and by hovering the
30
+ fade zones, and overflow is signalled by the edge fade below. */
29
31
  .list {
30
32
  flex: 0 1 auto;
31
33
  min-height: 0;
@@ -39,9 +41,21 @@
39
41
  align-items: center;
40
42
  gap: 6px;
41
43
  padding: 8px 0;
42
- scrollbar-width: thin;
44
+ scrollbar-width: none;
43
45
  pointer-events: auto;
44
46
  }
47
+ .list::-webkit-scrollbar {
48
+ width: 0;
49
+ height: 0;
50
+ display: none;
51
+ }
52
+ /* Overflowing band (applied from JS when scrollHeight > clientHeight): dots
53
+ fade out towards both edges, signalling "more beyond" without a scrollbar.
54
+ The fade zones double as hover auto-scroll areas (see QuestionNavStrip). */
55
+ .listScrollable {
56
+ -webkit-mask-image: linear-gradient(to bottom, transparent, black 22px, black calc(100% - 22px), transparent);
57
+ mask-image: linear-gradient(to bottom, transparent, black 22px, black calc(100% - 22px), transparent);
58
+ }
45
59
  .list > *:first-child {
46
60
  margin-top: auto;
47
61
  }
@@ -130,19 +144,21 @@
130
144
  pointer-events: none;
131
145
  }
132
146
 
133
- /* One focus question card (portal-rendered in the cascade). Solid, crisp
134
- no blur with the width set per cascade tier. Clicking jumps to the
135
- question, like the dot. */
147
+ /* One focus question card (portal-rendered in the cascade). The four context
148
+ cards use the base style: a clean solid surface, a quiet neutral border and
149
+ a low shadow — crisp and readable, one visual step behind the focus card.
150
+ Clicking jumps to the question, like the dot; hover raises the border to
151
+ signal the card is interactive. */
136
152
  .card {
137
153
  width: 380px;
138
154
  background: var(--dsw-alias-bg-layer-1);
139
- border: 1px solid var(--dsw-alias-border-l1);
155
+ border: 1px solid var(--dsw-alias-border-l2);
140
156
  border-radius: 10px;
141
157
  padding: 8px 12px 10px;
142
158
  font-size: 13px;
143
159
  line-height: 18px;
144
- color: var(--dsw-alias-label-primary);
145
- box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
160
+ color: var(--dsw-alias-label-secondary);
161
+ box-shadow: 0 1px 6px rgba(0, 0, 0, 0.10);
146
162
  white-space: normal;
147
163
  word-break: break-word;
148
164
  pointer-events: auto;
@@ -151,13 +167,25 @@
151
167
  }
152
168
  .card:hover {
153
169
  border-color: var(--dsw-alias-brand-primary);
154
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.26);
170
+ box-shadow: 0 3px 12px rgba(0, 0, 0, 0.18);
171
+ color: var(--dsw-alias-label-primary);
155
172
  }
156
173
 
157
- /* The selected (center) card: marked brighter with a brand edge, so the
158
- center card is distinct from the four unselected cascade cards. */
174
+ /* The selected (center) card the focus of the cascade: full-contrast text,
175
+ a brand left accent bar (inset, so it follows the rounded corners) and a
176
+ raised shadow lift it above the four context cards. */
159
177
  .cardSelected {
178
+ border-color: var(--dsw-alias-border-l1);
179
+ color: var(--dsw-alias-label-primary);
180
+ box-shadow:
181
+ inset 3px 0 0 0 var(--dsw-alias-brand-primary),
182
+ 0 6px 20px rgba(0, 0, 0, 0.22);
183
+ }
184
+ .cardSelected:hover {
160
185
  border-color: var(--dsw-alias-brand-primary);
186
+ box-shadow:
187
+ inset 3px 0 0 0 var(--dsw-alias-brand-primary),
188
+ 0 6px 22px rgba(0, 0, 0, 0.26);
161
189
  }
162
190
 
163
191
  /* Turn label line at the top of the card ("Turn 32"). */
@@ -176,11 +204,11 @@
176
204
  }
177
205
 
178
206
  /* Clamped cascade-card text (the two neighbors on each side): N lines with
179
- ellipsis via -webkit-line-clamp (set inline). */
207
+ ellipsis via -webkit-line-clamp (set inline). Inherits the card's text
208
+ color (secondary on context cards, primary on the focus card). */
180
209
  .cardClamp {
181
210
  font-size: 13px;
182
211
  line-height: 18px;
183
- color: var(--dsw-alias-label-primary);
184
212
  display: -webkit-box;
185
213
  -webkit-box-orient: vertical;
186
214
  overflow: hidden;
package/src/core/focus.ts CHANGED
@@ -33,10 +33,12 @@ export function focusScale(distance: number): number {
33
33
  }
34
34
 
35
35
  /** Presentation metrics for the question card of a dot at `distance` from the
36
- * selected one. Cards stay crisp (no blur): the selected card is the widest,
37
- * brightest and shows every text line; each of its two neighbors on each side
38
- * is a slightly narrower, dimmer card clamped to fewer lines. Null outside
39
- * the window (no card). */
36
+ * selected one. The hierarchy is two-tier: the selected card is the focus
37
+ * (widest, full text, brand accent styled via the selected CSS class), the
38
+ * four neighbors are context cards (same clean surface, narrower, clamped to
39
+ * fewer lines). Cards are never dimmed or blurred: context must stay crisp
40
+ * and readable so it can be recognized — and clicked. Null outside the
41
+ * window (no card). */
40
42
  export interface FocusCardMetrics {
41
43
  /** Card width in px (narrows away from the selected card). */
42
44
  widthPx: number
@@ -44,8 +46,6 @@ export interface FocusCardMetrics {
44
46
  fontSize: number
45
47
  /** Max text lines before clamping (cascade cards). */
46
48
  maxLines: number
47
- /** Brightness (1 = full, the selected card; dimmer away from it). */
48
- brightness: number
49
49
  }
50
50
 
51
51
  export function focusCardMetrics(distance: number): FocusCardMetrics | null {
@@ -53,11 +53,11 @@ export function focusCardMetrics(distance: number): FocusCardMetrics | null {
53
53
  if (tier === null) return null
54
54
  switch (tier) {
55
55
  case 0:
56
- return { widthPx: 380, fontSize: 13, maxLines: 6, brightness: 1 }
56
+ return { widthPx: 380, fontSize: 13, maxLines: 6 }
57
57
  case 1:
58
- return { widthPx: 300, fontSize: 12.5, maxLines: 2, brightness: 0.82 }
58
+ return { widthPx: 300, fontSize: 12.5, maxLines: 2 }
59
59
  default:
60
- return { widthPx: 240, fontSize: 12, maxLines: 1, brightness: 0.68 }
60
+ return { widthPx: 240, fontSize: 12, maxLines: 1 }
61
61
  }
62
62
  }
63
63
 
@@ -74,3 +74,23 @@ export function magnificationWindow(total: number, selected: number, radius: num
74
74
  for (let i = lo; i <= hi; i++) out.push(i)
75
75
  return out
76
76
  }
77
+
78
+ /** Height (px) of the dot band's top/bottom fade zones, which double as hover
79
+ * auto-scroll areas (slightly larger than the 22px CSS mask fade). */
80
+ export const EDGE_SCROLL_ZONE = 26
81
+
82
+ /** Edge auto-scroll speed range (px/s): MIN at the zone boundary, MAX at the
83
+ * very edge of the band. */
84
+ export const EDGE_SCROLL_MIN_SPEED = 90
85
+ export const EDGE_SCROLL_MAX_SPEED = 420
86
+
87
+ /**
88
+ * Auto-scroll speed (px/s) for a pointer at `ratio` depth into an edge fade
89
+ * zone (0 = at the zone boundary, 1 = at the band's very edge): eases
90
+ * linearly from MIN to MAX so a shallow probe scrolls gently and pushing
91
+ * into the edge moves fast. The sign (direction) is applied by the caller.
92
+ */
93
+ export function edgeScrollSpeed(ratio: number): number {
94
+ const r = Math.max(0, Math.min(1, ratio))
95
+ return EDGE_SCROLL_MIN_SPEED + (EDGE_SCROLL_MAX_SPEED - EDGE_SCROLL_MIN_SPEED) * r
96
+ }