@luziyang2026/dsh-question-nav 0.5.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.
@@ -1,12 +1,19 @@
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), 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.
10
17
  *
11
18
  * Data source: the host-folded `questionIndex` session projection (whole
12
19
  * history, persisted host-side, pushed live through session/projection
@@ -30,6 +37,8 @@ import type { QuestionEntry } from '../core/question-entry.ts'
30
37
  import { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'
31
38
  import type { AlignPreference } from '../core/align.ts'
32
39
  import type { JumpFailureCode } from '../core/jump.ts'
40
+ import { EDGE_SCROLL_ZONE, FOCUS_RADIUS, edgeScrollSpeed, focusCardMetrics, focusScale, focusTier } from '../core/focus.ts'
41
+ import { formatQuestionTime } from '../core/time.ts'
33
42
  import type { QuestionNavKey } from './locales.ts'
34
43
  import styles from './question-nav.module.css'
35
44
 
@@ -70,16 +79,25 @@ const FAILURE_HINTS: Record<JumpFailureCode, QuestionNavKey> = {
70
79
  TIMEOUT: 'jump.timeout',
71
80
  }
72
81
 
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[]
82
+ /** One card of the focus cascade: a dot inside the magnification window. */
83
+ interface FocusWindowItem {
84
+ dot: TurnDot
85
+ /** Distance from the selected dot (0 = selected, 1/2 = cascade neighbors). */
86
+ distance: number
87
+ }
88
+
89
+ /** The focus cascade: a vertical column of one card per window dot,
90
+ * non-overlapping, centered on the selected dot. */
91
+ interface FocusState {
92
+ /** The selected (hovered) dot's key. */
93
+ key: string
94
+ /** Every dot of the focus window, in dot order. */
95
+ items: FocusWindowItem[]
96
+ /** Vertical center (viewport px) of the selected dot — the column centers on it. */
97
+ centerY: number
79
98
  /** Horizontal anchor — left edge (left-aligned rail) or right edge (right-aligned). */
80
99
  left?: number
81
100
  right?: number
82
- top: number
83
101
  }
84
102
 
85
103
  /** Read the projection face value as a question-entry list (structural guard). */
@@ -98,8 +116,8 @@ function findConvRoot(): HTMLElement | null {
98
116
  }
99
117
 
100
118
  /** 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. */
119
+ * folded questions, so identical key sequences mean identical content).
120
+ * Lets the strip skip a re-render when a refresh produced no change. */
103
121
  function sameDots(a: readonly TurnDot[], b: readonly TurnDot[]): boolean {
104
122
  if (a.length !== b.length) return false
105
123
  for (let i = 0; i < a.length; i++) {
@@ -121,12 +139,88 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
121
139
  const [dots, setDots] = useState<TurnDot[]>([])
122
140
  const [jumpingKey, setJumpingKey] = useState<string | null>(null)
123
141
  const [hint, setHint] = useState<string | null>(null)
124
- const [tooltip, setTooltip] = useState<TooltipState | null>(null)
142
+ const [focus, setFocus] = useState<FocusState | null>(null)
125
143
  const [align, setAlign] = useState<AlignPreference>(() => props.align())
126
144
  const panelRef = useRef<HTMLDivElement | null>(null)
145
+ const listRef = useRef<HTMLDivElement | null>(null)
127
146
  const hintTimerRef = useRef<number | null>(null)
147
+ // Pending focus-clear timer: lets the mouse move from the rail onto a
148
+ // cascade card without dropping the cascade mid-flight.
149
+ const clearFocusTimerRef = useRef<number | null>(null)
128
150
  // Last rendered dot list, for the change-detection bail-out below.
129
151
  const lastDotsRef = useRef<TurnDot[]>([])
152
+ // Last focused dot key, so re-hovering the same dot after a gap re-centers it.
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
+ }
210
+
211
+ // Focus persists briefly after leaving the rail, so the mouse can reach the
212
+ // clickable cascade cards; entering a card cancels the clear, leaving
213
+ // everything re-arms it.
214
+ const cancelClearFocus = (): void => {
215
+ if (clearFocusTimerRef.current !== null) {
216
+ window.clearTimeout(clearFocusTimerRef.current)
217
+ clearFocusTimerRef.current = null
218
+ }
219
+ }
220
+ const scheduleClearFocus = (): void => {
221
+ cancelClearFocus()
222
+ clearFocusTimerRef.current = window.setTimeout(() => setFocus(null), 240)
223
+ }
130
224
 
131
225
  // Follow the rail anchor edge from the settings scope (a change re-anchors
132
226
  // the rail through the layout effect below).
@@ -289,9 +383,43 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
289
383
  }
290
384
  }, [visible, align])
291
385
 
