@luziyang2026/dsh-question-nav 0.5.0 → 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.
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * Question-nav minimap. Renders a vertical column of small round dots overlaid
3
3
  * on the LEFT edge of the conversation column (via the frame-wide
4
- * `shell.overlay` floating layer), vertically centered: one dot per turn that
5
- * claimed at least one user question — strictly aligned with the Trajectory
6
- * view's turn numbering (turns without a question produce no dot). Hover
7
- * enlarges a dot and shows an instant tooltip (portal-rendered, no native
8
- * delay) with the turn label and the turn's question text(s); clicking jumps
9
- * the chat to that turn's first question.
4
+ * `shell.overlay` floating layer): one dot per turn that claimed at least one
5
+ * user question — strictly aligned with the Trajectory view's turn numbering
6
+ * (turns without a question produce no dot). The dot column is vertically
7
+ * centered and clamped to at most 60% of the conversation height, scrolling
8
+ * within that band when the session has more dots than fit.
9
+ *
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.
10
16
  *
11
17
  * Data source: the host-folded `questionIndex` session projection (whole
12
18
  * history, persisted host-side, pushed live through session/projection
@@ -30,6 +36,8 @@ import type { QuestionEntry } from '../core/question-entry.ts'
30
36
  import { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'
31
37
  import type { AlignPreference } from '../core/align.ts'
32
38
  import type { JumpFailureCode } from '../core/jump.ts'
39
+ import { FOCUS_RADIUS, focusCardMetrics, focusScale, focusTier } from '../core/focus.ts'
40
+ import { formatQuestionTime } from '../core/time.ts'
33
41
  import type { QuestionNavKey } from './locales.ts'
34
42
  import styles from './question-nav.module.css'
35
43
 
@@ -70,16 +78,25 @@ const FAILURE_HINTS: Record<JumpFailureCode, QuestionNavKey> = {
70
78
  TIMEOUT: 'jump.timeout',
71
79
  }
72
80
 
73
- /** Live position of the instant hover tooltip. */
74
- interface TooltipState {
75
- /** Turn label line (e.g. "Turn 32"); null for ungrouped live questions. */
76
- title: string | null
77
- /** Question text lines (one per question folded into the dot). */
78
- lines: readonly string[]
81
+ /** One card of the focus cascade: a dot inside the magnification window. */
82
+ interface FocusWindowItem {
83
+ dot: TurnDot
84
+ /** Distance from the selected dot (0 = selected, 1/2 = cascade neighbors). */
85
+ distance: number
86
+ }
87
+
88
+ /** The focus cascade: a vertical column of one card per window dot,
89
+ * non-overlapping, centered on the selected dot. */
90
+ interface FocusState {
91
+ /** The selected (hovered) dot's key. */
92
+ key: string
93
+ /** Every dot of the focus window, in dot order. */
94
+ items: FocusWindowItem[]
95
+ /** Vertical center (viewport px) of the selected dot — the column centers on it. */
96
+ centerY: number
79
97
  /** Horizontal anchor — left edge (left-aligned rail) or right edge (right-aligned). */
80
98
  left?: number
81
99
  right?: number
82
- top: number
83
100
  }
84
101
 
85
102
  /** Read the projection face value as a question-entry list (structural guard). */
@@ -98,8 +115,8 @@ function findConvRoot(): HTMLElement | null {
98
115
  }
99
116
 
100
117
  /** Structural equality of two dot lists (member keys fully capture a dot's
101
- * folded questions, so identical key sequences mean identical content).
102
- * Lets the strip skip a re-render when a refresh produced no change. */
118
+ * folded questions, so identical key sequences mean identical content).
119
+ * Lets the strip skip a re-render when a refresh produced no change. */
103
120
  function sameDots(a: readonly TurnDot[], b: readonly TurnDot[]): boolean {
104
121
  if (a.length !== b.length) return false
105
122
  for (let i = 0; i < a.length; i++) {
@@ -121,12 +138,32 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
121
138
  const [dots, setDots] = useState<TurnDot[]>([])
122
139
  const [jumpingKey, setJumpingKey] = useState<string | null>(null)
123
140
  const [hint, setHint] = useState<string | null>(null)
124
- const [tooltip, setTooltip] = useState<TooltipState | null>(null)
141
+ const [focus, setFocus] = useState<FocusState | null>(null)
125
142
  const [align, setAlign] = useState<AlignPreference>(() => props.align())
126
143
  const panelRef = useRef<HTMLDivElement | null>(null)
144
+ const listRef = useRef<HTMLDivElement | null>(null)
127
145
  const hintTimerRef = useRef<number | null>(null)
146
+ // Pending focus-clear timer: lets the mouse move from the rail onto a
147
+ // cascade card without dropping the cascade mid-flight.
148
+ const clearFocusTimerRef = useRef<number | null>(null)
128
149
  // Last rendered dot list, for the change-detection bail-out below.
129
150
  const lastDotsRef = useRef<TurnDot[]>([])
151
+ // Last focused dot key, so re-hovering the same dot after a gap re-centers it.
152
+ const lastFocusedKeyRef = useRef<string | null>(null)
153
+
154
+ // Focus persists briefly after leaving the rail, so the mouse can reach the
155
+ // clickable cascade cards; entering a card cancels the clear, leaving
156
+ // everything re-arms it.
157
+ const cancelClearFocus = (): void => {
158
+ if (clearFocusTimerRef.current !== null) {
159
+ window.clearTimeout(clearFocusTimerRef.current)
160
+ clearFocusTimerRef.current = null
161
+ }
162
+ }
163
+ const scheduleClearFocus = (): void => {
164
+ cancelClearFocus()
165
+ clearFocusTimerRef.current = window.setTimeout(() => setFocus(null), 240)
166
+ }
130
167
 
131
168
  // Follow the rail anchor edge from the settings scope (a change re-anchors
132
169
  // the rail through the layout effect below).
@@ -289,9 +326,27 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
289
326
  }
290
327
  }, [visible, align])
291
328
 
292
- // Clear any pending hint timer on unmount.
329
+ // Keep the focused dot centered in the (≤60% tall, scrollable) band so its
330
+ // two neighbors on each side stay visible. Re-centers only when the focused
331
+ // key changes; scrolls instantly to avoid chasing a fast-moving hover.
332
+ useLayoutEffect(() => {
333
+ const key = focus?.key ?? null
334
+ if (lastFocusedKeyRef.current === key) return
335
+ lastFocusedKeyRef.current = key
336
+ if (key === null) return
337
+ const list = listRef.current
338
+ if (list === null) return
339
+ const target = list.querySelector<HTMLElement>('[data-question-nav-focused="true"]')
340
+ if (target === null) return
341
+ const delta = target.offsetTop - list.offsetTop
342
+ const center = delta - (list.clientHeight - target.offsetHeight) / 2
343
+ list.scrollTop = Math.max(0, Math.min(center, list.scrollHeight - list.clientHeight))
344
+ }, [focus])
345
+
346
+ // Clear any pending timers on unmount.
293
347
  useEffect(() => () => {
294
348
  if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)
349
+ if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current)
295
350
  }, [])
