@luziyang2026/dsh-question-nav 0.7.2 → 0.7.4

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,28 +1,32 @@
1
1
  /**
2
2
  * Browser mirror of the `question-nav` settings namespace: reads the rail
3
- * anchor edge from the settings scope (`ctx.settingsScope.bind`) and routes
4
- * the user's choice back through `scope.set`. The namespace itself is
5
- * registered by the host half (src/settings.ts).
3
+ * anchor edge and the ▲/▼ page size from the settings scope
4
+ * (`ctx.settingsScope.bind`) and routes the user's choices back through
5
+ * `scope.set`. The namespace itself is registered by the host half
6
+ * (src/settings.ts).
6
7
  *
7
8
  * The settings surface is optional and may apply after this plugin, so the
8
- * controller starts unbound and degrades to the default alignment until
9
- * {@link attach} binds the scope (called from a fiber that injects
10
- * `settingsScope`). The plugin keeps working everywhere it already did.
9
+ * controller starts unbound and degrades to the defaults until {@link attach}
10
+ * binds the scope (called from a fiber that injects `settingsScope`). The
11
+ * plugin keeps working everywhere it already did.
11
12
  *
12
13
  * @module dsh-question-nav/client/settings
13
14
  */
14
15
  import type { SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client';
15
16
  import { type AlignPreference } from '../core/align.ts';
17
+ import { type PageSize } from '../core/page-size.ts';
16
18
  /** The minimal face of the settings scope service this controller needs.
17
19
  * Kept structural (bind only) so the controller stays decoupled from the
18
20
  * full service and is unit-testable with a stub binder. */
19
21
  export interface SettingsScopeBinderLike {
20
22
  bind<T>(spec: SettingsScopeSpec<T>): SettingsScope<T>;
21
23
  }
22
- /** Snapshot consumed by the strip and the settings row. */
24
+ /** Snapshot consumed by the strip and the settings rows. */
23
25
  export interface QuestionNavSettingsState {
24
26
  /** Last accepted anchor edge (default while the scope is absent/loading). */
25
27
  align: AlignPreference;
28
+ /** Last accepted page size (default while the scope is absent/loading). */
29
+ pageSize: PageSize;
26
30
  /** Whether the user layer overrides the composition default. */
27
31
  overridden: boolean;
28
32
  }
@@ -49,4 +53,6 @@ export declare class QuestionNavSettingsController {
49
53
  subscribe(listener: () => void): () => void;
50
54
  /** Route the user's anchor-edge choice to the Host document. */
51
55
  setAlign(align: AlignPreference): void;
56
+ /** Route the user's page-size choice to the Host document. */
57
+ setPageSize(pageSize: PageSize): void;
52
58
  }
@@ -42,24 +42,11 @@ export declare function focusCardMetrics(distance: number): FocusCardMetrics | n
42
42
  * of range.
43
43
  */
44
44
  export declare function magnificationWindow(total: number, selected: number, radius?: number): number[];
45
- /** Clearance (px) kept around the focused dot when scrolling it into view, so
46
- * its two magnified neighbors on each side stay inside the band's clear area
47
- * (2 dot rows ≈ 28px + the 22px fade zone). */
48
- export declare const FOCUS_NEIGHBOR_CLEARANCE = 50;
49
- /**
50
- * Minimal "scroll into view" for the focused dot, in the spirit of
51
- * scrollIntoView({ block: 'nearest' }): returns the scrollTop that brings the
52
- * dot — plus `clearance` room for its magnified neighbors — inside the
53
- * visible band with the smallest possible movement, or null when the dot is
54
- * already fully visible. Unlike unconditional centering this never shifts the
55
- * band while the user browses dot-by-dot: only a clipped dot is scrolled.
56
- */
57
- export declare function minimalScrollIntoView(scrollTop: number, clientHeight: number, scrollHeight: number, dotTop: number, dotHeight: number, clearance?: number): number | null;
58
45
  /**
59
46
  * Paging model for the dot band. Overflowing dots are not auto-scrolled by
60
- * hovering the edges: instead the user pages them with two triangle buttons
61
- * (▲ above the dot queue, ▼ below it), each click revealing `DOT_PAGE_ROWS`
62
- * hidden dots. Pure arithmetic — no React, no DOM.
47
+ * hovering the edges or hovering a clipped dot: instead the user pages them
48
+ * with two triangle buttons (▲ above the dot queue, ▼ below it), each click
49
+ * revealing `DOT_PAGE_ROWS` hidden dots. Pure arithmetic — no React, no DOM.
63
50
  */
64
51
  /** How many hidden dots one click of a paging triangle reveals. */
65
52
  export declare const DOT_PAGE_ROWS = 5;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Page-size constants for the ▲/▼ paging buttons, shared by the host schema
3
+ * and the browser settings scope. Pure data: no DSH imports, so the client
4
+ * bundle may inline this module (a Host import here would leak into the
5
+ * browser half).
6
+ *
7
+ * @module dsh-question-nav/page-size
8
+ */
9
+ /** Selectable page sizes: how many hidden dots one ▲/▼ click reveals. */
10
+ export declare const PAGE_SIZE_OPTIONS: readonly [3, 5, 8, 10];
11
+ /** Page-size preference (dots revealed per paging-button click). */
12
+ export type PageSize = typeof PAGE_SIZE_OPTIONS[number];
13
+ /** Default page size when the user-settings document has no override. */
14
+ export declare const DEFAULT_PAGE_SIZE: PageSize;
15
+ /** Field carrying the selected page size. */
16
+ export declare const PAGE_SIZE_FIELD = "pageSize";
@@ -1,19 +1,23 @@
1
1
  /**
2
2
  * Host-side durable settings for the question-nav plugin, registered into the
3
- * DSH user-settings document. Currently one field: which edge of the
4
- * conversation column the rail anchors to (`align`). The browser half reads
5
- * the same namespace through the settings scope (`ctx.settingsScope.bind`)
6
- * and routes the user's choice back through `scope.set`.
3
+ * DSH user-settings document. Two fields: which edge of the conversation
4
+ * column the rail anchors to (`align`) and how many hidden dots one ▲/▼
5
+ * paging-button click reveals (`pageSize`). The browser half reads the same
6
+ * namespace through the settings scope (`ctx.settingsScope.bind`) and routes
7
+ * the user's choice back through `scope.set`.
7
8
  *
8
9
  * @module dsh-question-nav/settings
9
10
  */
10
11
  import z from '@deepseek-ai/schemastery';
11
12
  import type { SettingsNamespace } from '@deepseek-ai/dsh-settings';
12
13
  import { type AlignPreference } from './core/align.ts';
14
+ import { type PageSize } from './core/page-size.ts';
13
15
  /** Durable settings section shared by the Host schema and the browser scope. */
14
16
  export interface QuestionNavSettings {
15
17
  /** Anchor edge of the rail. */
16
18
  align: AlignPreference;
19
+ /** Dots revealed per ▲/▼ paging-button click. */
20
+ pageSize: PageSize;
17
21
  }
18
22
  /** Durable settings schema; also the wire envelope the browser scope validates against. */
19
23
  export declare const QuestionNavSettingsSchema: z<QuestionNavSettings>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@luziyang2026/dsh-question-nav",
3
3
  "description": "In-session question navigator for the DSH web GUI: a vertical minimap of round dots overlaid on the edge of the conversation column (left or right, configurable in settings), one dot per user question — hovering focuses a magnified window and a vertical cascade of crisp question cards showing the full text and sent time of each (any card is clickable), click jumps to that message.",
4
- "version": "0.7.2",
4
+ "version": "0.7.4",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.7.0",
7
7
  "engines": {
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * The plugin's settings page inside the shell's Plugins section
3
- * (`settings.plugins.tab`): a segmented control choosing which edge of the
4
- * conversation column the dot rail anchors to. The choice is written to the
3
+ * (`settings.plugins.tab`): two segmented controls which edge of the
4
+ * conversation column the dot rail anchors to, and how many hidden dots one
5
+ * ▲/▼ paging-button click reveals. The choices are written to the
5
6
  * `question-nav` settings namespace (registered by the host half); the strip
6
- * re-anchors live when the snapshot changes.
7
+ * re-anchors and re-steps live when the snapshot changes.
7
8
  *
8
9
  * @module dsh-question-nav/client/settings-tab
9
10
  */
@@ -13,6 +14,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots
13
14
  // Type-only: pulls the settings shell's SlotMap merge ('settings.plugins.tab').
14
15
  import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
15
16
  import { ALIGN_OPTIONS } from '../core/align.ts'
17
+ import { PAGE_SIZE_OPTIONS } from '../core/page-size.ts'
16
18
  import type { QuestionNavInjected } from './QuestionNavStrip.tsx'
17
19
  import type { QuestionNavKey } from './locales.ts'
18
20
  import styles from './question-nav.module.css'
@@ -20,14 +22,15 @@ import styles from './question-nav.module.css'
20
22
  type ComponentProps = PropsRuntime<'settings.plugins.tab'> & QuestionNavInjected & PropsLocale<'question-nav'>
21
23
 
22
24
  /** Re-render on settings snapshot changes (the register inject face is static). */
23
- function useAlignTick(subscribe: QuestionNavInjected['subscribeAlign']): void {
25
+ function useSettingsTick(subscribe: QuestionNavInjected['subscribeSettings']): void {
24
26
  const [, bump] = useState(0)
25
27
  useEffect(() => subscribe(() => bump((n) => n + 1)), [subscribe])
26
28
  }
27
29
 
28
30
  export function QuestionNavSettingsTab(props: ComponentProps): React.JSX.Element | null {
29
- useAlignTick(props.subscribeAlign)
31
+ useSettingsTick(props.subscribeSettings)
30
32
  const align = props.align()
33
+ const pageSize = props.pageSize()
31
34
  const t = props.t
32
35
 
33
36
  return (
@@ -48,6 +51,22 @@ export function QuestionNavSettingsTab(props: ComponentProps): React.JSX.Element
48
51
  </button>
49
52
  ))}
50
53
  </div>
54
+ <p className={styles.settingsTitle}>{t('settings.pagesize.title')}</p>
55
+ <p className={styles.settingsDesc}>{t('settings.pagesize.desc')}</p>
56
+ <div className={styles.segmented} role="radiogroup" aria-label={t('settings.pagesize.title')}>
57
+ {PAGE_SIZE_OPTIONS.map((option) => (
58
+ <button
59
+ key={option}
60
+ type="button"
61
+ role="radio"
62
+ aria-checked={pageSize === option}
63
+ className={pageSize === option ? `${styles.segment} ${styles.segmentActive}` : styles.segment}
64
+ onClick={() => props.setPageSize(option)}
65
+ >
66
+ {option}
67
+ </button>
68
+ ))}
69
+ </div>
51
70
  </div>
52
71
  )
53
72
  }
