@luziyang2026/dsh-question-nav 0.7.1 → 0.7.3

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.
@@ -12,8 +12,13 @@
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
+ *
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 of any kind.
17
22
  *
18
23
  * Data source: the host-folded `questionIndex` session projection (whole
19
24
  * history, persisted host-side, pushed live through session/projection
@@ -37,7 +42,7 @@ import type { QuestionEntry } from '../core/question-entry.ts'
37
42
  import { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'
38
43
  import type { AlignPreference } from '../core/align.ts'
39
44
  import type { JumpFailureCode } from '../core/jump.ts'
40
- import { EDGE_SCROLL_DWELL_MS, EDGE_SCROLL_ZONE, FOCUS_RADIUS, edgeScrollSpeed, focusCardMetrics, focusScale, focusTier, minimalScrollIntoView } from '../core/focus.ts'
45
+ import { FOCUS_RADIUS, clampScrollTop, focusCardMetrics, focusScale, focusTier, pageStep } from '../core/focus.ts'
41
46
  import { formatQuestionTime } from '../core/time.ts'
42
47
  import type { QuestionNavKey } from './locales.ts'
43
48
  import styles from './question-nav.module.css'
@@ -149,94 +154,32 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
149
154
  const clearFocusTimerRef = useRef<number | null>(null)
150
155
  // Last rendered dot list, for the change-detection bail-out below.
151
156
  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
157
  // Whether the dot band overflows its 60% clamp (drives the edge-fade mask
155
- // and the hover auto-scroll).
158
+ // and the ▲/▼ paging buttons).
156
159
  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
- // Hover-intent dwell timer: auto-scroll only starts after the pointer rests
163
- // in a fade zone for EDGE_SCROLL_DWELL_MS, so browsing dot-by-dot (or just
164
- // passing through the zone) never triggers an unwanted scroll.
165
- const edgeDwellRef = useRef<number | null>(null)
166
-
167
- const cancelEdgeDwell = (): void => {
168
- if (edgeDwellRef.current !== null) {
169
- window.clearTimeout(edgeDwellRef.current)
170
- edgeDwellRef.current = null
171
- }
172
- }
173
-
174
- const stopEdgeScroll = (): void => {
175
- cancelEdgeDwell()
176
- edgeScrollRef.current.speed = 0
177
- if (edgeScrollRef.current.raf !== 0) {
178
- cancelAnimationFrame(edgeScrollRef.current.raf)
179
- edgeScrollRef.current.raf = 0
180
- }
181
- }
160
+ // Scroll offset of the band + its max offset: drives the ▲/▼ visibility
161
+ // (each direction hides once there is nothing more to reveal).
162
+ const [scrollPos, setScrollPos] = useState<{ top: number; max: number }>({ top: 0, max: 0 })
182
163
 
183
- const edgeTick = (now: number): void => {
164
+ const syncScroll = (): void => {
184
165
  const list = listRef.current
185
- const state = edgeScrollRef.current
186
- state.raf = 0
187
- if (list === null || state.speed === 0) return
188
- const dt = Math.min(64, now - state.last) / 1000
189
- state.last = now
190
- const before = list.scrollTop
191
- list.scrollTop = before + state.speed * dt
192
- // Stop at the scroll bounds — there is nothing more to reveal.
193
- const atTop = list.scrollTop <= 0 && state.speed < 0
194
- const atBottom = list.scrollTop >= list.scrollHeight - list.clientHeight - 1 && state.speed > 0
195
- if (atTop || atBottom) {
196
- state.speed = 0
197
- return
198
- }
199
- state.raf = requestAnimationFrame(edgeTick)
166
+ if (list === null) return
167
+ const max = Math.max(0, list.scrollHeight - list.clientHeight)
168
+ setScrollPos({ top: list.scrollTop, max })
200
169
  }
201
170
 
202
- const onListMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
171
+ // Page the band by one DOT_PAGE_ROWS click in the given direction.
172
+ const pageBy = (dir: 1 | -1): void => {
203
173
  const list = listRef.current
204
174
  if (list === null) return
205
- // Hovering a dot is never a scroll intent: the user is reading that dot's
206
- // card, so suppress auto-scroll while the pointer is over any dot.
207
- const overDot = (e.target as HTMLElement).closest('[data-question-nav-index]') !== null
208
- const rect = list.getBoundingClientRect()
209
- const y = e.clientY - rect.top
210
- const depthTop = EDGE_SCROLL_ZONE - y
211
- const depthBottom = y - (rect.height - EDGE_SCROLL_ZONE)
212
- let speed = 0
213
- if (!overDot) {
214
- if (depthTop > 0 && list.scrollTop > 0) {
215
- speed = -edgeScrollSpeed(depthTop / EDGE_SCROLL_ZONE)
216
- } else if (depthBottom > 0 && list.scrollTop < list.scrollHeight - list.clientHeight - 1) {
217
- speed = edgeScrollSpeed(depthBottom / EDGE_SCROLL_ZONE)
218
- }
219
- }
220
- const state = edgeScrollRef.current
221
- state.speed = speed
222
- if (speed === 0) {
223
- // Left the zone (or moved onto a dot): cancel any pending dwell and stop.
224
- stopEdgeScroll()
225
- return
226
- }
227
- if (state.raf === 0 && edgeDwellRef.current === null) {
228
- // Start only after the pointer has rested in the zone for the dwell
229
- // time; re-check at fire time because the pointer may have left.
230
- edgeDwellRef.current = window.setTimeout(() => {
231
- edgeDwellRef.current = null
232
- if (edgeScrollRef.current.speed !== 0 && edgeScrollRef.current.raf === 0) {
233
- edgeScrollRef.current.last = performance.now()
234
- edgeScrollRef.current.raf = requestAnimationFrame(edgeTick)
235
- }
236
- }, EDGE_SCROLL_DWELL_MS)
237
- }
175
+ const max = Math.max(0, list.scrollHeight - list.clientHeight)
176
+ list.scrollTop = clampScrollTop(list.scrollTop + dir * pageStep(), max)
177
+ syncScroll()
238
178
  }
239
179
 
180
+ const canPageUp = scrollable && scrollPos.top > 0
181
+ const canPageDown = scrollable && scrollPos.top < scrollPos.max
182
+
240
183
  // Focus persists briefly after leaving the rail, so the mouse can reach the
241
184
  // clickable cascade cards; entering a card cancels the clear, leaving
242
185
  // everything re-arms it.
@@ -412,40 +355,16 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
412
355
  }
413
356
  }, [visible, align])
