@luziyang2026/dsh-question-nav 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -28,7 +34,10 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
28
34
  import type { QuestionNode } from '../core/nodes.ts'
29
35
  import type { QuestionEntry } from '../core/question-entry.ts'
30
36
  import { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'
37
+ import type { AlignPreference } from '../core/align.ts'
31
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'
32
41
  import type { QuestionNavKey } from './locales.ts'
33
42
  import styles from './question-nav.module.css'
34
43
 
@@ -52,6 +61,12 @@ export interface QuestionNavInjected {
52
61
  questionProjection: (sessionId: SessionId) => ObservableFace | undefined
53
62
  /** Jump the chat to a question row (pages the window on demand). */
54
63
  jump: (sessionId: SessionId, key: string) => void
64
+ /** Current rail anchor edge (defaults to 'left' before the settings section is ready). */
65
+ align: () => AlignPreference
66
+ /** Observe anchor-edge changes; returns an unsubscribe. */
67
+ subscribeAlign: (cb: () => void) => () => void
68
+ /** Persist a new anchor edge. */
69
+ setAlign: (align: AlignPreference) => void
55
70
  }
56
71
 
57
72
  type ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>
@@ -63,14 +78,25 @@ const FAILURE_HINTS: Record<JumpFailureCode, QuestionNavKey> = {
63
78
  TIMEOUT: 'jump.timeout',
64
79
  }
65
80
 
66
- /** Live position of the instant hover tooltip. */
67
- interface TooltipState {
68
- /** Turn label line (e.g. "Turn 32"); null for ungrouped live questions. */
69
- title: string | null
70
- /** Question text lines (one per question folded into the dot). */
71
- lines: readonly string[]
72
- left: number
73
- top: number
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
97
+ /** Horizontal anchor — left edge (left-aligned rail) or right edge (right-aligned). */
98
+ left?: number
99
+ right?: number
74
100
  }
75
101
 
76
102
  /** Read the projection face value as a question-entry list (structural guard). */
@@ -89,8 +115,8 @@ function findConvRoot(): HTMLElement | null {
89
115
  }
90
116
 
91
117
  /** Structural equality of two dot lists (member keys fully capture a dot's
92
- * folded questions, so identical key sequences mean identical content).
93
- * 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. */
94
120
  function sameDots(a: readonly TurnDot[], b: readonly TurnDot[]): boolean {
95
121
  if (a.length !== b.length) return false
96
122
  for (let i = 0; i < a.length; i++) {
@@ -112,11 +138,36 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
112
138
  const [dots, setDots] = useState<TurnDot[]>([])
113
139
  const [jumpingKey, setJumpingKey] = useState<string | null>(null)
114
140
  const [hint, setHint] = useState<string | null>(null)
115
- const [tooltip, setTooltip] = useState<TooltipState | null>(null)
141
+ const [focus, setFocus] = useState<FocusState | null>(null)
142
+ const [align, setAlign] = useState<AlignPreference>(() => props.align())
116
143
  const panelRef = useRef<HTMLDivElement | null>(null)
144
+ const listRef = useRef<HTMLDivElement | null>(null)
117
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)
118
149
  // Last rendered dot list, for the change-detection bail-out below.
119
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
+ }
167
+
168
+ // Follow the rail anchor edge from the settings scope (a change re-anchors
169
+ // the rail through the layout effect below).
170
+ useEffect(() => props.subscribeAlign(() => setAlign(props.align())), [props])
120
171
 
121
172
  const showHint = (message: string): void => {
122
173
  setHint(message)
@@ -217,13 +268,29 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
217
268
  if (convRect.height <= 0 || convRect.width <= 0) return true
218
269
  const top = `${convRect.top - frameRect.top}px`
219
270
  const height = `${convRect.height}px`
271
+ // Anchor to the configured edge: keep the other edge's inline style
272
+ // cleared so the CSS class (left: auto on .railRight) owns it.
273
+ if (align === 'right') {
274
+ const right = `${frameRect.right - convRect.right}px`
275
+ if (panel.style.top === top && panel.style.height === height
276
+ && panel.style.right === right && panel.style.left === '') {
277
+ return false
278
+ }
279
+ panel.style.top = top
280
+ panel.style.height = height
281
+ panel.style.right = right
282
+ panel.style.left = ''
283
+ return true
284
+ }
220
285
  const left = `${convRect.left - frameRect.left}px`
221
- if (panel.style.top === top && panel.style.height === height && panel.style.left === left) {
286
+ if (panel.style.top === top && panel.style.height === height
287
+ && panel.style.left === left && panel.style.right === '') {
222
288
  return false
223
289
  }
224
290
  panel.style.top = top
225
291
  panel.style.height = height
226
292
  panel.style.left = left
293
+ panel.style.right = ''
227
294
  return true
228
295
  }
229
296
 
@@ -257,11 +324,29 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
257
324
  observer?.disconnect()
258
325
  window.removeEventListener('resize', wake)
259
326
  }
260
- }, [visible])
327
+ }, [visible, align])
328
+
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])
261
345
 