@@ -12,13 +12,16 @@
12
12
  * vertical cascade of question cards opens — the selected (center) card is the
13
13
  * focus (brand accent, elevated, full question text), the four neighbors are
14
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 scrolls the
16
- * selected dot into view only when it is clipped by the band's edges.
15
+ * jumps to its question, exactly like clicking the dot. Hovering never scrolls
16
+ * the band the dot column stays put while you browse.
17
17
  *
18
18
  * Overflow is paged, not auto-scrolled: two small triangle buttons in the dot
19
- * style sit above and below the dot queue (▲ / ▼), each click revealing five
20
- * hidden dots. The native scrollbar stays hidden and the column fades at its
21
- * edges as a pure visual cue no hover auto-scroll.
19
+ * style sit above and below the dot queue (▲ / ▼), each click revealing one
20
+ * page of hidden dots (the page size is configurable in the plugin settings,
21
+ * default 5) with a staggered pop-in animation as click feedback. The native
22
+ * scrollbar stays hidden; the triangles themselves are the overflow cue (each
23
+ * appears only while its direction has more to reveal) — no hover auto-scroll
24
+ * of any kind.
22
25
  *
23
26
  * Data source: the host-folded `questionIndex` session projection (whole
24
27
  * history, persisted host-side, pushed live through session/projection
@@ -41,8 +44,9 @@ import type { QuestionNode } from '../core/nodes.ts'
41
44
  import type { QuestionEntry } from '../core/question-entry.ts'
42
45
  import { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'
43
46
  import type { AlignPreference } from '../core/align.ts'
47
+ import type { PageSize } from '../core/page-size.ts'
44
48
  import type { JumpFailureCode } from '../core/jump.ts'
45
- import { FOCUS_RADIUS, clampScrollTop, focusCardMetrics, focusScale, focusTier, minimalScrollIntoView, pageStep } from '../core/focus.ts'
49
+ import { DOT_GAP, DOT_SIZE, FOCUS_RADIUS, clampScrollTop, focusCardMetrics, focusScale, focusTier, pageStep } from '../core/focus.ts'
46
50
  import { formatQuestionTime } from '../core/time.ts'
47
51
  import type { QuestionNavKey } from './locales.ts'
48
52
  import styles from './question-nav.module.css'
@@ -69,10 +73,14 @@ export interface QuestionNavInjected {
69
73
  jump: (sessionId: SessionId, key: string) => void
70
74
  /** Current rail anchor edge (defaults to 'left' before the settings section is ready). */
71
75
  align: () => AlignPreference
72
- /** Observe anchor-edge changes; returns an unsubscribe. */
73
- subscribeAlign: (cb: () => void) => () => void
76
+ /** Observe plugin-settings changes (align, page size); returns an unsubscribe. */
77
+ subscribeSettings: (cb: () => void) => () => void
74
78
  /** Persist a new anchor edge. */
75
79
  setAlign: (align: AlignPreference) => void
80
+ /** Current ▲/▼ page size (defaults to DEFAULT_PAGE_SIZE before the settings section is ready). */
81
+ pageSize: () => PageSize
82
+ /** Persist a new page size. */
83
+ setPageSize: (pageSize: PageSize) => void
76
84
  }
77
85
 
78
86
  type ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>
@@ -146,6 +154,8 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
146
154
  const [hint, setHint] = useState<string | null>(null)
147
155
  const [focus, setFocus] = useState<FocusState | null>(null)
148
156
  const [align, setAlign] = useState<AlignPreference>(() => props.align())
157
+ // Dots revealed per ▲/▼ click (settings scope; drives pageBy's step).
158
+ const [pageSize, setPageSize] = useState<PageSize>(() => props.pageSize())
149
159
  const panelRef = useRef<HTMLDivElement | null>(null)
150
160
  const listRef = useRef<HTMLDivElement | null>(null)
151
161
  const hintTimerRef = useRef<number | null>(null)
@@ -154,14 +164,17 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
154
164
  const clearFocusTimerRef = useRef<number | null>(null)
155
165
  // Last rendered dot list, for the change-detection bail-out below.
156
166
  const lastDotsRef = useRef<TurnDot[]>([])
157
- // Last focused dot key, so re-hovering the same dot after a gap re-centers it.
158
- const lastFocusedKeyRef = useRef<string | null>(null)
159
- // Whether the dot band overflows its 60% clamp (drives the edge-fade mask
160
- // and the ▲/▼ paging buttons).
167
+ // Whether the dot band overflows its 60% clamp (drives the ▲/▼ paging
168
+ // buttons).
161
169
  const [scrollable, setScrollable] = useState(false)
162
170
  // Scroll offset of the band + its max offset: drives the ▲/▼ visibility
163
171
  // (each direction hides once there is nothing more to reveal).
164
172
  const [scrollPos, setScrollPos] = useState<{ top: number; max: number }>({ top: 0, max: 0 })
173
+ // Dots revealed by the latest ▲/▼ click: key → stagger order + travel
174
+ // direction. Drives a short staggered pop-in animation so the click has a
175
+ // visible effect; cleared by a timer once the animation has played out.
176
+ const [revealed, setRevealed] = useState<ReadonlyMap<string, { dir: 1 | -1; order: number }> | null>(null)
177
+ const revealTimerRef = useRef<number | null>(null)
165
178
 
166
179
  const syncScroll = (): void => {
167
180
  const list = listRef.current
@@ -170,13 +183,45 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
170
183
  setScrollPos({ top: list.scrollTop, max })
171
184
  }
172
185
 
173
- // Page the band by one DOT_PAGE_ROWS click in the given direction.
186
+ // Page the band by one DOT_PAGE_ROWS click in the given direction, then mark
187
+ // the dots the page just brought into view so they play a staggered pop-in
188
+ // animation in the direction of travel (▼ → rise from below, ▲ → drop from
189
+ // above) — the visual confirmation that the click revealed new dots.
174
190
  const pageBy = (dir: 1 | -1): void => {
175
191
  const list = listRef.current
176
192
  if (list === null) return
177
193
  const max = Math.max(0, list.scrollHeight - list.clientHeight)
178
- list.scrollTop = clampScrollTop(list.scrollTop + dir * pageStep(), max)
194
+ const target = clampScrollTop(list.scrollTop + dir * pageStep(DOT_SIZE, DOT_GAP, pageSize), max)
195
+ if (target === list.scrollTop) return
196
+ // Snapshot which dots are geometrically inside the band before the jump
197
+ // (the container rect does not move when its content scrolls).
198
+ const band = list.getBoundingClientRect()
199
+ const els = Array.from(list.querySelectorAll<HTMLElement>('[data-question-nav-key]'))
200
+ const wasVisible = els.map((el) => {
201
+ const r = el.getBoundingClientRect()
202
+ return r.bottom > band.top && r.top < band.bottom
203
+ })
204
+ list.scrollTop = target
179
205
  syncScroll()
206
+ const next = new Map<string, { dir: 1 | -1; order: number }>()
207
+ const fresh: string[] = []
208
+ els.forEach((el, i) => {
209
+ if (wasVisible[i]) return
210
+ const r = el.getBoundingClientRect()
211
+ const key = el.dataset.questionNavKey
212
+ if (key !== undefined && r.bottom > band.top && r.top < band.bottom) {
213
+ fresh.push(key)
214
+ }
215
+ })
216
+ // Stagger radiates from the clicked triangle: paging down (▼) starts at
217
+ // the bottom of the fresh batch, paging up (▲) at the top.
218
+ if (dir === 1) fresh.reverse()
219
+ fresh.forEach((key, order) => next.set(key, { dir, order }))
220
+ if (next.size === 0) return
221
+ setRevealed(next)
222
+ if (revealTimerRef.current !== null) window.clearTimeout(revealTimerRef.current)
223
+ // Animation (320ms) + per-dot stagger (35ms each) + slack.
224
+ revealTimerRef.current = window.setTimeout(() => setRevealed(null), 320 + next.size * 35 + 120)
180
225
  }
181
226
 
182
227
  const canPageUp = scrollable && scrollPos.top > 0
@@ -196,9 +241,13 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
196
241
  clearFocusTimerRef.current = window.setTimeout(() => setFocus(null), 240)
197
242
  }
198
243
 
199
- // Follow the rail anchor edge from the settings scope (a change re-anchors
200
- // the rail through the layout effect below).
201
- useEffect(() => props.subscribeAlign(() => setAlign(props.align())), [props])
244
+ // Follow the plugin settings from the settings scope (an align change
245
+ // re-anchors the rail through the layout effect below; a page-size change
246
+ // steps the ▲/▼ paging).
247
+ useEffect(() => props.subscribeSettings(() => {
248
+ setAlign(props.align())
249
+ setPageSize(props.pageSize())
250
+ }), [props])
202
251
 
203
252
  const showHint = (message: string): void => {
204
253
  setHint(message)
@@ -357,34 +406,9 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
357
406
  }
358
407
  }, [visible, align])
