@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,13 +1,14 @@
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
  */
@@ -17,12 +18,21 @@ import {
17
18
  ALIGN_FIELD, ALIGN_OPTIONS, DEFAULT_ALIGN, QUESTION_NAV_SETTINGS_NS,
18
19
  type AlignPreference,
19
20
  } from '../core/align.ts'
21
+ import {
22
+ DEFAULT_PAGE_SIZE, PAGE_SIZE_FIELD, PAGE_SIZE_OPTIONS,
23
+ type PageSize,
24
+ } from '../core/page-size.ts'
20
25
 
21
- /** Narrow a raw section to the anchor field. */
26
+ /** Narrow a raw section value to the anchor field. */
22
27
  function isAlignPreference(value: unknown): value is AlignPreference {
23
28
  return ALIGN_OPTIONS.some((option) => option === value)
24
29
  }
25
30
 
31
+ /** Narrow a raw section value to the page-size field. */
32
+ function isPageSize(value: unknown): value is PageSize {
33
+ return (PAGE_SIZE_OPTIONS as readonly unknown[]).includes(value)
34
+ }
35
+
26
36
  /** The minimal face of the settings scope service this controller needs.
27
37
  * Kept structural (bind only) so the controller stays decoupled from the
28
38
  * full service and is unit-testable with a stub binder. */
@@ -30,15 +40,18 @@ export interface SettingsScopeBinderLike {
30
40
  bind<T>(spec: SettingsScopeSpec<T>): SettingsScope<T>
31
41
  }
32
42
 
33
- /** Wire section this plugin owns (the Host schema's `align` field). */
43
+ /** Wire section this plugin owns (the Host schema's fields). */
34
44
  interface QuestionNavSection {
35
45
  align?: unknown
46
+ pageSize?: unknown
36
47
  }
37
48
 
38
- /** Snapshot consumed by the strip and the settings row. */
49
+ /** Snapshot consumed by the strip and the settings rows. */
39
50
  export interface QuestionNavSettingsState {
40
51
  /** Last accepted anchor edge (default while the scope is absent/loading). */
41
52
  align: AlignPreference
53
+ /** Last accepted page size (default while the scope is absent/loading). */
54
+ pageSize: PageSize
42
55
  /** Whether the user layer overrides the composition default. */
43
56
  overridden: boolean
44
57
  }
@@ -48,7 +61,7 @@ export class QuestionNavSettingsController {
48
61
  private scope: SettingsScope<QuestionNavSection> | undefined
49
62
  private readonly listeners = new Set<() => void>()
50
63
  private unsubscribe: () => void = () => {}
51
- private state: QuestionNavSettingsState = { align: DEFAULT_ALIGN, overridden: false }
64
+ private state: QuestionNavSettingsState = { align: DEFAULT_ALIGN, pageSize: DEFAULT_PAGE_SIZE, overridden: false }
52
65
 
53
66
  /**
54
67
  * Bind the namespace scope once the settings surface is present. Called
@@ -64,19 +77,23 @@ export class QuestionNavSettingsController {
64
77
  this.unsubscribe = this.scope.subscribe(() => {
65
78
  if (this.scope === undefined) return
66
79
  const next = this.derive(this.scope.getSnapshot())
67
- if (next.align === this.state.align && next.overridden === this.state.overridden) return
80
+ if (next.align === this.state.align && next.pageSize === this.state.pageSize
81
+ && next.overridden === this.state.overridden) return
68
82
  this.state = next
69
83
  for (const listener of this.listeners) listener()
70
84
  })
71
85
  }
72
86
 
73
87
  private derive(snapshot: SettingsScopeSnapshot<QuestionNavSection>): QuestionNavSettingsState {
74
- const user = snapshot.user as { align?: unknown } | undefined
88
+ const user = snapshot.user as QuestionNavSection | undefined
75
89
  return {
76
90
  align: snapshot.status === 'ready' && isAlignPreference(snapshot.value?.align)
77
91
  ? snapshot.value.align
78
92
  : DEFAULT_ALIGN,
79
- overridden: user !== undefined && user.align !== undefined,
93
+ pageSize: snapshot.status === 'ready' && isPageSize(snapshot.value?.pageSize)
94
+ ? snapshot.value.pageSize
95
+ : DEFAULT_PAGE_SIZE,
96
+ overridden: user !== undefined && (user.align !== undefined || user.pageSize !== undefined),
80
97
  }
81
98
  }
82
99
 
@@ -102,4 +119,10 @@ export class QuestionNavSettingsController {
102
119
  if (this.scope === undefined) return
103
120
  void this.scope.set(ALIGN_FIELD, align)
104
121
  }
122
+
123
+ /** Route the user's page-size choice to the Host document. */
124
+ setPageSize(pageSize: PageSize): void {
125
+ if (this.scope === undefined) return
126
+ void this.scope.set(PAGE_SIZE_FIELD, pageSize)
127
+ }
105
128
  }