292
- // Clear any pending hint timer on unmount.
386
+ // Keep the focused dot centered in the (≤60% tall, scrollable) band so its
387
+ // two neighbors on each side stay visible. Re-centers only when the focused
388
+ // key changes; scrolls instantly to avoid chasing a fast-moving hover.
389
+ useLayoutEffect(() => {
390
+ const key = focus?.key ?? null
391
+ if (lastFocusedKeyRef.current === key) return
392
+ lastFocusedKeyRef.current = key
393
+ if (key === null) return
394
+ const list = listRef.current
395
+ if (list === null) return
396
+ const target = list.querySelector<HTMLElement>('[data-question-nav-focused="true"]')
397
+ if (target === null) return
398
+ const delta = target.offsetTop - list.offsetTop
399
+ const center = delta - (list.clientHeight - target.offsetHeight) / 2
400
+ list.scrollTop = Math.max(0, Math.min(center, list.scrollHeight - list.clientHeight))
401
+ }, [focus])
402
+
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.
293
418
  useEffect(() => () => {
294
419
  if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)
420
+ if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current)
421
+ stopEdgeScroll()
422
+ // eslint-disable-next-line react-hooks/exhaustive-deps
295
423
  }, [])
296
424
 
297
425
  if (!visible) return null
@@ -303,21 +431,28 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
303
431
  window.setTimeout(() => setJumpingKey((k) => (k === dot.key ? null : k)), 600)
304
432
  }
305
433
 
306
- const openTooltip = (dot: TurnDot, target: HTMLElement): void => {
434
+ const openFocus = (dot: TurnDot, target: HTMLElement): void => {
435
+ const index = dots.findIndex((d) => d.key === dot.key)
436
+ if (index < 0) return
437
+ cancelClearFocus()
307
438
  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
- })
439
+ // The cascade opens away from the rail: to the right of the dots on a
440
+ // left-anchored rail, to the left on a right-anchored one. The column is
441
+ // vertically centered on the selected dot (top: centerY + translateY(-50%)).
442
+ const horizontal = align === 'right'
443
+ ? { right: window.innerWidth - r.left + 10 }
444
+ : { left: r.right + 10 }
445
+ const lo = Math.max(0, index - FOCUS_RADIUS)
446
+ const hi = Math.min(dots.length - 1, index + FOCUS_RADIUS)
447
+ const items: FocusWindowItem[] = []
448
+ for (let i = lo; i <= hi; i++) {
449
+ items.push({ dot: dots[i], distance: Math.abs(i - index) })
450
+ }
451
+ setFocus({ key: dot.key, items, centerY: r.top + r.height / 2, ...horizontal })
318
452
  }
319
453
 
320
454
  const t = props.t
455
+ const selectedIndex = focus === null ? -1 : dots.findIndex((d) => d.key === focus.key)
321
456
 
322
457
  return (
323
458
  <div
@@ -325,33 +460,113 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
325
460
  className={align === 'right' ? `${styles.rail} ${styles.railRight}` : styles.rail}
326
461
  data-question-nav="rail"
327
462
  >
328
- {hint !== null ? <div className={styles.hint} role="status">{hint}</div> : null}
329
- <div className={styles.list}>
463
+ <div
464
+ ref={listRef}
465
+ className={scrollable ? `${styles.list} ${styles.listScrollable}` : styles.list}
466
+ onMouseMove={scrollable ? onListMouseMove : undefined}
467
+ onMouseLeave={() => {
468
+ stopEdgeScroll()
469
+ scheduleClearFocus()
470
+ }}
471
+ >
330
472
  {dots.length === 0 ? (
331
473
  <div className={styles.empty}>{t('strip.empty')}</div>
332
474
  ) : (
333
475
  <div className={styles.dots}>
334
476
  <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
- ))}
477
+ {dots.map((dot, index) => {
478
+ const isFocused = focus !== null && focus.key === dot.key
479
+ // Progressive magnification: selected largest, ±1 smaller, ±2
480
+ // smaller still, the rest base scale.
481
+ const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex)
482
+ const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1
483
+ const cls = [styles.dot]
484
+ if (isFocused) cls.push(styles.focused)
485
+ if (jumpingKey === dot.key) cls.push(styles.active)
486
+ return (
487
+ <button
488
+ key={dot.key}
489
+ className={cls.join(' ')}
490
+ style={{ transform: `scale(${scale})` }}
491
+ data-question-nav-focused={isFocused ? 'true' : undefined}
492
+ data-question-nav-index={index}
493
+ aria-label={dot.texts[0] ?? ''}
494
+ onMouseEnter={(e) => openFocus(dot, e.currentTarget)}
495
+ onClick={() => onJump(dot)}
496
+ />
497
+ )
498
+ })}
345
499
  </div>