359
408
 
360
- // Keep the focused dot visible inside the (≤60% tall, scrollable) band:
361
- // scroll only when the dot (plus room for its two magnified neighbors) is
362
- // clipped by the band edges, and then only by the minimal amount — never
363
- // re-center an already-visible dot, so browsing dot-by-dot doesn't shift
364
- // the band under the pointer.
365
- useLayoutEffect(() => {
366
- const key = focus?.key ?? null
367
- if (lastFocusedKeyRef.current === key) return
368
- lastFocusedKeyRef.current = key
369
- if (key === null) return
370
- const list = listRef.current
371
- if (list === null) return
372
- const target = list.querySelector<HTMLElement>('[data-question-nav-focused="true"]')
373
- if (target === null) return
374
- const next = minimalScrollIntoView(
375
- list.scrollTop,
376
- list.clientHeight,
377
- list.scrollHeight,
378
- target.offsetTop - list.offsetTop,
379
- target.offsetHeight,
380
- )
381
- if (next !== null) list.scrollTop = next
382
- syncScroll()
383
- }, [focus])
384
-
385
- // Detect band overflow: drives the edge-fade mask + the ▲/▼ paging buttons.
386
- // Re-checked when the dots change and whenever the band itself resizes
387
- // (the layout loop above clamps it to the conversation height).
409
+ // Detect band overflow: drives the ▲/▼ paging buttons. Re-checked when the
410
+ // dots change and whenever the band itself resizes (the layout loop above
411
+ // clamps it to the conversation height).
388
412
  useLayoutEffect(() => {
389
413
  const list = listRef.current
390
414
  if (list === null) return
@@ -403,6 +427,7 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
403
427
  useEffect(() => () => {
404
428
  if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)
405
429
  if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current)
430
+ if (revealTimerRef.current !== null) window.clearTimeout(revealTimerRef.current)
406
431
  // eslint-disable-next-line react-hooks/exhaustive-deps
407
432
  }, [])