262
- // Clear any pending hint timer on unmount.
346
+ // Clear any pending timers on unmount.
263
347
  useEffect(() => () => {
264
348
  if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)
349
+ if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current)
265
350
  }, [])
266
351
 
267
352
  if (!visible) return null
@@ -273,47 +358,141 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
273
358
  window.setTimeout(() => setJumpingKey((k) => (k === dot.key ? null : k)), 600)
274
359
  }
275
360
 
276
- 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()
277
365
  const r = target.getBoundingClientRect()
278
- setTooltip({
279
- title: dot.turn === null ? null : `Turn ${dot.turn}`,
280
- lines: dot.texts,
281
- left: r.right + 10,
282
- top: r.top,
283
- })
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 })
284
379
  }
285
380
 
286
381
  const t = props.t
382
+ const selectedIndex = focus === null ? -1 : dots.findIndex((d) => d.key === focus.key)
287
383
 
288
384
  return (
289
- <div ref={panelRef} className={styles.rail} data-question-nav="rail">
290
- {hint !== null ? <div className={styles.hint} role="status">{hint}</div> : null}
291
- <div className={styles.list}>
385
+ <div
386
+ ref={panelRef}
387
+ className={align === 'right' ? `${styles.rail} ${styles.railRight}` : styles.rail}
388
+ data-question-nav="rail"
389
+ >
390
+ <div
391
+ ref={listRef}
392
+ className={styles.list}
393
+ onMouseLeave={scheduleClearFocus}
394
+ >
292
395
  {dots.length === 0 ? (
293
396
  <div className={styles.empty}>{t('strip.empty')}</div>
294
397
  ) : (
295
398
  <div className={styles.dots}>
296
399
  <span className={styles.count}>{dots.length}</span>
297
- {dots.map((dot) => (
298
- <button
299
- key={dot.key}
300
- className={jumpingKey === dot.key ? `${styles.dot} ${styles.active}` : styles.dot}
301
- aria-label={dot.texts[0] ?? ''}
302
- onMouseEnter={(e) => openTooltip(dot, e.currentTarget)}
303
- onMouseLeave={() => setTooltip(null)}
304
- onClick={() => onJump(dot)}
305
- />
306
- ))}
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
+ })}
307
422
  </div>
308
423
  )}
309
424
  </div>
310
- {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
311
437
  ? createPortal(
312
- <div className={styles.tooltip} style={{ left: tooltip.left, top: tooltip.top }}>
313
- {tooltip.title !== null ? <div className={styles.tooltipTitle}>{tooltip.title}</div> : null}
314
- {tooltip.lines.map((line, index) => (
315
- <div key={index} className={styles.tooltipLine}>{line}</div>
316
- ))}
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
+ })}
317
496
  </div>,
318
497
  document.body,
319
498
  )
@@ -23,7 +23,13 @@ import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
23
23
  import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
24
24
  // Type-only: pulls the locale plugin's Context merge (ctx.locale).
25
25
  import type {} from '@deepseek-ai/dsh-client-locale/client'
26
+ // Type-only: pulls the settings shell's SlotMap merge ('settings.plugins.tab')
27
+ // and the ctx.settingsScope Context merge.
28
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
29
+ import type { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
26
30
  import { QuestionNavStrip, type ObservableFace, type QuestionNavInjected } from './QuestionNavStrip.tsx'
31
+ import { QuestionNavSettingsTab } from './QuestionNavSettingsTab.tsx'
32
+ import { QuestionNavSettingsController } from './settings.ts'
27
33
  import { en, zh, type QuestionNavKey } from './locales.ts'
28
34
  import { extractQuestions } from '../core/nodes.ts'
29
35
  import { jumpToQuestion, type JumpFailureCode, type JumpPorts } from '../core/jump.ts'
@@ -103,7 +109,7 @@ function questionProjectionOf(ctx: ClientContext, sessionId: SessionId): Observa
103
109
  }