346
500
  )}
347
501
  </div>
348
- {tooltip !== null
502
+ {hint !== null
503
+ ? createPortal(
504
+ <div
505
+ className={styles.hint}
506
+ style={align === 'right' ? { right: 60, top: 20 } : { left: 60, top: 20 }}
507
+ >
508
+ {hint}
509
+ </div>,
510
+ document.body,
511
+ )
512
+ : null}
513
+ {focus !== null
349
514
  ? 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
- ))}
515
+ /* The vertical cascade: one crisp card per window dot, stacked
516
+ non-overlapping, vertically centered on the selected dot.
517
+ Hovering the cascade keeps it alive (the rail only re-arms its
518
+ clear once the mouse leaves both). */
519
+ <div
520
+ className={styles.cascade}
521
+ style={{
522
+ ...(focus.left !== undefined ? { left: focus.left } : {}),
523
+ ...(focus.right !== undefined ? { right: focus.right } : {}),
524
+ top: focus.centerY,
525
+ transform: 'translateY(-50%)',
526
+ }}
527
+ onMouseEnter={cancelClearFocus}
528
+ onMouseLeave={scheduleClearFocus}
529
+ >
530
+ {focus.items.map((item) => {
531
+ const metrics = focusCardMetrics(item.distance)
532
+ if (metrics === null) return null
533
+ const isSelected = item.distance === 0
534
+ // Cascade cards join their dot's texts into one clamped line;
535
+ // the selected card keeps every line. Every card shows the
536
+ // question's sent time, and clicking any card jumps to its
537
+ // question exactly like clicking the dot.
538
+ const text = item.dot.texts.join(' · ')
539
+ const cls = isSelected ? `${styles.card} ${styles.cardSelected}` : styles.card
540
+ return (
541
+ <div
542
+ key={item.dot.key}
543
+ className={cls}
544
+ role="button"
545
+ tabIndex={-1}
546
+ style={{ width: metrics.widthPx }}
547
+ onClick={() => onJump(item.dot)}
548
+ >
549
+ {isSelected && item.dot.turn !== null ? (
550
+ <div className={styles.cardTitle}>Turn {item.dot.turn}</div>
551
+ ) : null}
552
+ <div
553
+ className={isSelected ? styles.cardBody : styles.cardClamp}
554
+ style={
555
+ isSelected
556
+ ? undefined
557
+ : ({ WebkitLineClamp: metrics.maxLines, fontSize: metrics.fontSize } as React.CSSProperties)
558
+ }
559
+ >
560
+ {isSelected
561
+ ? item.dot.texts.map((line, lineIndex) => (
562
+ <div key={lineIndex} className={styles.cardLine}>{line}</div>
563
+ ))
564
+ : text}
565
+ </div>
566
+ <div className={styles.cardTime}>{formatQuestionTime(item.dot.time, Date.now())}</div>
567
+ </div>
568
+ )
569
+ })}
355
570
  </div>,
356
571
  document.body,
357
572
  )
@@ -21,11 +21,18 @@
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). 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. */
26
31
  .list {
27
- flex: 1;
32
+ flex: 0 1 auto;
28
33
  min-height: 0;
34
+ max-height: 60%;
35
+ margin: auto 0;
29
36
  width: 100%;
30
37
  overflow-y: auto;
31
38
  overflow-x: hidden;
@@ -34,7 +41,20 @@
34
41
  align-items: center;
35
42
  gap: 6px;
36
43
  padding: 8px 0;
37
- scrollbar-width: thin;
44
+ scrollbar-width: none;
45
+ pointer-events: auto;
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);
38
58
  }
39
59
  .list > *:first-child {
40
60
  margin-top: auto;
@@ -55,12 +75,15 @@
55
75
  cursor: pointer;
56
76
  transition: transform 120ms ease, background 120ms ease;
57
77
  }
78
+ /* Scale is driven by the focus model through an inline transform; this only
79
+ colors the hovered (selected) dot instantly, before React applies it. */
58
80
  .dot:hover {
59
- transform: scale(2);
81
+ background: var(--dsw-alias-brand-primary);
82
+ }
83
+ .dot.focused {
60
84
  background: var(--dsw-alias-brand-primary);
61
85
  }
62
86
  .dot.active {
63
- transform: scale(1.6);
64
87
  background: var(--dsw-alias-brand-primary);
65
88
  }
66
89
 
@@ -91,40 +114,121 @@
91
114
  word-break: break-word;
92
115
  }
