@luziyang2026/dsh-question-nav 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -98,9 +98,90 @@ window.__ModuleLoader__.load({
98
98
  }
99
99
  return out;
100
100
  }
101
+ /** Magnification scale per tier, by distance from the selected dot
102
+ * (0 = selected, 1 = immediate neighbor, 2 = outer window edge). */
103
+ const FOCUS_SCALES = [
104
+ 2,
105
+ 1.55,
106
+ 1.25
107
+ ];
108
+ /**
109
+ * The focus tier of a dot at `distance` from the selected dot: 0..FOCUS_RADIUS
110
+ * while inside the magnification window, null beyond it (base scale).
111
+ */
112
+ function focusTier(distance) {
113
+ const d = Math.abs(distance);
114
+ if (d > 2) return null;
115
+ return d;
116
+ }
117
+ /** Magnification scale for a dot at `distance`; 1 (base) outside the window. */
118
+ function focusScale(distance) {
119
+ const tier = focusTier(distance);
120
+ return tier === null ? 1 : FOCUS_SCALES[tier];
121
+ }
122
+ function focusCardMetrics(distance) {
123
+ const tier = focusTier(distance);
124
+ if (tier === null) return null;
125
+ switch (tier) {
126
+ case 0: return {
127
+ widthPx: 380,
128
+ fontSize: 13,
129
+ maxLines: 6,
130
+ brightness: 1
131
+ };
132
+ case 1: return {
133
+ widthPx: 300,
134
+ fontSize: 12.5,
135
+ maxLines: 2,
136
+ brightness: .82
137
+ };
138
+ default: return {
139
+ widthPx: 240,
140
+ fontSize: 12,
141
+ maxLines: 1,
142
+ brightness: .68
143
+ };
144
+ }
145
+ }
146
+ //#endregion
147
+ //#region src/core/time.ts
148
+ /**
149
+ * Question sent-time formatting for the question-nav surface. Pure: no DSH
150
+ * imports, deterministic for a given (time, now) pair — unit-testable in
151
+ * isolation and safe to inline into the client bundle.
152
+ *
153
+ * @module dsh-question-nav/time
154
+ */
155
+ function pad2(n) {
156
+ return n < 10 ? `0${n}` : String(n);
157
+ }
158
+ /** Whether `time` falls on the same calendar day as `now`. */
159
+ function isSameDay(time, now) {
160
+ const a = new Date(time);
161
+ const b = new Date(now);
162
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
163
+ }
164
+ /**
165
+ * Smart question sent-time, relative to `now`:
166
+ * - same calendar day → `HH:MM`
167
+ * - same calendar year → `MM-DD HH:MM`
168
+ * - otherwise → `YYYY-MM-DD HH:MM`
169
+ *
170
+ * Returns `''` for a missing/invalid timestamp (e.g. a live node that never
171
+ * reported one), so callers can hide the time line without branching.
172
+ */
173
+ function formatQuestionTime(time, now) {
174
+ if (!Number.isFinite(time) || time <= 0) return "";
175
+ const d = new Date(time);
176
+ const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
177
+ if (isSameDay(time, now)) return hm;
178
+ const md = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
179
+ if (d.getFullYear() === new Date(now).getFullYear()) return `${md} ${hm}`;
180
+ return `${d.getFullYear()}-${md} ${hm}`;
181
+ }
101
182
  //#endregion
102
183
  //#region \0dsh-css:dsh-question-nav/src/client/question-nav.module.css.mjs
103
- 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_list{scrollbar-width:thin;flex-direction:column;flex:1;align-items:center;gap:6px;width:100%;min-height: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{background:var(--dsw-alias-brand-primary);transform:scale(2)}.TWf_pa_dot.TWf_pa_active{background:var(--dsw-alias-brand-primary);transform:scale(1.6)}.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_tooltip{z-index:20;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);max-width:420px;color:var(--dsw-alias-label-primary);white-space:normal;word-break:break-word;pointer-events:none;border-radius:8px;padding:8px 12px;font-size:13px;line-height:18px;position:fixed;box-shadow:0 4px 16px #0003}.TWf_pa_tooltipTitle{color:var(--dsw-alias-label-tertiary);margin-bottom:4px;font-size:11px;font-weight:600}.TWf_pa_tooltipLine+.TWf_pa_tooltipLine{border-top:1px solid var(--dsw-alias-border-l1);margin-top:6px;padding-top:6px}";
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)}";
104
185
  const tagId = "@luziyang2026/dsh-question-nav/question-nav.module.css";