104
110
  }
105
111
 
106
- function createInject(ctx: ClientContext): QuestionNavInjected {
112
+ function createInject(ctx: ClientContext, settings: QuestionNavSettingsController): QuestionNavInjected {
107
113
  return {
108
114
  readQuestions: (sessionId) => {
109
115
  const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot()
@@ -126,6 +132,9 @@ function createInject(ctx: ClientContext): QuestionNavInjected {
126
132
  }
127
133
  void jumpToQuestion(ports, key)
128
134
  },
135
+ align: () => settings.getSnapshot().align,
136
+ subscribeAlign: (cb) => settings.subscribe(cb),
137
+ setAlign: (align) => settings.setAlign(align),
129
138
  }
130
139
  }
131
140
 
@@ -138,8 +147,10 @@ export function apply(ctx: ClientContext): void {
138
147
  ctx.effect(() => releaseApply, 'question-nav: apply claim')
139
148
 
140
149
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'question-nav: dictionaries')
150
+ const t = ctx.locale.bind(NS)
141
151
 
142
- const injected = createInject(ctx)
152
+ const settings = new QuestionNavSettingsController()
153
+ const injected = createInject(ctx, settings)
143
154
 
144
155
  ctx.slots.inject('shell.overlay', () => ctx.slots.register({
145
156
  name: 'shell.overlay',
@@ -148,4 +159,19 @@ export function apply(ctx: ClientContext): void {
148
159
  locale: NS,
149
160
  inject: () => injected,
150
161
  }, QuestionNavStrip))
162
+
163
+ // The settings surface is optional and may apply after this plugin, so the
164
+ // namespace binds and the settings page mounts in a fiber that waits for
165
+ // it. Without it the strip simply keeps the default left alignment.
166
+ ctx.inject(['settingsScope'], (scopeCtx) => {
167
+ settings.attach(scopeCtx.get('settingsScope') as SettingsScopeBinder)
168
+ ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
169
+ name: 'settings.plugins.tab',
170
+ id: 'question-nav',
171
+ order: 100,
172
+ label: () => t('settings.tab'),
173
+ locale: NS,
174
+ inject: () => injected,
175
+ }, QuestionNavSettingsTab))
176
+ })
151
177
  }
@@ -8,6 +8,11 @@ export const zh = {
8
8
  'jump.hidden': '目标无独立气泡,已定位到邻近内容',
9
9
  'jump.notfound': '目标未加载或不存在(可能已压缩)',
10
10
  'jump.timeout': '加载历史超时,可重试',
11
+ 'settings.tab': '提问导航',
12
+ 'settings.align.title': '导航条对齐',
13
+ 'settings.align.desc': '选择圆点导航条锚定在对话栏的哪一侧。',
14
+ 'settings.align.left': '左侧',
15
+ 'settings.align.right': '右侧',
11
16
  } as const
12
17
 
13
18
  export const en = {
@@ -16,6 +21,11 @@ export const en = {
16
21
  'jump.hidden': 'No dedicated bubble; landed on nearby content',
17
22
  'jump.notfound': 'Target not loaded or missing (maybe compacted)',
18
23
  'jump.timeout': 'Timed out loading history; retry',
24
+ 'settings.tab': 'Question Nav',
25
+ 'settings.align.title': 'Rail alignment',
26
+ 'settings.align.desc': 'Choose which edge of the conversation column the dot rail anchors to.',
27
+ 'settings.align.left': 'Left',
28
+ 'settings.align.right': 'Right',
19
29
  } as const
20
30
 
21
31
  export type QuestionNavKey = keyof typeof zh
@@ -15,11 +15,22 @@
15
15
  pointer-events: none;
16
16
  }
17
17
 
18
- /* Vertically center the dot column when it is short; scroll from the top when
19
- it is tall (auto margins collapse to 0 on overflow, so nothing clips). */
18
+ /* Right-anchored rail: release the CSS `left: 0` so the inline `right`
19
+ (written by the layout-correction loop) owns the position. */
20
+ .railRight {
21
+ left: auto;
22
+ }
23
+
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). */
20
29
  .list {
21
- flex: 1;
30
+ flex: 0 1 auto;
22
31
  min-height: 0;
32
+ max-height: 60%;
33
+ margin: auto 0;
23
34
  width: 100%;
24
35
  overflow-y: auto;
25
36
  overflow-x: hidden;
@@ -29,6 +40,7 @@
29
40
  gap: 6px;
30
41
  padding: 8px 0;
31
42
  scrollbar-width: thin;
43
+ pointer-events: auto;
32
44
  }