414
357
 
415
- // Keep the focused dot visible inside the (≤60% tall, scrollable) band:
416
- // scroll only when the dot (plus room for its two magnified neighbors) is
417
- // clipped by the band edges, and then only by the minimal amount — never
418
- // re-center an already-visible dot, so browsing dot-by-dot doesn't shift
419
- // the band under the pointer. Suppressed while the edge auto-scroll is
420
- // driving: hovering dots that flow under a parked pointer would otherwise
421
- // fight the scroll.
422
- useLayoutEffect(() => {
423
- const key = focus?.key ?? null
424
- if (lastFocusedKeyRef.current === key) return
425
- lastFocusedKeyRef.current = key
426
- if (key === null) return
427
- if (edgeScrollRef.current.raf !== 0) return
428
- const list = listRef.current
429
- if (list === null) return
430
- const target = list.querySelector<HTMLElement>('[data-question-nav-focused="true"]')
431
- if (target === null) return
432
- const next = minimalScrollIntoView(
433
- list.scrollTop,
434
- list.clientHeight,
435
- list.scrollHeight,
436
- target.offsetTop - list.offsetTop,
437
- target.offsetHeight,
438
- )
439
- if (next !== null) list.scrollTop = next
440
- }, [focus])
441
-
442
- // Detect band overflow: drives the edge-fade mask + hover auto-scroll.
358
+ // Detect band overflow: drives the edge-fade mask + the ▲/▼ paging buttons.
443
359
  // Re-checked when the dots change and whenever the band itself resizes
444
360
  // (the layout loop above clamps it to the conversation height).
445
361
  useLayoutEffect(() => {
446
362
  const list = listRef.current
447
363
  if (list === null) return
448
- const check = (): void => setScrollable(list.scrollHeight > list.clientHeight + 1)
364
+ const check = (): void => {
365
+ setScrollable(list.scrollHeight > list.clientHeight + 1)
366
+ syncScroll()
367
+ }
449
368
  check()
450
369
  if (typeof ResizeObserver === 'undefined') return
451
370
  const observer = new ResizeObserver(check)
@@ -453,11 +372,10 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
453
372
  return () => observer.disconnect()
454
373
  }, [dots, align])
455
374
 
456
- // Clear any pending timers and the edge auto-scroll on unmount.
375
+ // Clear any pending timers on unmount.
457
376
  useEffect(() => () => {
458
377
  if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)
459
378
  if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current)
460
- stopEdgeScroll()
461
379
  // eslint-disable-next-line react-hooks/exhaustive-deps
462
380
  }, [])
463
381
 