105
186
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
106
187
  const tag = document.createElement("style");
@@ -111,27 +192,47 @@ window.__ModuleLoader__.load({
111
192
  }
112
193
  var question_nav_module_css_default = {
113
194
  "active": "TWf_pa_active",
195
+ "card": "TWf_pa_card",
196
+ "cardBody": "TWf_pa_cardBody",
197
+ "cardClamp": "TWf_pa_cardClamp",
198
+ "cardLine": "TWf_pa_cardLine",
199
+ "cardSelected": "TWf_pa_cardSelected",
200
+ "cardTime": "TWf_pa_cardTime",
201
+ "cardTitle": "TWf_pa_cardTitle",
202
+ "cascade": "TWf_pa_cascade",
114
203
  "count": "TWf_pa_count",
115
204
  "dot": "TWf_pa_dot",
116
205
  "dots": "TWf_pa_dots",
117
206
  "empty": "TWf_pa_empty",
207
+ "focused": "TWf_pa_focused",
208
+ "hint": "TWf_pa_hint",
118
209
  "list": "TWf_pa_list",
119
210
  "rail": "TWf_pa_rail",
120
- "tooltip": "TWf_pa_tooltip",
121
- "tooltipLine": "TWf_pa_tooltipLine",
122
- "tooltipTitle": "TWf_pa_tooltipTitle"
211
+ "railRight": "TWf_pa_railRight",
212
+ "segment": "TWf_pa_segment",
213
+ "segmentActive": "TWf_pa_segmentActive",
214
+ "segmented": "TWf_pa_segmented",
215
+ "settings": "TWf_pa_settings",
216
+ "settingsDesc": "TWf_pa_settingsDesc",
217
+ "settingsTitle": "TWf_pa_settingsTitle"
123
218
  };
124
219
  //#endregion
125
220
  //#region src/client/QuestionNavStrip.tsx
126
221
  /**
127
222
  * Question-nav minimap. Renders a vertical column of small round dots overlaid
128
223
  * on the LEFT edge of the conversation column (via the frame-wide
129
- * `shell.overlay` floating layer), vertically centered: one dot per turn that
130
- * claimed at least one user question — strictly aligned with the Trajectory
131
- * view's turn numbering (turns without a question produce no dot). Hover
132
- * enlarges a dot and shows an instant tooltip (portal-rendered, no native
133
- * delay) with the turn label and the turn's question text(s); clicking jumps
134
- * the chat to that turn's first question.
224
+ * `shell.overlay` floating layer): one dot per turn that claimed at least one
225
+ * user question — strictly aligned with the Trajectory view's turn numbering
226
+ * (turns without a question produce no dot). The dot column is vertically
227
+ * centered and clamped to at most 60% of the conversation height, scrolling
228
+ * within that band when the session has more dots than fit.
229
+ *
230
+ * 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.
135
236
  *
136
237
  * Data source: the host-folded `questionIndex` session projection (whole
137
238
  * history, persisted host-side, pushed live through session/projection
@@ -160,8 +261,8 @@ window.__ModuleLoader__.load({
160
261
  return document.querySelector("[data-slot=\"conversation\"] > div[data-phase]");
161
262
  }
162
263
  /** Structural equality of two dot lists (member keys fully capture a dot's
163
- * folded questions, so identical key sequences mean identical content).
164
- * Lets the strip skip a re-render when a refresh produced no change. */
264
+ * folded questions, so identical key sequences mean identical content).
265
+ * Lets the strip skip a re-render when a refresh produced no change. */
165
266
  function sameDots(a, b) {
166
267
  if (a.length !== b.length) return false;
167
268
  for (let i = 0; i < a.length; i++) {
@@ -179,10 +280,25 @@ window.__ModuleLoader__.load({
179
280
  const [dots, setDots] = (0, react.useState)([]);
180
281
  const [jumpingKey, setJumpingKey] = (0, react.useState)(null);
181
282
  const [hint, setHint] = (0, react.useState)(null);
182
- const [tooltip, setTooltip] = (0, react.useState)(null);
283
+ const [focus, setFocus] = (0, react.useState)(null);
284
+ const [align, setAlign] = (0, react.useState)(() => props.align());
183
285
  const panelRef = (0, react.useRef)(null);
286
+ const listRef = (0, react.useRef)(null);
184
287
  const hintTimerRef = (0, react.useRef)(null);
288
+ const clearFocusTimerRef = (0, react.useRef)(null);
185
289
  const lastDotsRef = (0, react.useRef)([]);
290
+ const lastFocusedKeyRef = (0, react.useRef)(null);
291
+ const cancelClearFocus = () => {
292
+ if (clearFocusTimerRef.current !== null) {
293
+ window.clearTimeout(clearFocusTimerRef.current);
294
+ clearFocusTimerRef.current = null;
295
+ }
296
+ };
297
+ const scheduleClearFocus = () => {
298
+ cancelClearFocus();
299
+ clearFocusTimerRef.current = window.setTimeout(() => setFocus(null), 240);
300
+ };
301
+ (0, react.useEffect)(() => props.subscribeAlign(() => setAlign(props.align())), [props]);
186
302
  const showHint = (message) => {
187
303
  setHint(message);
188
304
  if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
@@ -256,11 +372,21 @@ window.__ModuleLoader__.load({
256
372
  if (convRect.height <= 0 || convRect.width <= 0) return true;
257
373
  const top = `${convRect.top - frameRect.top}px`;
258
374
  const height = `${convRect.height}px`;
375
+ if (align === "right") {
376
+ const right = `${frameRect.right - convRect.right}px`;
377
+ if (panel.style.top === top && panel.style.height === height && panel.style.right === right && panel.style.left === "") return false;
378
+ panel.style.top = top;
379
+ panel.style.height = height;
380
+ panel.style.right = right;
381
+ panel.style.left = "";
382
+ return true;
383
+ }
259
384
  const left = `${convRect.left - frameRect.left}px`;
260
- if (panel.style.top === top && panel.style.height === height && panel.style.left === left) return false;
385
+ if (panel.style.top === top && panel.style.height === height && panel.style.left === left && panel.style.right === "") return false;
261
386
  panel.style.top = top;
262
387
  panel.style.height = height;
263
388
  panel.style.left = left;
389
+ panel.style.right = "";
264
390
  return true;
265
391
  };
266
392
  const loop = () => {
@@ -295,9 +421,22 @@ window.__ModuleLoader__.load({
295
421
  observer?.disconnect();
296
422
  window.removeEventListener("resize", wake);
297
423
  };
298
- }, [visible]);
424
+ }, [visible, align]);
425
+ (0, react.useLayoutEffect)(() => {
426
+ const key = focus?.key ?? null;
427
+ if (lastFocusedKeyRef.current === key) return;
428
+ lastFocusedKeyRef.current = key;
429
+ if (key === null) return;
430
+ const list = listRef.current;
431
+ if (list === null) return;
432
+ const target = list.querySelector("[data-question-nav-focused=\"true\"]");
433
+ if (target === null) return;
434
+ const center = target.offsetTop - list.offsetTop - (list.clientHeight - target.offsetHeight) / 2;
435
+ list.scrollTop = Math.max(0, Math.min(center, list.scrollHeight - list.clientHeight));
436
+ }, [focus]);
299
437
  (0, react.useEffect)(() => () => {
300
438
  if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current);
439
+ if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current);
301
440
  }, []);
302
441
  if (!visible) return null;
303
442
  const onJump = (dot) => {
@@ -306,28 +445,37 @@ window.__ModuleLoader__.load({
306
445
  props.jump(current, dot.key);
307
446
  window.setTimeout(() => setJumpingKey((k) => k === dot.key ? null : k), 600);
308
447
  };
309
- const openTooltip = (dot, target) => {
448
+ const openFocus = (dot, target) => {
449
+ const index = dots.findIndex((d) => d.key === dot.key);
450
+ if (index < 0) return;
451
+ cancelClearFocus();
310
452
  const r = target.getBoundingClientRect();
311
- setTooltip({
312
- title: dot.turn === null ? null : `Turn ${dot.turn}`,
313
- lines: dot.texts,
314
- left: r.right + 10,
315
- top: r.top
453
+ const horizontal = align === "right" ? { right: window.innerWidth - r.left + 10 } : { left: r.right + 10 };
454
+ const lo = Math.max(0, index - 2);
455
+ const hi = Math.min(dots.length - 1, index + 2);
456
+ const items = [];
457
+ for (let i = lo; i <= hi; i++) items.push({
458
+ dot: dots[i],
459
+ distance: Math.abs(i - index)
460
+ });
461
+ setFocus({
462
+ key: dot.key,
463
+ items,
464
+ centerY: r.top + r.height / 2,
465
+ ...horizontal
316
466
  });
317
467
  };
318
468
  const t = props.t;
469
+ const selectedIndex = focus === null ? -1 : dots.findIndex((d) => d.key === focus.key);
319
470
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
320
471
  ref: panelRef,
321
- className: question_nav_module_css_default.rail,
472
+ className: align === "right" ? `${question_nav_module_css_default.rail} ${question_nav_module_css_default.railRight}` : question_nav_module_css_default.rail,
322
473
  "data-question-nav": "rail",
323
474
  children: [
324
- hint !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
325
- className: question_nav_module_css_default.hint,
326
- role: "status",
327
- children: hint
328
- }) : null,
329
475
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
476
+ ref: listRef,
330
477
  className: question_nav_module_css_default.list,
478
+ onMouseLeave: scheduleClearFocus,
331
479
  children: dots.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
332
480
  className: question_nav_module_css_default.empty,
333
481
  children: t("strip.empty")
@@ -336,33 +484,217 @@ window.__ModuleLoader__.load({
336
484
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
337
485
  className: question_nav_module_css_default.count,
338
486
  children: dots.length
339
- }), dots.map((dot) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
340
- className: jumpingKey === dot.key ? `${question_nav_module_css_default.dot} ${question_nav_module_css_default.active}` : question_nav_module_css_default.dot,
341
- "aria-label": dot.texts[0] ?? "",
342
- onMouseEnter: (e) => openTooltip(dot, e.currentTarget),
343
- onMouseLeave: () => setTooltip(null),
344
- onClick: () => onJump(dot)
345
- }, dot.key))]
487
+ }), dots.map((dot, index) => {
488
+ const isFocused = focus !== null && focus.key === dot.key;
489
+ const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex);
490
+ const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1;
491
+ const cls = [question_nav_module_css_default.dot];
492
+ if (isFocused) cls.push(question_nav_module_css_default.focused);
493
+ if (jumpingKey === dot.key) cls.push(question_nav_module_css_default.active);
494
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
495
+ className: cls.join(" "),
496
+ style: { transform: `scale(${scale})` },
497
+ "data-question-nav-focused": isFocused ? "true" : void 0,
498
+ "data-question-nav-index": index,
499
+ "aria-label": dot.texts[0] ?? "",
500
+ onMouseEnter: (e) => openFocus(dot, e.currentTarget),
501
+ onClick: () => onJump(dot)
502
+ }, dot.key);
503
+ })]
346
504
  })