296
351
 
297
352
  if (!visible) return null
@@ -303,21 +358,28 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
303
358
  window.setTimeout(() => setJumpingKey((k) => (k === dot.key ? null : k)), 600)
304
359
  }
305
360
 
306
- const openTooltip = (dot: TurnDot, target: HTMLElement): void => {
361
+ const openFocus = (dot: TurnDot, target: HTMLElement): void => {
362
+ const index = dots.findIndex((d) => d.key === dot.key)
363
+ if (index < 0) return
364
+ cancelClearFocus()
307
365
  const r = target.getBoundingClientRect()
308
- setTooltip({
309
- title: dot.turn === null ? null : `Turn ${dot.turn}`,
310
- lines: dot.texts,
311
- // The tooltip opens away from the rail: to the right of the dot on a
312
- // left-anchored rail, to the left on a right-anchored one.
313
- ...(align === 'right'
314
- ? { right: window.innerWidth - r.left + 10 }
315
- : { left: r.right + 10 }),
316
- top: r.top,
317
- })
366
+ // The cascade opens away from the rail: to the right of the dots on a
367
+ // left-anchored rail, to the left on a right-anchored one. The column is
368
+ // vertically centered on the selected dot (top: centerY + translateY(-50%)).
369
+ const horizontal = align === 'right'
370
+ ? { right: window.innerWidth - r.left + 10 }
371
+ : { left: r.right + 10 }
372
+ const lo = Math.max(0, index - FOCUS_RADIUS)
373
+ const hi = Math.min(dots.length - 1, index + FOCUS_RADIUS)
374
+ const items: FocusWindowItem[] = []
375
+ for (let i = lo; i <= hi; i++) {
376
+ items.push({ dot: dots[i], distance: Math.abs(i - index) })
377
+ }
378
+ setFocus({ key: dot.key, items, centerY: r.top + r.height / 2, ...horizontal })
318
379
  }
319
380
 
320
381
  const t = props.t
382
+ const selectedIndex = focus === null ? -1 : dots.findIndex((d) => d.key === focus.key)
321
383
 
322
384
  return (
323
385
  <div
@@ -325,33 +387,112 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
325
387
  className={align === 'right' ? `${styles.rail} ${styles.railRight}` : styles.rail}
326
388
  data-question-nav="rail"
327
389
  >
328
- {hint !== null ? <div className={styles.hint} role="status">{hint}</div> : null}
329
- <div className={styles.list}>
390
+ <div
391
+ ref={listRef}
392
+ className={styles.list}
393
+ onMouseLeave={scheduleClearFocus}
394
+ >
330
395
  {dots.length === 0 ? (
331
396
  <div className={styles.empty}>{t('strip.empty')}</div>
332
397
  ) : (
333
398
  <div className={styles.dots}>
334
399
  <span className={styles.count}>{dots.length}</span>
335
- {dots.map((dot) => (
336
- <button
337
- key={dot.key}
338
- className={jumpingKey === dot.key ? `${styles.dot} ${styles.active}` : styles.dot}
339
- aria-label={dot.texts[0] ?? ''}
340
- onMouseEnter={(e) => openTooltip(dot, e.currentTarget)}
341
- onMouseLeave={() => setTooltip(null)}
342
- onClick={() => onJump(dot)}
343
- />
344
- ))}
400
+ {dots.map((dot, index) => {
401
+ const isFocused = focus !== null && focus.key === dot.key
402
+ // Progressive magnification: selected largest, ±1 smaller, ±2
403
+ // smaller still, the rest base scale.
404
+ const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex)
405
+ const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1
406
+ const cls = [styles.dot]
407
+ if (isFocused) cls.push(styles.focused)
408
+ if (jumpingKey === dot.key) cls.push(styles.active)
409
+ return (
410
+ <button
411
+ key={dot.key}
412
+ className={cls.join(' ')}
413
+ style={{ transform: `scale(${scale})` }}
414
+ data-question-nav-focused={isFocused ? 'true' : undefined}
415
+ data-question-nav-index={index}
416
+ aria-label={dot.texts[0] ?? ''}
417
+ onMouseEnter={(e) => openFocus(dot, e.currentTarget)}
418
+ onClick={() => onJump(dot)}
419
+ />
420
+ )
421
+ })}
345
422
  </div>
346
423
  )}
347
424
  </div>
348
- {tooltip !== null
425
+ {hint !== null
426
+ ? createPortal(
427
+ <div
428
+ className={styles.hint}
429
+ style={align === 'right' ? { right: 60, top: 20 } : { left: 60, top: 20 }}
430
+ >
431
+ {hint}
432
+ </div>,
433
+ document.body,
434
+ )
435
+ : null}
436
+ {focus !== null
349
437
  ? createPortal(
350
- <div className={styles.tooltip} style={{ left: tooltip.left, right: tooltip.right, top: tooltip.top }}>
351
- {tooltip.title !== null ? <div className={styles.tooltipTitle}>{tooltip.title}</div> : null}
352
- {tooltip.lines.map((line, index) => (
353
- <div key={index} className={styles.tooltipLine}>{line}</div>
354
- ))}
438
+ /* The vertical cascade: one crisp card per window dot, stacked
439
+ non-overlapping, vertically centered on the selected dot.
440
+ Hovering the cascade keeps it alive (the rail only re-arms its
441
+ clear once the mouse leaves both). */
442
+ <div
443
+ className={styles.cascade}
444
+ style={{
445
+ ...(focus.left !== undefined ? { left: focus.left } : {}),
446
+ ...(focus.right !== undefined ? { right: focus.right } : {}),
447
+ top: focus.centerY,
448
+ transform: 'translateY(-50%)',
449
+ }}
450
+ onMouseEnter={cancelClearFocus}
451
+ onMouseLeave={scheduleClearFocus}
452
+ >
453
+ {focus.items.map((item) => {
454
+ const metrics = focusCardMetrics(item.distance)
455
+ if (metrics === null) return null
456
+ const isSelected = item.distance === 0
457
+ // Cascade cards join their dot's texts into one clamped line;
458
+ // the selected card keeps every line. Every card shows the
459
+ // question's sent time, and clicking any card jumps to its
460
+ // question exactly like clicking the dot.
461
+ const text = item.dot.texts.join(' · ')
462
+ const cls = isSelected ? `${styles.card} ${styles.cardSelected}` : styles.card
463
+ return (
464
+ <div
465
+ key={item.dot.key}
466
+ className={cls}
467
+ role="button"
468
+ tabIndex={-1}
469
+ style={{
470
+ width: metrics.widthPx,
471
+ filter: `brightness(${metrics.brightness})`,
472
+ }}
473
+ onClick={() => onJump(item.dot)}
474
+ >
475
+ {isSelected && item.dot.turn !== null ? (
476
+ <div className={styles.cardTitle}>Turn {item.dot.turn}</div>
477
+ ) : null}
478
+ <div
479
+ className={isSelected ? styles.cardBody : styles.cardClamp}
480
+ style={
481
+ isSelected
482
+ ? undefined
483
+ : ({ WebkitLineClamp: metrics.maxLines, fontSize: metrics.fontSize } as React.CSSProperties)
484
+ }
485
+ >
486
+ {isSelected
487
+ ? item.dot.texts.map((line, lineIndex) => (
488
+ <div key={lineIndex} className={styles.cardLine}>{line}</div>
489
+ ))
490
+ : text}
491
+ </div>
492
+ <div className={styles.cardTime}>{formatQuestionTime(item.dot.time, Date.now())}</div>
493
+ </div>
494
+ )
495
+ })}
355
496
  </div>,
356
497
  document.body,
357
498
  )
@@ -21,11 +21,16 @@
21
21
  left: auto;
22
22
  }
23
23
 
24
- /* Vertically center the dot column when it is short; scroll from the top when
25
- it is tall (auto margins collapse to 0 on overflow, so nothing clips). */
24
+ /* Vertically center the dot column when it is short; when it is tall it is
25
+ clamped to at most 60% of the conversation height (auto margins center the
26
+ band) and scrolls from the top (auto margins collapse to 0 on overflow).
27
+ The band itself is interactive so wheel-scrolling works over the gaps too
28
+ (the rail above is pointer-events:none). */
26
29
  .list {
27
- flex: 1;
30
+ flex: 0 1 auto;
28
31
  min-height: 0;
32
+ max-height: 60%;
33
+ margin: auto 0;
29
34
  width: 100%;
30
35
  overflow-y: auto;
31
36
  overflow-x: hidden;
@@ -35,6 +40,7 @@
35
40
  gap: 6px;
36
41
  padding: 8px 0;
37
42
  scrollbar-width: thin;
43
+ pointer-events: auto;
38
44
  }
39
45
  .list > *:first-child {
40
46
  margin-top: auto;
@@ -55,12 +61,15 @@
55
61
  cursor: pointer;
56
62
  transition: transform 120ms ease, background 120ms ease;
57
63
  }
64
+ /* Scale is driven by the focus model through an inline transform; this only
65
+ colors the hovered (selected) dot instantly, before React applies it. */
58
66
  .dot:hover {
59
- transform: scale(2);
67
+ background: var(--dsw-alias-brand-primary);
68
+ }
69
+ .dot.focused {
60
70
  background: var(--dsw-alias-brand-primary);
61
71
  }
62
72
  .dot.active {
63
- transform: scale(1.6);
64
73
  background: var(--dsw-alias-brand-primary);
65
74
  }
66
75
 
@@ -91,40 +100,107 @@
91
100
  word-break: break-word;
92
101
  }
93
102
 
94
- /* Instant hover tooltip (rendered in a portal to document.body, so it is never
95
- clipped by the rail's scroll container). */
96
- .tooltip {
103
+ /* Transient jump-failure hint (portal-rendered near the rail top, so it never
104
+ pushes the centered dot band or clips in the 44px-wide rail). */
105
+ .hint {
106
+ position: fixed;
107
+ z-index: 30;
108
+ max-width: 260px;
109
+ background: var(--dsw-alias-bg-layer-2);
110
+ border: 1px solid var(--dsw-alias-border-l1);
111
+ border-radius: 6px;
112
+ padding: 6px 10px;
113
+ font-size: 12px;
114
+ line-height: 16px;
115
+ color: var(--dsw-alias-label-secondary);
116
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
117
+ pointer-events: none;
118
+ }
119
+
120
+ /* The vertical focus cascade: one crisp question card per window dot, stacked
121
+ top-to-bottom without overlap, centered on the selected dot. The container
122
+ stays pass-through; only the cards are interactive. */
123
+ .cascade {
97
124
  position: fixed;
98
125
  z-index: 20;
99
- max-width: 420px;
126
+ display: flex;
127
+ flex-direction: column;
128
+ align-items: flex-start;
129
+ gap: 6px;
130
+ pointer-events: none;
131
+ }
132
+
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. */
136
+ .card {
137
+ width: 380px;
100
138
  background: var(--dsw-alias-bg-layer-1);
101
139
  border: 1px solid var(--dsw-alias-border-l1);
102
- border-radius: 8px;
103
- padding: 8px 12px;
140
+ border-radius: 10px;
141
+ padding: 8px 12px 10px;
104
142
  font-size: 13px;
105
143
  line-height: 18px;
106
144
  color: var(--dsw-alias-label-primary);
107
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
145
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
108
146
  white-space: normal;
109
147
  word-break: break-word;
110
- pointer-events: none;
148
+ pointer-events: auto;
149
+ cursor: pointer;
150
+ transition: border-color 120ms ease, box-shadow 120ms ease;
151
+ }
152
+ .card:hover {
153
+ border-color: var(--dsw-alias-brand-primary);
154
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.26);
155
+ }
156
+
157
+ /* The selected (center) card: marked brighter with a brand edge, so the
158
+ center card is distinct from the four unselected cascade cards. */
159
+ .cardSelected {
160
+ border-color: var(--dsw-alias-brand-primary);
111
161
  }
112
162
 
113
- /* Turn label line at the top of the tooltip ("Turn 32"). */
114
- .tooltipTitle {
163
+ /* Turn label line at the top of the card ("Turn 32"). */
164
+ .cardTitle {
115
165
  font-size: 11px;
116
166
  font-weight: 600;
117
167
  color: var(--dsw-alias-label-tertiary);
118
168
  margin-bottom: 4px;
119
169
  }
120
170
 
171
+ /* Question body; long multi-question turns scroll inside the card. */
172
+ .cardBody {
173
+ max-height: 96px;
174
+ overflow-y: auto;
175
+ scrollbar-width: thin;
176
+ }
177
+
178
+ /* Clamped cascade-card text (the two neighbors on each side): N lines with
179
+ ellipsis via -webkit-line-clamp (set inline). */
180
+ .cardClamp {
181
+ font-size: 13px;
182
+ line-height: 18px;
183
+ color: var(--dsw-alias-label-primary);
184
+ display: -webkit-box;
185
+ -webkit-box-orient: vertical;
186
+ overflow: hidden;
187
+ word-break: break-word;
188
+ }
189
+
121
190
  /* One question text line; consecutive lines (multi-question turns) separate. */
122
- .tooltipLine + .tooltipLine {
191
+ .cardLine + .cardLine {
123
192
  margin-top: 6px;
124
193
  padding-top: 6px;
125
194
  border-top: 1px solid var(--dsw-alias-border-l1);
126
195
  }
127
196
 
197
+ /* Question sent-time line (HH:MM / MM-DD HH:MM / YYYY-MM-DD HH:MM). */
198
+ .cardTime {
199
+ margin-top: 6px;
200
+ font-size: 11px;
201
+ color: var(--dsw-alias-label-tertiary);
202
+ }
203
+
128
204
  /* Settings page (settings.plugins.tab). */
129
205
  .settings {
130
206
  display: flex;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Focus-magnification model for the question-nav rail. Pure index/scale math
3
+ * for the hover-selected dot's neighborhood window, its progressive
4
+ * magnification tiers, and the vertical cascade of question cards (one per
5
+ * window dot, non-overlapping, sizes descending away from the selected one).
6
+ * No React, no DOM — every function here is unit-testable in isolation and
7
+ * safe to inline into the client bundle.
8
+ *
9
+ * @module dsh-question-nav/focus
10
+ */
11
+
12
+ /** How many dots stay enlarged on each side of the selected dot. */
13
+ export const FOCUS_RADIUS = 2
14
+
15
+ /** Magnification scale per tier, by distance from the selected dot
16
+ * (0 = selected, 1 = immediate neighbor, 2 = outer window edge). */
17
+ export const FOCUS_SCALES = [2.0, 1.55, 1.25] as const
18
+
19
+ /**
20
+ * The focus tier of a dot at `distance` from the selected dot: 0..FOCUS_RADIUS
21
+ * while inside the magnification window, null beyond it (base scale).
22
+ */
23
+ export function focusTier(distance: number): number | null {
24
+ const d = Math.abs(distance)
25
+ if (d > FOCUS_RADIUS) return null
26
+ return d
27
+ }
28
+
29
+ /** Magnification scale for a dot at `distance`; 1 (base) outside the window. */
30
+ export function focusScale(distance: number): number {
31
+ const tier = focusTier(distance)
32
+ return tier === null ? 1 : FOCUS_SCALES[tier]
33
+ }
34
+
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). */
40
+ export interface FocusCardMetrics {
41
+ /** Card width in px (narrows away from the selected card). */
42
+ widthPx: number
43
+ /** Text font size (px); cascade cards are slightly smaller. */
44
+ fontSize: number
45
+ /** Max text lines before clamping (cascade cards). */
46
+ maxLines: number
47
+ /** Brightness (1 = full, the selected card; dimmer away from it). */
48
+ brightness: number
49
+ }
50
+
51
+ export function focusCardMetrics(distance: number): FocusCardMetrics | null {
52
+ const tier = focusTier(distance)
53
+ if (tier === null) return null
54
+ switch (tier) {
55
+ case 0:
56
+ return { widthPx: 380, fontSize: 13, maxLines: 6, brightness: 1 }
57
+ case 1:
58
+ return { widthPx: 300, fontSize: 12.5, maxLines: 2, brightness: 0.82 }
59
+ default:
60
+ return { widthPx: 240, fontSize: 12, maxLines: 1, brightness: 0.68 }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Indices of the focus window: the selected dot plus `radius` on each side,
66
+ * clipped to the dot list. Empty when the list is empty or `selected` is out
67
+ * of range.
68
+ */
69
+ export function magnificationWindow(total: number, selected: number, radius: number = FOCUS_RADIUS): number[] {
70
+ if (total <= 0 || selected < 0 || selected >= total || radius < 0) return []
71
+ const out: number[] = []
72
+ const lo = Math.max(0, selected - radius)
73
+ const hi = Math.min(total - 1, selected + radius)
74
+ for (let i = lo; i <= hi; i++) out.push(i)
75
+ return out
76
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Question sent-time formatting for the question-nav surface. Pure: no DSH
3
+ * imports, deterministic for a given (time, now) pair — unit-testable in
4
+ * isolation and safe to inline into the client bundle.
5
+ *
6
+ * @module dsh-question-nav/time
7
+ */
8
+
9
+ function pad2(n: number): string {
10
+ return n < 10 ? `0${n}` : String(n)
11
+ }
12
+
13
+ /** Whether `time` falls on the same calendar day as `now`. */
14
+ export function isSameDay(time: number, now: number): boolean {
15
+ const a = new Date(time)
16
+ const b = new Date(now)
17
+ return a.getFullYear() === b.getFullYear()
18
+ && a.getMonth() === b.getMonth()
19
+ && a.getDate() === b.getDate()
20
+ }
21
+
22
+ /**
23
+ * Smart question sent-time, relative to `now`:
24
+ * - same calendar day → `HH:MM`
25
+ * - same calendar year → `MM-DD HH:MM`
26
+ * - otherwise → `YYYY-MM-DD HH:MM`
27
+ *
28
+ * Returns `''` for a missing/invalid timestamp (e.g. a live node that never
29
+ * reported one), so callers can hide the time line without branching.
30
+ */
31
+ export function formatQuestionTime(time: number, now: number): string {
32
+ if (!Number.isFinite(time) || time <= 0) return ''
33
+ const d = new Date(time)
34
+ const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
35
+ if (isSameDay(time, now)) return hm
36
+ const md = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
37
+ if (d.getFullYear() === new Date(now).getFullYear()) return `${md} ${hm}`
38
+ return `${d.getFullYear()}-${md} ${hm}`
39
+ }