33
45
  .list > *:first-child {
34
46
  margin-top: auto;
@@ -49,12 +61,15 @@
49
61
  cursor: pointer;
50
62
  transition: transform 120ms ease, background 120ms ease;
51
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. */
52
66
  .dot:hover {
53
- transform: scale(2);
67
+ background: var(--dsw-alias-brand-primary);
68
+ }
69
+ .dot.focused {
54
70
  background: var(--dsw-alias-brand-primary);
55
71
  }
56
72
  .dot.active {
57
- transform: scale(1.6);
58
73
  background: var(--dsw-alias-brand-primary);
59
74
  }
60
75
 
@@ -85,36 +100,152 @@
85
100
  word-break: break-word;
86
101
  }
87
102
 
88
- /* Instant hover tooltip (rendered in a portal to document.body, so it is never
89
- clipped by the rail's scroll container). */
90
- .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 {
91
124
  position: fixed;
92
125
  z-index: 20;
93
- 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;
94
138
  background: var(--dsw-alias-bg-layer-1);
95
139
  border: 1px solid var(--dsw-alias-border-l1);
96
- border-radius: 8px;
97
- padding: 8px 12px;
140
+ border-radius: 10px;
141
+ padding: 8px 12px 10px;
98
142
  font-size: 13px;
99
143
  line-height: 18px;
100
144
  color: var(--dsw-alias-label-primary);
101
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
145
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
102
146
  white-space: normal;
103
147
  word-break: break-word;
104
- 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);
105
161
  }
106
162
 
107
- /* Turn label line at the top of the tooltip ("Turn 32"). */
108
- .tooltipTitle {
163
+ /* Turn label line at the top of the card ("Turn 32"). */
164
+ .cardTitle {
109
165
  font-size: 11px;
110
166
  font-weight: 600;
111
167
  color: var(--dsw-alias-label-tertiary);
112
168
  margin-bottom: 4px;
113
169
  }
114
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
+
115
190
  /* One question text line; consecutive lines (multi-question turns) separate. */
116
- .tooltipLine + .tooltipLine {
191
+ .cardLine + .cardLine {
117
192
  margin-top: 6px;
118
193
  padding-top: 6px;
119
194
  border-top: 1px solid var(--dsw-alias-border-l1);
120
195
  }
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
+
204
+ /* Settings page (settings.plugins.tab). */
205
+ .settings {
206
+ display: flex;
207
+ flex-direction: column;
208
+ gap: 8px;
209
+ padding: 4px 0;
210
+ }
211
+ .settingsTitle {
212
+ font-size: 13px;
213
+ font-weight: 600;
214
+ color: var(--dsw-alias-label-primary);
215
+ margin: 0;
216
+ }
217
+ .settingsDesc {
218
+ font-size: 12px;
219
+ line-height: 18px;
220
+ color: var(--dsw-alias-label-secondary);
221
+ margin: 0;
222
+ }
223
+ .segmented {
224
+ display: inline-flex;
225
+ border: 1px solid var(--dsw-alias-border-l1);
226
+ border-radius: 8px;
227
+ overflow: hidden;
228
+ align-self: flex-start;
229
+ }
230
+ .segment {
231
+ border: none;
232
+ background: transparent;
233
+ color: var(--dsw-alias-label-secondary);
234
+ font-size: 13px;
235
+ line-height: 1;
236
+ padding: 8px 16px;
237
+ cursor: pointer;
238
+ transition: background 120ms ease, color 120ms ease;
239
+ }
240
+ .segment + .segment {
241
+ border-left: 1px solid var(--dsw-alias-border-l1);
242
+ }
243
+ .segment:hover {
244
+ background: var(--dsw-alias-bg-layer-1);
245
+ color: var(--dsw-alias-label-primary);
246
+ }
247
+ .segmentActive,
248
+ .segmentActive:hover {
249
+ background: var(--dsw-alias-brand-primary);
250
+ color: var(--dsw-alias-label-inverse);
251
+ }