347
505
  }),
348
- tooltip !== null ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
349
- className: question_nav_module_css_default.tooltip,
506
+ hint !== null ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
507
+ className: question_nav_module_css_default.hint,
508
+ style: align === "right" ? {
509
+ right: 60,
510
+ top: 20
511
+ } : {
512
+ left: 60,
513
+ top: 20
514
+ },
515
+ children: hint
516
+ }), document.body) : null,
517
+ focus !== null ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
518
+ className: question_nav_module_css_default.cascade,
350
519
  style: {
351
- left: tooltip.left,
352
- top: tooltip.top
520
+ ...focus.left !== void 0 ? { left: focus.left } : {},
521
+ ...focus.right !== void 0 ? { right: focus.right } : {},
522
+ top: focus.centerY,
523
+ transform: "translateY(-50%)"
353
524
  },
354
- children: [tooltip.title !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
355
- className: question_nav_module_css_default.tooltipTitle,
356
- children: tooltip.title
357
- }) : null, tooltip.lines.map((line, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
358
- className: question_nav_module_css_default.tooltipLine,
359
- children: line
360
- }, index))]
525
+ onMouseEnter: cancelClearFocus,
526
+ onMouseLeave: scheduleClearFocus,
527
+ children: focus.items.map((item) => {
528
+ const metrics = focusCardMetrics(item.distance);
529
+ if (metrics === null) return null;
530
+ const isSelected = item.distance === 0;
531
+ const text = item.dot.texts.join(" · ");
532
+ const cls = isSelected ? `${question_nav_module_css_default.card} ${question_nav_module_css_default.cardSelected}` : question_nav_module_css_default.card;
533
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
534
+ className: cls,
535
+ role: "button",
536
+ tabIndex: -1,
537
+ style: {
538
+ width: metrics.widthPx,
539
+ filter: `brightness(${metrics.brightness})`
540
+ },
541
+ onClick: () => onJump(item.dot),
542
+ children: [
543
+ isSelected && item.dot.turn !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
544
+ className: question_nav_module_css_default.cardTitle,
545
+ children: ["Turn ", item.dot.turn]
546
+ }) : null,
547
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
548
+ className: isSelected ? question_nav_module_css_default.cardBody : question_nav_module_css_default.cardClamp,
549
+ style: isSelected ? void 0 : {
550
+ WebkitLineClamp: metrics.maxLines,
551
+ fontSize: metrics.fontSize
552
+ },
553
+ children: isSelected ? item.dot.texts.map((line, lineIndex) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
554
+ className: question_nav_module_css_default.cardLine,
555
+ children: line
556
+ }, lineIndex)) : text
557
+ }),
558
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
559
+ className: question_nav_module_css_default.cardTime,
560
+ children: formatQuestionTime(item.dot.time, Date.now())
561
+ })
562
+ ]
563
+ }, item.dot.key);
564
+ })
361
565
  }), document.body) : null
