@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.
- package/README.md +25 -7
- package/README.zh.md +19 -5
- package/lib/client.js +295 -45
- package/lib/client.js.map +1 -1
- package/lib/types/core/focus.d.ts +58 -0
- package/lib/types/core/time.d.ts +19 -0
- package/package.json +2 -2
- package/src/client/QuestionNavStrip.tsx +261 -46
- package/src/client/question-nav.module.css +123 -19
- package/src/core/focus.ts +96 -0
- package/src/core/time.ts +39 -0
|
@@ -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)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
/**
|
|
74
|
-
interface
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
*
|
|
102
|
-
*
|
|
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 [
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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;
|
|
25
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
/*
|
|
95
|
-
|
|
96
|
-
.
|
|
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
|
-
|
|
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-
|
|
102
|
-
border-radius:
|
|
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-
|
|
107
|
-
box-shadow: 0
|
|
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:
|
|
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
|
|
114
|
-
.
|
|
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
|
-
.
|
|
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
|
+
}
|