package/src/core/focus.ts CHANGED
@@ -75,42 +75,11 @@ export function magnificationWindow(total: number, selected: number, radius: num
75
75
  return out
76
76
  }
77
77
 
78
- /** Clearance (px) kept around the focused dot when scrolling it into view, so
79
- * its two magnified neighbors on each side stay inside the band's clear area
80
- * (2 dot rows ≈ 28px + the 22px fade zone). */
81
- export const FOCUS_NEIGHBOR_CLEARANCE = 50
82
-
83
- /**
84
- * Minimal "scroll into view" for the focused dot, in the spirit of
85
- * scrollIntoView({ block: 'nearest' }): returns the scrollTop that brings the
86
- * dot — plus `clearance` room for its magnified neighbors — inside the
87
- * visible band with the smallest possible movement, or null when the dot is
88
- * already fully visible. Unlike unconditional centering this never shifts the
89
- * band while the user browses dot-by-dot: only a clipped dot is scrolled.
90
- */
91
- export function minimalScrollIntoView(
92
- scrollTop: number,
93
- clientHeight: number,
94
- scrollHeight: number,
95
- dotTop: number,
96
- dotHeight: number,
97
- clearance: number = FOCUS_NEIGHBOR_CLEARANCE,
98
- ): number | null {
99
- const margin = Math.min(clearance, clientHeight / 4)
100
- const viewTop = scrollTop + margin
101
- const viewBottom = scrollTop + clientHeight - margin
102
- if (dotTop >= viewTop && dotTop + dotHeight <= viewBottom) return null
103
- const next = dotTop < viewTop
104
- ? dotTop - margin
105
- : dotTop + dotHeight + margin - clientHeight
106
- return Math.max(0, Math.min(next, Math.max(0, scrollHeight - clientHeight)))
107
- }
108
-
109
78
  /**
110
79
  * Paging model for the dot band. Overflowing dots are not auto-scrolled by
111
- * hovering the edges: instead the user pages them with two triangle buttons
112
- * (▲ above the dot queue, ▼ below it), each click revealing `DOT_PAGE_ROWS`
113
- * hidden dots. Pure arithmetic — no React, no DOM.
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.
114
83
  */
115
84
 
116
85
  /** How many hidden dots one click of a paging triangle reveals. */
@@ -0,0 +1,20 @@
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
+
10
+ /** Selectable page sizes: how many hidden dots one ▲/▼ click reveals. */
11
+ export const PAGE_SIZE_OPTIONS = [3, 5, 8, 10] as const
12
+
13
+ /** Page-size preference (dots revealed per paging-button click). */
14
+ export type PageSize = typeof PAGE_SIZE_OPTIONS[number]
15
+
16
+ /** Default page size when the user-settings document has no override. */
17
+ export const DEFAULT_PAGE_SIZE: PageSize = 5
18
+
19
+ /** Field carrying the selected page size. */
20
+ export const PAGE_SIZE_FIELD = 'pageSize'
package/src/settings.ts CHANGED
@@ -1,9 +1,10 @@
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
  */
@@ -14,16 +15,23 @@ import {
14
15
  ALIGN_FIELD, ALIGN_OPTIONS, DEFAULT_ALIGN, QUESTION_NAV_SETTINGS_NS,
15
16
  type AlignPreference,
16
17
  } from './core/align.ts'
18
+ import {
19
+ DEFAULT_PAGE_SIZE, PAGE_SIZE_FIELD, PAGE_SIZE_OPTIONS,
20
+ type PageSize,
21
+ } from './core/page-size.ts'
17
22
 
18
23
  /** Durable settings section shared by the Host schema and the browser scope. */
19
24
  export interface QuestionNavSettings {
20
25
  /** Anchor edge of the rail. */
21
26
  align: AlignPreference
27
+ /** Dots revealed per ▲/▼ paging-button click. */
28
+ pageSize: PageSize
22
29
  }
23
30
 
24
31
  /** Durable settings schema; also the wire envelope the browser scope validates against. */
25
32
  export const QuestionNavSettingsSchema: z<QuestionNavSettings> = z.object({
26
33
  [ALIGN_FIELD]: z.union([...ALIGN_OPTIONS]).default(DEFAULT_ALIGN),
34
+ [PAGE_SIZE_FIELD]: z.union([...PAGE_SIZE_OPTIONS]).default(DEFAULT_PAGE_SIZE),
27
35
  })
28
36
 
29
37
  /** The settings namespace this plugin owns, branded for the Host registry.