362
566
  ]
363
567
  });
364
568
  }
365
569
  //#endregion
570
+ //#region src/core/align.ts
571
+ /**
572
+ * Rail anchor-edge constants shared by the host schema and the browser
573
+ * settings scope. Pure data: no DSH imports, so the client bundle may inline
574
+ * this module (a Host import here would leak into the browser half).
575
+ *
576
+ * @module dsh-question-nav/align
577
+ */
578
+ /** Supported rail anchor edges. */
579
+ const ALIGN_OPTIONS = ["left", "right"];
580
+ /** Default anchor edge when the user-settings document has no override. */
581
+ const DEFAULT_ALIGN = "left";
582
+ /** Settings namespace owned by this plugin (spelled here rather than
583
+ * imported: the client bundle must not depend on a Host package). */
584
+ const QUESTION_NAV_SETTINGS_NS = "question-nav";
585
+ /** Field carrying the selected anchor edge. */
586
+ const ALIGN_FIELD = "align";
587
+ //#endregion
588
+ //#region src/client/QuestionNavSettingsTab.tsx
589
+ /**
590
+ * The plugin's settings page inside the shell's Plugins section
591
+ * (`settings.plugins.tab`): a segmented control choosing which edge of the
592
+ * conversation column the dot rail anchors to. The choice is written to the
593
+ * `question-nav` settings namespace (registered by the host half); the strip
594
+ * re-anchors live when the snapshot changes.
595
+ *
596
+ * @module dsh-question-nav/client/settings-tab
597
+ */
598
+ /** Re-render on settings snapshot changes (the register inject face is static). */
599
+ function useAlignTick(subscribe) {
600
+ const [, bump] = (0, react.useState)(0);
601
+ (0, react.useEffect)(() => subscribe(() => bump((n) => n + 1)), [subscribe]);
602
+ }
603
+ function QuestionNavSettingsTab(props) {
604
+ useAlignTick(props.subscribeAlign);
605
+ const align = props.align();
606
+ const t = props.t;
607
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
608
+ className: question_nav_module_css_default.settings,
609
+ children: [
610
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
611
+ className: question_nav_module_css_default.settingsTitle,
612
+ children: t("settings.align.title")
613
+ }),
614
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
615
+ className: question_nav_module_css_default.settingsDesc,
616
+ children: t("settings.align.desc")
617
+ }),
618
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
619
+ className: question_nav_module_css_default.segmented,
620
+ role: "radiogroup",
621
+ "aria-label": t("settings.align.title"),
622
+ children: ALIGN_OPTIONS.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
623
+ type: "button",
624
+ role: "radio",
625
+ "aria-checked": align === option,
626
+ className: align === option ? `${question_nav_module_css_default.segment} ${question_nav_module_css_default.segmentActive}` : question_nav_module_css_default.segment,
627
+ onClick: () => props.setAlign(option),
628
+ children: t(option === "left" ? "settings.align.left" : "settings.align.right")
629
+ }, option))
630
+ })
631
+ ]
632
+ });
633
+ }
634
+ //#endregion
635
+ //#region src/client/settings.ts
636
+ /** Narrow a raw section to the anchor field. */
637
+ function isAlignPreference(value) {
638
+ return ALIGN_OPTIONS.some((option) => option === value);
639
+ }
640
+ /** Reactive handle over the plugin's durable settings section. */
641
+ var QuestionNavSettingsController = class {
642
+ scope;
643
+ listeners = /* @__PURE__ */ new Set();
644
+ unsubscribe = () => {};
645
+ state = {
646
+ align: DEFAULT_ALIGN,
647
+ overridden: false
648
+ };
649
+ /**
650
+ * Bind the namespace scope once the settings surface is present. Called
651
+ * from a fiber that injects `settingsScope`, so the scope subscription
652
+ * lives on that fiber and is released with it. A no-op after the first
653
+ * bind.
654
+ * @param binder - the settings scope service.
655
+ */
656
+ attach(binder) {
657
+ if (this.scope !== void 0) return;
658
+ this.scope = binder.bind({ namespace: QUESTION_NAV_SETTINGS_NS });
659
+ this.state = this.derive(this.scope.getSnapshot());
660
+ this.unsubscribe = this.scope.subscribe(() => {
661
+ if (this.scope === void 0) return;
662
+ const next = this.derive(this.scope.getSnapshot());
663
+ if (next.align === this.state.align && next.overridden === this.state.overridden) return;
664
+ this.state = next;
665
+ for (const listener of this.listeners) listener();
666
+ });
667
+ }
668
+ derive(snapshot) {
669
+ const user = snapshot.user;
670
+ return {
671
+ align: snapshot.status === "ready" && isAlignPreference(snapshot.value?.align) ? snapshot.value.align : DEFAULT_ALIGN,
672
+ overridden: user !== void 0 && user.align !== void 0
673
+ };
674
+ }
675
+ /** Release the scope subscription (bound on the settings fiber's lifecycle). */
676
+ dispose() {
677
+ this.unsubscribe();
678
+ this.listeners.clear();
679
+ }
680
+ /** @returns the current state (stable reference until the next change). */
681
+ getSnapshot() {
682
+ return this.state;
683
+ }
684
+ /** Observe state replacements; returns the disposer. */
685
+ subscribe(listener) {
686
+ this.listeners.add(listener);
687
+ return () => {
688
+ this.listeners.delete(listener);
689
+ };
690
+ }
691
+ /** Route the user's anchor-edge choice to the Host document. */
692
+ setAlign(align) {
693
+ if (this.scope === void 0) return;
694
+ this.scope.set(ALIGN_FIELD, align);
695
+ }
696
+ };
697
+ //#endregion
366
698
  //#region src/client/locales.ts