408
433
 
@@ -463,7 +488,7 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
463
488
  ) : null}
464
489
  <div
465
490
  ref={listRef}
466
- className={scrollable ? `${styles.list} ${styles.listScrollable}` : styles.list}
491
+ className={styles.list}
467
492
  onScroll={syncScroll}
468
493
  onMouseLeave={scheduleClearFocus}
469
494
  >
@@ -474,16 +499,26 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
474
499
  // smaller still, the rest base scale.
475
500
  const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex)
476
501
  const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1
502
+ // Click-paging feedback: dots just paged into view play a
503
+ // staggered pop-in, traveling in the paging direction.
504
+ const reveal = revealed?.get(dot.key)
477
505
  const cls = [styles.dot]
478
506
  if (isFocused) cls.push(styles.focused)
479
507
  if (jumpingKey === dot.key) cls.push(styles.active)
508
+ if (reveal !== undefined) {
509
+ cls.push(reveal.dir === 1 ? styles.dotEnterFromBottom : styles.dotEnterFromTop)
510
+ }
480
511
  return (
481
512
  <button
482
513
  key={dot.key}
483
514
  className={cls.join(' ')}
484
- style={{ transform: `scale(${scale})` }}
515
+ style={{
516
+ transform: `scale(${scale})`,
517
+ ...(reveal !== undefined ? { animationDelay: `${reveal.order * 35}ms` } : {}),
518
+ }}
485
519
  data-question-nav-focused={isFocused ? 'true' : undefined}
486
520
  data-question-nav-index={index}
521
+ data-question-nav-key={dot.key}
487
522
  aria-label={dot.texts[0] ?? ''}
488
523
  onMouseEnter={(e) => openFocus(dot, e.currentTarget)}
489
524
  onClick={() => onJump(dot)}
@@ -133,8 +133,10 @@ function createInject(ctx: ClientContext, settings: QuestionNavSettingsControlle
133
133
  void jumpToQuestion(ports, key)
134
134
  },
135
135
  align: () => settings.getSnapshot().align,
136
- subscribeAlign: (cb) => settings.subscribe(cb),
136
+ subscribeSettings: (cb) => settings.subscribe(cb),
137
137
  setAlign: (align) => settings.setAlign(align),
138
+ pageSize: () => settings.getSnapshot().pageSize,
139
+ setPageSize: (pageSize) => settings.setPageSize(pageSize),
138
140
  }