@@ -499,43 +417,66 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
499
417
  className={align === 'right' ? `${styles.rail} ${styles.railRight}` : styles.rail}
500
418
  data-question-nav="rail"
501
419
  >
502
- <div
503
- ref={listRef}
504
- className={scrollable ? `${styles.list} ${styles.listScrollable}` : styles.list}
505
- onMouseMove={scrollable ? onListMouseMove : undefined}
506
- onMouseLeave={() => {
507
- stopEdgeScroll()
508
- scheduleClearFocus()
509
- }}
510
- >
420
+ <div className={styles.queue}>
511
421
  {dots.length === 0 ? (
512
422
  <div className={styles.empty}>{t('strip.empty')}</div>
513
423
  ) : (
514
- <div className={styles.dots}>
424
+ <>
515
425
  <span className={styles.count}>{dots.length}</span>
516
- {dots.map((dot, index) => {
517
- const isFocused = focus !== null && focus.key === dot.key
518
- // Progressive magnification: selected largest, ±1 smaller, ±2
519
- // smaller still, the rest base scale.
520
- const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex)
521
- const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1
522
- const cls = [styles.dot]
523
- if (isFocused) cls.push(styles.focused)
524
- if (jumpingKey === dot.key) cls.push(styles.active)
525
- return (
526
- <button
527
- key={dot.key}
528
- className={cls.join(' ')}
529
- style={{ transform: `scale(${scale})` }}
530
- data-question-nav-focused={isFocused ? 'true' : undefined}
531
- data-question-nav-index={index}
532
- aria-label={dot.texts[0] ?? ''}
533
- onMouseEnter={(e) => openFocus(dot, e.currentTarget)}
534
- onClick={() => onJump(dot)}
535
- />
536
- )
537
- })}
538
- </div>
426
+ {scrollable ? (
427
+ <button
428
+ type="button"
429
+ className={`${styles.navBtn} ${styles.navUp}`}
430
+ aria-label={t('strip.up')}
431
+ style={{ visibility: canPageUp ? 'visible' : 'hidden' }}
432
+ onMouseEnter={cancelClearFocus}
433
+ onMouseLeave={scheduleClearFocus}
434
+ onClick={() => pageBy(-1)}
435
+ />
436
+ ) : null}
437
+ <div
438
+ ref={listRef}
439
+ className={scrollable ? `${styles.list} ${styles.listScrollable}` : styles.list}
440
+ onScroll={syncScroll}
441
+ onMouseLeave={scheduleClearFocus}
442
+ >
443
+ <div className={styles.dots}>
444
+ {dots.map((dot, index) => {
445
+ const isFocused = focus !== null && focus.key === dot.key
446
+ // Progressive magnification: selected largest, ±1 smaller, ±2
447
+ // smaller still, the rest base scale.
448
+ const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex)
449
+ const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1
450
+ const cls = [styles.dot]
451
+ if (isFocused) cls.push(styles.focused)
452
+ if (jumpingKey === dot.key) cls.push(styles.active)
453
+ return (
454
+ <button
455
+ key={dot.key}
456
+ className={cls.join(' ')}
457
+ style={{ transform: `scale(${scale})` }}
458
+ data-question-nav-focused={isFocused ? 'true' : undefined}
459
+ data-question-nav-index={index}
460
+ aria-label={dot.texts[0] ?? ''}
461
+ onMouseEnter={(e) => openFocus(dot, e.currentTarget)}
462
+ onClick={() => onJump(dot)}
463
+ />
464
+ )
465
+ })}
466
+ </div>
467
+ </div>
468
+ {scrollable ? (
469
+ <button
470
+ type="button"
471
+ className={`${styles.navBtn} ${styles.navDown}`}
472
+ aria-label={t('strip.down')}
473
+ style={{ visibility: canPageDown ? 'visible' : 'hidden' }}
474
+ onMouseEnter={cancelClearFocus}
475
+ onMouseLeave={scheduleClearFocus}
476
+ onClick={() => pageBy(1)}
477
+ />
478
+ ) : null}
479
+ </>
539
480
  )}
540
481
  </div>