367
699
  /**
368
700
  * Locale dictionaries for the question-nav surface (zh/en). Registered under
@@ -373,14 +705,24 @@ window.__ModuleLoader__.load({
373
705
  "jump.inactive": "聊天视图未激活",
374
706
  "jump.hidden": "目标无独立气泡,已定位到邻近内容",
375
707
  "jump.notfound": "目标未加载或不存在(可能已压缩)",
376
- "jump.timeout": "加载历史超时,可重试"
708
+ "jump.timeout": "加载历史超时,可重试",
709
+ "settings.tab": "提问导航",
710
+ "settings.align.title": "导航条对齐",
711
+ "settings.align.desc": "选择圆点导航条锚定在对话栏的哪一侧。",
712
+ "settings.align.left": "左侧",
713
+ "settings.align.right": "右侧"
377
714
  };
378
715
  const en = {
379
716
  "strip.empty": "No questions in this session yet",
380
717
  "jump.inactive": "Chat view is not active",
381
718
  "jump.hidden": "No dedicated bubble; landed on nearby content",
382
719
  "jump.notfound": "Target not loaded or missing (maybe compacted)",
383
- "jump.timeout": "Timed out loading history; retry"
720
+ "jump.timeout": "Timed out loading history; retry",
721
+ "settings.tab": "Question Nav",
722
+ "settings.align.title": "Rail alignment",
723
+ "settings.align.desc": "Choose which edge of the conversation column the dot rail anchors to.",
724
+ "settings.align.left": "Left",
725
+ "settings.align.right": "Right"
384
726
  };
385
727
  //#endregion
386
728
  //#region src/core/nodes.ts
@@ -588,7 +930,7 @@ window.__ModuleLoader__.load({
588
930
  subscribe: (listener) => face.subscribe(listener)
589
931
  };
590
932
  }
591
- function createInject(ctx) {
933
+ function createInject(ctx, settings) {
592
934
  return {
593
935
  readQuestions: (sessionId) => {
594
936
  const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot();
@@ -608,7 +950,10 @@ window.__ModuleLoader__.load({
608
950
  window.dispatchEvent(new CustomEvent("question-nav:jump-failed", { detail: code }));
609
951
  };
610
952
  jumpToQuestion(ports, key);
611
- }
953
+ },
954
+ align: () => settings.getSnapshot().align,
955
+ subscribeAlign: (cb) => settings.subscribe(cb),
956
+ setAlign: (align) => settings.setAlign(align)
612
957
  };
613
958
  }
614
959
  /**
@@ -622,7 +967,9 @@ window.__ModuleLoader__.load({
622
967
  zh,
623
968
  en
624
969
  }), "question-nav: dictionaries");
625
- const injected = createInject(ctx);
970
+ const t = ctx.locale.bind(NS);
971
+ const settings = new QuestionNavSettingsController();
972
+ const injected = createInject(ctx, settings);
626
973
  ctx.slots.inject("shell.overlay", () => ctx.slots.register({
627
974
  name: "shell.overlay",
628
975
  id: "question-nav",
@@ -630,6 +977,17 @@ window.__ModuleLoader__.load({
630
977
  locale: NS,
631
978
  inject: () => injected
632
979
  }, QuestionNavStrip));
980
+ ctx.inject(["settingsScope"], (scopeCtx) => {
981
+ settings.attach(scopeCtx.get("settingsScope"));
982
+ ctx.slots.inject("settings.plugins.tab", () => ctx.slots.register({
983
+ name: "settings.plugins.tab",
984
+ id: "question-nav",
985
+ order: 100,
986
+ label: () => t("settings.tab"),
987
+ locale: NS,
988
+ inject: () => injected
989
+ }, QuestionNavSettingsTab));
990
+ });
633
991
  }
634
992
  //#endregion
635
993
  exports.apply = apply;