93
116
 
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 {
117
+ /* Transient jump-failure hint (portal-rendered near the rail top, so it never
118
+ pushes the centered dot band or clips in the 44px-wide rail). */
119
+ .hint {
120
+ position: fixed;
121
+ z-index: 30;
122
+ max-width: 260px;
123
+ background: var(--dsw-alias-bg-layer-2);
124
+ border: 1px solid var(--dsw-alias-border-l1);
125
+ border-radius: 6px;
126
+ padding: 6px 10px;
127
+ font-size: 12px;
128
+ line-height: 16px;
129
+ color: var(--dsw-alias-label-secondary);
130
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
131
+ pointer-events: none;
132
+ }
133
+
134
+ /* The vertical focus cascade: one crisp question card per window dot, stacked
135
+ top-to-bottom without overlap, centered on the selected dot. The container
136
+ stays pass-through; only the cards are interactive. */
137
+ .cascade {
97
138
  position: fixed;
98
139
  z-index: 20;
99
- max-width: 420px;
140
+ display: flex;
141
+ flex-direction: column;
142
+ align-items: flex-start;
143
+ gap: 6px;
144
+ pointer-events: none;
145
+ }
146
+
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. */
152
+ .card {
153
+ width: 380px;
100
154
  background: var(--dsw-alias-bg-layer-1);
101
- border: 1px solid var(--dsw-alias-border-l1);
102
- border-radius: 8px;
103
- padding: 8px 12px;
155
+ border: 1px solid var(--dsw-alias-border-l2);
156
+ border-radius: 10px;
157
+ padding: 8px 12px 10px;
104
158
  font-size: 13px;
105
159
  line-height: 18px;
106
- color: var(--dsw-alias-label-primary);
107
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
160
+ color: var(--dsw-alias-label-secondary);
161
+ box-shadow: 0 1px 6px rgba(0, 0, 0, 0.10);
108
162
  white-space: normal;
109
163
  word-break: break-word;
110
- pointer-events: none;
164
+ pointer-events: auto;
165
+ cursor: pointer;
166
+ transition: border-color 120ms ease, box-shadow 120ms ease;
167
+ }
168
+ .card:hover {
169
+ border-color: var(--dsw-alias-brand-primary);
170
+ box-shadow: 0 3px 12px rgba(0, 0, 0, 0.18);
171
+ color: var(--dsw-alias-label-primary);
172
+ }
173
+
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. */
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 {
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);
111
189
  }
112
190
 
113
- /* Turn label line at the top of the tooltip ("Turn 32"). */
114
- .tooltipTitle {
191
+ /* Turn label line at the top of the card ("Turn 32"). */
192
+ .cardTitle {
115
193
  font-size: 11px;
116
194
  font-weight: 600;
117
195
  color: var(--dsw-alias-label-tertiary);
118
196
  margin-bottom: 4px;
119
197
  }
120
198
 
199
+ /* Question body; long multi-question turns scroll inside the card. */
200
+ .cardBody {
201
+ max-height: 96px;
202
+ overflow-y: auto;
203
+ scrollbar-width: thin;
204
+ }
205
+
206
+ /* Clamped cascade-card text (the two neighbors on each side): N lines with
207
+ ellipsis via -webkit-line-clamp (set inline). Inherits the card's text
208
+ color (secondary on context cards, primary on the focus card). */
209
+ .cardClamp {
210
+ font-size: 13px;
211
+ line-height: 18px;
212
+ display: -webkit-box;
213
+ -webkit-box-orient: vertical;
214
+ overflow: hidden;
215
+ word-break: break-word;
216
+ }
217
+
121
218
  /* One question text line; consecutive lines (multi-question turns) separate. */
122
- .tooltipLine + .tooltipLine {
219
+ .cardLine + .cardLine {
123
220
  margin-top: 6px;
124
221
  padding-top: 6px;
125
222
  border-top: 1px solid var(--dsw-alias-border-l1);
126
223
  }
127
224
 
225
+ /* Question sent-time line (HH:MM / MM-DD HH:MM / YYYY-MM-DD HH:MM). */
226
+ .cardTime {
227
+ margin-top: 6px;
228
+ font-size: 11px;
229
+ color: var(--dsw-alias-label-tertiary);
230
+ }
231
+
128
232
  /* Settings page (settings.plugins.tab). */
129
233
  .settings {
130
234
  display: flex;
@@ -0,0 +1,96 @@
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. 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). */
42
+ export interface FocusCardMetrics {
43
+ /** Card width in px (narrows away from the selected card). */
44
+ widthPx: number
45
+ /** Text font size (px); cascade cards are slightly smaller. */
46
+ fontSize: number
47
+ /** Max text lines before clamping (cascade cards). */
48
+ maxLines: 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 }
57
+ case 1:
58
+ return { widthPx: 300, fontSize: 12.5, maxLines: 2 }
59
+ default:
60
+ return { widthPx: 240, fontSize: 12, maxLines: 1 }
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
+ }
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
+ }