@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.
- package/README.md +19 -7
- package/README.zh.md +14 -5
- package/lib/client.js +228 -44
- package/lib/client.js.map +1 -1
- package/lib/types/core/focus.d.ts +44 -0
- package/lib/types/core/time.d.ts +19 -0
- package/package.json +2 -2
- package/src/client/QuestionNavStrip.tsx +187 -46
- package/src/client/question-nav.module.css +92 -16
- package/src/core/focus.ts +76 -0
- package/src/core/time.ts +39 -0
|
@@ -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)
|
|
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), 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
|
-
/**
|
|
74
|
-
interface
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
*
|
|
102
|
-
*
|
|
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 [
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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;
|
|
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). */
|
|
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
|
-
|
|
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
|
-
/*
|
|
95
|
-
|
|
96
|
-
.
|
|
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
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
114
|
-
.
|
|
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
|
-
.
|
|
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
|
+
}
|
package/src/core/time.ts
ADDED
|
@@ -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
|
+
}
|