139
141
  }
140
142
 
@@ -4,8 +4,8 @@
4
4
  */
5
5
  export const zh = {
6
6
  'strip.empty': '本会话还没有提问',
7
- 'strip.up': '向上翻出 5 个提问圆点',
8
- 'strip.down': '向下翻出 5 个提问圆点',
7
+ 'strip.up': '向上翻出更多提问圆点',
8
+ 'strip.down': '向下翻出更多提问圆点',
9
9
  'jump.inactive': '聊天视图未激活',
10
10
  'jump.hidden': '目标无独立气泡,已定位到邻近内容',
11
11
  'jump.notfound': '目标未加载或不存在(可能已压缩)',
@@ -15,12 +15,14 @@ export const zh = {
15
15
  'settings.align.desc': '选择圆点导航条锚定在对话栏的哪一侧。',
16
16
  'settings.align.left': '左侧',
17
17
  'settings.align.right': '右侧',
18
+ 'settings.pagesize.title': '每次翻出数量',
19
+ 'settings.pagesize.desc': '点击 ▲/▼ 三角按钮时,每次翻出的隐藏圆点数量。',
18
20
  } as const
19
21
 
20
22
  export const en = {
21
23
  'strip.empty': 'No questions in this session yet',
22
- 'strip.up': 'Reveal 5 question dots above',
23
- 'strip.down': 'Reveal 5 question dots below',
24
+ 'strip.up': 'Reveal more question dots above',
25
+ 'strip.down': 'Reveal more question dots below',
24
26
  'jump.inactive': 'Chat view is not active',
25
27
  'jump.hidden': 'No dedicated bubble; landed on nearby content',
26
28
  'jump.notfound': 'Target not loaded or missing (maybe compacted)',
@@ -30,6 +32,8 @@ export const en = {
30
32
  'settings.align.desc': 'Choose which edge of the conversation column the dot rail anchors to.',
31
33
  'settings.align.left': 'Left',
32
34
  'settings.align.right': 'Right',
35
+ 'settings.pagesize.title': 'Dots per page',
36
+ 'settings.pagesize.desc': 'How many hidden dots one click of the ▲/▼ paging buttons reveals.',
33
37
  } as const
34
38
 
35
39
  export type QuestionNavKey = keyof typeof zh
@@ -22,11 +22,10 @@
22
22
  }
23
23
 
24
24
  /* The paging queue: the fixed column (count + ▲, then the scrollable dot band,
25
- then ▼) that replaces the edge-fade auto-scroll. Clamped to 60% of the
26
- conversation height and vertically centered (auto margins); when the queue
27
- overflows, only the middle dot band scrolls the count and triangles stay
28
- pinned. The wrapper stays pass-through; the band and triangles re-enable
29
- pointer events. */
25
+ then ▼). Clamped to 60% of the conversation height and vertically centered
26
+ (auto margins); when the queue overflows, only the middle dot band scrolls
27
+ the count and triangles stay pinned. The wrapper stays pass-through; the
28
+ band and triangles re-enable pointer events. */
30
29
  .queue {
31
30
  flex: 0 1 auto;
32
31
  min-height: 0;
@@ -44,7 +43,8 @@
44
43
  /* The middle dot band: the only scrollable part of the queue. Scrolls from the
45
44
  top (overflow collapses the auto margins to 0). The native scrollbar is
46
45
  always hidden: this is a minimap paged by the ▲/▼ buttons and by
47
- wheel/trackpad, and overflow is signalled by the edge fade below. */
46
+ wheel/trackpad, and overflow is signalled by the ▲/▼ triangles themselves
47
+ (each appears only while its direction has more dots to reveal). */
48
48
  .list {
49
49
  flex: 1 1 auto;
50
50
  min-height: 0;
@@ -64,13 +64,6 @@
64
64
  height: 0;
65
65
  display: none;
66
66
  }
67
- /* Overflowing band (applied from JS when scrollHeight > clientHeight): dots
68
- fade out towards both edges — a pure visual cue for "more beyond" (the
69
- paging triangles do the actual scrolling). */
70
- .listScrollable {
71
- -webkit-mask-image: linear-gradient(to bottom, transparent, black 22px, black calc(100% - 22px), transparent);
72
- mask-image: linear-gradient(to bottom, transparent, black 22px, black calc(100% - 22px), transparent);
73
- }
74
67
 
75
68
  .dot {
76
69
  flex: none;
@@ -96,6 +89,50 @@
96
89
  background: var(--dsw-alias-brand-primary);
97
90
  }
98
91
 
92
+ /* Click-paging feedback: dots revealed by a ▲/▼ click pop in with a staggered
93
+ fade + rise/drop (per-dot animation-delay is set inline, stagger order *
94
+ 35ms), traveling in the paging direction so the newly revealed batch reads
95
+ as "the click brought these in". `both` fill holds the hidden `from` state
96
+ during each dot's delay. */
97
+ @keyframes dotEnterFromBottom {
98
+ from {
99
+ opacity: 0;
100
+ transform: translateY(7px) scale(0.4);
101
+ }
102
+ 60% {
103
+ opacity: 1;
104
+ }
105
+ to {
106
+ opacity: 1;
107
+ transform: translateY(0) scale(1);
108
+ }
109
+ }
110
+ @keyframes dotEnterFromTop {
111
+ from {
112
+ opacity: 0;
113
+ transform: translateY(-7px) scale(0.4);
114
+ }
115
+ 60% {
116
+ opacity: 1;
117
+ }
118
+ to {
119
+ opacity: 1;
120
+ transform: translateY(0) scale(1);
121
+ }
122
+ }
123
+ .dotEnterFromBottom {
124
+ animation: dotEnterFromBottom 320ms cubic-bezier(0.2, 0.9, 0.3, 1.2) both;
125
+ }
126
+ .dotEnterFromTop {
127
+ animation: dotEnterFromTop 320ms cubic-bezier(0.2, 0.9, 0.3, 1.2) both;
128
+ }
129
+ @media (prefers-reduced-motion: reduce) {
130
+ .dotEnterFromBottom,
131
+ .dotEnterFromTop {
132
+ animation: none;
133
+ }
134
+ }
135
+
99
136
  /* Question count, rendered just above the first dot (sits in the list gap). */
100
137
  .count {
101
138
  flex: none;
@@ -169,7 +206,7 @@
169
206
  position: fixed;
170
207
  z-index: 30;
171
208
  max-width: 260px;
172
- background: var(--dsw-alias-bg-layer-2);
209
+ background: var(--dsw-alias-bg-layer-3);
173
210
  border: 1px solid var(--dsw-alias-border-l1);
174
211
  border-radius: 6px;
175
212
  padding: 6px 10px;
@@ -196,11 +233,14 @@
196
233
  /* One focus question card (portal-rendered in the cascade). The four context
197
234
  cards use the base style: a clean solid surface, a quiet neutral border and
198
235
  a low shadow — crisp and readable, one visual step behind the focus card.
199
- Clicking jumps to the question, like the dot; hover raises the border to
200
- signal the card is interactive. */
236
+ The surface is the theme's TOP elevation layer (bg-layer-3, the same token
237
+ DSH menus and popups use): these cards float above the conversation, and in
238
+ dark themes the layer ramp gives them the raised-surface tint. Clicking
239
+ jumps to the question, like the dot; hover raises the border to signal the
240
+ card is interactive. */
201
241
  .card {
202
242
  width: 380px;
203
- background: var(--dsw-alias-bg-layer-1);
243
+ background: var(--dsw-alias-bg-layer-3);
204
244
  border: 1px solid var(--dsw-alias-border-l2);
205
245
  border-radius: 10px;
206
246
  padding: 8px 12px 10px;
@@ -291,6 +331,10 @@
291
331
  color: var(--dsw-alias-label-primary);
292
332
  margin: 0;
293
333
  }
334
+ /* Separate consecutive setting groups (align, then page size). */
335
+ .segmented + .settingsTitle {
336
+ margin-top: 12px;
337
+ }
294
338
  .settingsDesc {
295
339
  font-size: 12px;
296
340
  line-height: 18px;