541
482
  {hint !== null
@@ -4,6 +4,8 @@
4
4
  */
5
5
  export const zh = {
6
6
  'strip.empty': '本会话还没有提问',
7
+ 'strip.up': '向上翻出 5 个提问圆点',
8
+ 'strip.down': '向下翻出 5 个提问圆点',
7
9
  'jump.inactive': '聊天视图未激活',
8
10
  'jump.hidden': '目标无独立气泡,已定位到邻近内容',
9
11
  'jump.notfound': '目标未加载或不存在(可能已压缩)',
@@ -17,6 +19,8 @@ export const zh = {
17
19
 
18
20
  export const en = {
19
21
  'strip.empty': 'No questions in this session yet',
22
+ 'strip.up': 'Reveal 5 question dots above',
23
+ 'strip.down': 'Reveal 5 question dots below',
20
24
  'jump.inactive': 'Chat view is not active',
21
25
  'jump.hidden': 'No dedicated bubble; landed on nearby content',
22
26
  'jump.notfound': 'Target not loaded or missing (maybe compacted)',
@@ -21,19 +21,34 @@
21
21
  left: auto;
22
22
  }
23
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). 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. */
31
- .list {
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. */
30
+ .queue {
32
31
  flex: 0 1 auto;
33
32
  min-height: 0;
34
33
  max-height: 60%;
35
34
  margin: auto 0;
36
35
  width: 100%;
36
+ display: flex;
37
+ flex-direction: column;
38
+ align-items: center;
39
+ gap: 6px;
40
+ box-sizing: border-box;
41
+ pointer-events: none;
42
+ }
43
+
44
+ /* The middle dot band: the only scrollable part of the queue. Scrolls from the
45
+ top (overflow collapses the auto margins to 0). The native scrollbar is
46
+ always hidden: this is a minimap paged by the ▲/▼ buttons and by
47
+ wheel/trackpad, and overflow is signalled by the edge fade below. */
48
+ .list {
49
+ flex: 1 1 auto;
50
+ min-height: 0;
51
+ width: 100%;
37
52
  overflow-y: auto;
38
53
  overflow-x: hidden;
39
54
  display: flex;
@@ -50,18 +65,12 @@
50
65
  display: none;
51
66
  }
52
67
  /* 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). */
68
+ fade out towards both edges — a pure visual cue for "more beyond" (the
69
+ paging triangles do the actual scrolling). */
55
70
  .listScrollable {
56
71
  -webkit-mask-image: linear-gradient(to bottom, transparent, black 22px, black calc(100% - 22px), transparent);
57
72
  mask-image: linear-gradient(to bottom, transparent, black 22px, black calc(100% - 22px), transparent);
58
73
  }
59
- .list > *:first-child {
60
- margin-top: auto;
61
- }
62
- .list > *:last-child {
63
- margin-bottom: auto;
64
- }
65
74
 
66
75
  .dot {
67
76
  flex: none;
@@ -97,7 +106,7 @@
97
106
  user-select: none;
98
107
  }
99
108
 
100
- /* The centered group: count + dot column, centered together (the list's auto
109
+ /* The centered group: dot column, centered together (the list's auto
101
110
  margins center it when short; it scrolls as a unit when tall). */
102
111
  .dots {
103
112
  display: flex;
@@ -106,6 +115,46 @@
106
115
  gap: 6px;
107
116
  }
108
117
 
118
+ /* Paging triangles (▲ above the dot queue, ▼ below), styled like the dots:
119
+ a plain triangle in the dot's quiet border color that lights up brand on
120
+ hover. Clicking pages the band by DOT_PAGE_ROWS dots. The triangle is a
121
+ pure CSS border triangle so it stays crisp at any DPI; the button box is a
122
+ slightly larger transparent hit target. */
123
+ .navBtn {
124
+ flex: none;
125
+ pointer-events: auto;
126
+ width: 14px;
127
+ height: 12px;
128
+ padding: 0;
129
+ border: none;
130
+ background: transparent;
131
+ cursor: pointer;
132
+ display: flex;
133
+ align-items: center;
134
+ justify-content: center;
135
+ }
136
+ .navBtn::before {
137
+ content: '';
138
+ display: block;
139
+ width: 0;
140
+ height: 0;
141
+ border-left: 5px solid transparent;
142
+ border-right: 5px solid transparent;
143
+ transition: border-color 120ms ease;
144
+ }
145
+ .navUp::before {
146
+ border-bottom: 7px solid var(--dsw-alias-border-l3);
147
+ }
148
+ .navDown::before {
149
+ border-top: 7px solid var(--dsw-alias-border-l3);
150
+ }
151
+ .navUp:hover::before {
152
+ border-bottom-color: var(--dsw-alias-brand-primary);
153
+ }
154
+ .navDown:hover::before {
155
+ border-top-color: var(--dsw-alias-brand-primary);
156
+ }
157
+
109
158
  .empty {
110
159
  padding: 10px 4px;
111
160
  font-size: 11px;
package/src/core/focus.ts CHANGED
@@ -75,58 +75,37 @@ export function magnificationWindow(total: number, selected: number, radius: num
75
75
  return out
76
76
  }
77
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
- /** Clearance (px) kept around the focused dot when scrolling it into view, so
83
- * its two magnified neighbors on each side stay inside the band's clear area
84
- * (2 dot rows ≈ 28px + the 22px fade zone). */
85
- export const FOCUS_NEIGHBOR_CLEARANCE = 50
86
-
87
78
  /**
88
- * Minimal "scroll into view" for the focused dot, in the spirit of
89
- * scrollIntoView({ block: 'nearest' }): returns the scrollTop that brings the
90
- * dot — plus `clearance` room for its magnified neighbors — inside the
91
- * visible band with the smallest possible movement, or null when the dot is
92
- * already fully visible. Unlike unconditional centering this never shifts the
93
- * band while the user browses dot-by-dot: only a clipped dot is scrolled.
79
+ * Paging model for the dot band. Overflowing dots are not auto-scrolled by
80
+ * hovering the edges or hovering a clipped dot: instead the user pages them
81
+ * with two triangle buttons (▲ above the dot queue, ▼ below it), each click
82
+ * revealing `DOT_PAGE_ROWS` hidden dots. Pure arithmetic — no React, no DOM.
94
83
  */
95
- export function minimalScrollIntoView(
96
- scrollTop: number,
97
- clientHeight: number,
98
- scrollHeight: number,
99
- dotTop: number,
100
- dotHeight: number,
101
- clearance: number = FOCUS_NEIGHBOR_CLEARANCE,
102
- ): number | null {
103
- const margin = Math.min(clearance, clientHeight / 4)
104
- const viewTop = scrollTop + margin
105
- const viewBottom = scrollTop + clientHeight - margin
106
- if (dotTop >= viewTop && dotTop + dotHeight <= viewBottom) return null
107
- const next = dotTop < viewTop
108
- ? dotTop - margin
109
- : dotTop + dotHeight + margin - clientHeight
110
- return Math.max(0, Math.min(next, Math.max(0, scrollHeight - clientHeight)))
84
+
85
+ /** How many hidden dots one click of a paging triangle reveals. */
86
+ export const DOT_PAGE_ROWS = 5
87
+
88
+ /** Base dot diameter (px) and gap (px), matching the rail CSS — the paging
89
+ * step is expressed in whole dot rows, so it tracks the visible geometry. */
90
+ export const DOT_SIZE = 8
91
+ export const DOT_GAP = 6
92
+
93
+ /** Scroll step (px) for one paging click: `rows` whole dot rows. */
94
+ export function pageStep(dotSize: number = DOT_SIZE, gap: number = DOT_GAP, rows: number = DOT_PAGE_ROWS): number {
95
+ return rows * (dotSize + gap)
111
96
  }
112
97
 
113
- /** Hover-intent dwell (ms): the pointer must rest inside a fade zone this
114
- * long before auto-scroll starts, so sweeping up/down the dots one by one
115
- * (or just passing through the zone) never triggers an unwanted scroll. */
116
- export const EDGE_SCROLL_DWELL_MS = 200
98
+ /** Clamp a target scrollTop into the band's valid range (0..maxScroll). */
99
+ export function clampScrollTop(target: number, maxScroll: number): number {
100
+ return Math.max(0, Math.min(target, Math.max(0, maxScroll)))
101
+ }
117
102
 
118
- /** Edge auto-scroll speed range (px/s): MIN at the zone boundary, MAX at the
119
- * very edge of the band. */
120
- export const EDGE_SCROLL_MIN_SPEED = 90
121
- export const EDGE_SCROLL_MAX_SPEED = 420
103
+ /** Whether hidden dots remain above the band (show ▲). */
104
+ export function canScrollAbove(scrollTop: number): boolean {
105
+ return scrollTop > 0
106
+ }
122
107
 
123
- /**
124
- * Auto-scroll speed (px/s) for a pointer at `ratio` depth into an edge fade
125
- * zone (0 = at the zone boundary, 1 = at the band's very edge): eases
126
- * linearly from MIN to MAX so a shallow probe scrolls gently and pushing
127
- * into the edge moves fast. The sign (direction) is applied by the caller.
128
- */
129
- export function edgeScrollSpeed(ratio: number): number {
130
- const r = Math.max(0, Math.min(1, ratio))
131
- return EDGE_SCROLL_MIN_SPEED + (EDGE_SCROLL_MAX_SPEED - EDGE_SCROLL_MIN_SPEED) * r
108
+ /** Whether hidden dots remain below the band (show ▼). */
109
+ export function canScrollBelow(scrollTop: number, maxScroll: number): boolean {
110
+ return scrollTop < maxScroll
132
111
  }