@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.
@@ -0,0 +1,105 @@
1
+ /**
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).
6
+ *
7
+ * 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.
11
+ *
12
+ * @module dsh-question-nav/client/settings
13
+ */
14
+
15
+ import type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
16
+ import {
17
+ ALIGN_FIELD, ALIGN_OPTIONS, DEFAULT_ALIGN, QUESTION_NAV_SETTINGS_NS,
18
+ type AlignPreference,
19
+ } from '../core/align.ts'
20
+
21
+ /** Narrow a raw section to the anchor field. */
22
+ function isAlignPreference(value: unknown): value is AlignPreference {
23
+ return ALIGN_OPTIONS.some((option) => option === value)
24
+ }
25
+
26
+ /** The minimal face of the settings scope service this controller needs.
27
+ * Kept structural (bind only) so the controller stays decoupled from the
28
+ * full service and is unit-testable with a stub binder. */
29
+ export interface SettingsScopeBinderLike {
30
+ bind<T>(spec: SettingsScopeSpec<T>): SettingsScope<T>
31
+ }
32
+
33
+ /** Wire section this plugin owns (the Host schema's `align` field). */
34
+ interface QuestionNavSection {
35
+ align?: unknown
36
+ }
37
+
38
+ /** Snapshot consumed by the strip and the settings row. */
39
+ export interface QuestionNavSettingsState {
40
+ /** Last accepted anchor edge (default while the scope is absent/loading). */
41
+ align: AlignPreference
42
+ /** Whether the user layer overrides the composition default. */
43
+ overridden: boolean
44
+ }
45
+
46
+ /** Reactive handle over the plugin's durable settings section. */
47
+ export class QuestionNavSettingsController {
48
+ private scope: SettingsScope<QuestionNavSection> | undefined
49
+ private readonly listeners = new Set<() => void>()
50
+ private unsubscribe: () => void = () => {}
51
+ private state: QuestionNavSettingsState = { align: DEFAULT_ALIGN, overridden: false }
52
+
53
+ /**
54
+ * Bind the namespace scope once the settings surface is present. Called
55
+ * from a fiber that injects `settingsScope`, so the scope subscription
56
+ * lives on that fiber and is released with it. A no-op after the first
57
+ * bind.
58
+ * @param binder - the settings scope service.
59
+ */
60
+ attach(binder: SettingsScopeBinderLike): void {
61
+ if (this.scope !== undefined) return
62
+ this.scope = binder.bind<QuestionNavSection>({ namespace: QUESTION_NAV_SETTINGS_NS })
63
+ this.state = this.derive(this.scope.getSnapshot())
64
+ this.unsubscribe = this.scope.subscribe(() => {
65
+ if (this.scope === undefined) return
66
+ const next = this.derive(this.scope.getSnapshot())
67
+ if (next.align === this.state.align && next.overridden === this.state.overridden) return
68
+ this.state = next
69
+ for (const listener of this.listeners) listener()
70
+ })
71
+ }
72
+
73
+ private derive(snapshot: SettingsScopeSnapshot<QuestionNavSection>): QuestionNavSettingsState {
74
+ const user = snapshot.user as { align?: unknown } | undefined
75
+ return {
76
+ align: snapshot.status === 'ready' && isAlignPreference(snapshot.value?.align)
77
+ ? snapshot.value.align
78
+ : DEFAULT_ALIGN,
79
+ overridden: user !== undefined && user.align !== undefined,
80
+ }
81
+ }
82
+
83
+ /** Release the scope subscription (bound on the settings fiber's lifecycle). */
84
+ dispose(): void {
85
+ this.unsubscribe()
86
+ this.listeners.clear()
87
+ }
88
+
89
+ /** @returns the current state (stable reference until the next change). */
90
+ getSnapshot(): QuestionNavSettingsState {
91
+ return this.state
92
+ }
93
+
94
+ /** Observe state replacements; returns the disposer. */
95
+ subscribe(listener: () => void): () => void {
96
+ this.listeners.add(listener)
97
+ return () => { this.listeners.delete(listener) }
98
+ }
99
+
100
+ /** Route the user's anchor-edge choice to the Host document. */
101
+ setAlign(align: AlignPreference): void {
102
+ if (this.scope === undefined) return
103
+ void this.scope.set(ALIGN_FIELD, align)
104
+ }
105
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Rail anchor-edge constants shared by the host schema and the browser
3
+ * settings scope. Pure data: no DSH imports, so the client bundle may inline
4
+ * this module (a Host import here would leak into the browser half).
5
+ *
6
+ * @module dsh-question-nav/align
7
+ */
8
+
9
+ /** Supported rail anchor edges. */
10
+ export const ALIGN_OPTIONS = ['left', 'right'] as const
11
+
12
+ /** Rail anchor edge preference. */
13
+ export type AlignPreference = typeof ALIGN_OPTIONS[number]
14
+
15
+ /** Default anchor edge when the user-settings document has no override. */
16
+ export const DEFAULT_ALIGN: AlignPreference = 'left'
17
+
18
+ /** Settings namespace owned by this plugin (spelled here rather than
19
+ * imported: the client bundle must not depend on a Host package). */
20
+ export const QUESTION_NAV_SETTINGS_NS = 'question-nav'
21
+
22
+ /** Field carrying the selected anchor edge. */
23
+ export const ALIGN_FIELD = 'align'
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Focus-magnification model for the question-nav rail. Pure index/scale math
3
+ * for the hover-selected dot's neighborhood window, its progressive
4
+ * magnification tiers, and the vertical cascade of question cards (one per
5
+ * window dot, non-overlapping, sizes descending away from the selected one).
6
+ * No React, no DOM — every function here is unit-testable in isolation and
7
+ * safe to inline into the client bundle.
8
+ *
9
+ * @module dsh-question-nav/focus
10
+ */
11
+
12
+ /** How many dots stay enlarged on each side of the selected dot. */
13
+ export const FOCUS_RADIUS = 2
14
+
15
+ /** Magnification scale per tier, by distance from the selected dot
16
+ * (0 = selected, 1 = immediate neighbor, 2 = outer window edge). */
17
+ export const FOCUS_SCALES = [2.0, 1.55, 1.25] as const
18
+
19
+ /**
20
+ * The focus tier of a dot at `distance` from the selected dot: 0..FOCUS_RADIUS
21
+ * while inside the magnification window, null beyond it (base scale).
22
+ */
23
+ export function focusTier(distance: number): number | null {
24
+ const d = Math.abs(distance)
25
+ if (d > FOCUS_RADIUS) return null
26
+ return d
27
+ }
28
+
29
+ /** Magnification scale for a dot at `distance`; 1 (base) outside the window. */
30
+ export function focusScale(distance: number): number {
31
+ const tier = focusTier(distance)
32
+ return tier === null ? 1 : FOCUS_SCALES[tier]
33
+ }
34
+
35
+ /** Presentation metrics for the question card of a dot at `distance` from the
36
+ * selected one. Cards stay crisp (no blur): the selected card is the widest,
37
+ * brightest and shows every text line; each of its two neighbors on each side
38
+ * is a slightly narrower, dimmer card clamped to fewer lines. Null outside
39
+ * the window (no card). */
40
+ export interface FocusCardMetrics {
41
+ /** Card width in px (narrows away from the selected card). */
42
+ widthPx: number
43
+ /** Text font size (px); cascade cards are slightly smaller. */
44
+ fontSize: number
45
+ /** Max text lines before clamping (cascade cards). */
46
+ maxLines: number
47
+ /** Brightness (1 = full, the selected card; dimmer away from it). */
48
+ brightness: number
49
+ }
50
+
51
+ export function focusCardMetrics(distance: number): FocusCardMetrics | null {
52
+ const tier = focusTier(distance)
53
+ if (tier === null) return null
54
+ switch (tier) {
55
+ case 0:
56
+ return { widthPx: 380, fontSize: 13, maxLines: 6, brightness: 1 }
57
+ case 1:
58
+ return { widthPx: 300, fontSize: 12.5, maxLines: 2, brightness: 0.82 }
59
+ default:
60
+ return { widthPx: 240, fontSize: 12, maxLines: 1, brightness: 0.68 }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Indices of the focus window: the selected dot plus `radius` on each side,
66
+ * clipped to the dot list. Empty when the list is empty or `selected` is out
67
+ * of range.
68
+ */
69
+ export function magnificationWindow(total: number, selected: number, radius: number = FOCUS_RADIUS): number[] {
70
+ if (total <= 0 || selected < 0 || selected >= total || radius < 0) return []
71
+ const out: number[] = []
72
+ const lo = Math.max(0, selected - radius)
73
+ const hi = Math.min(total - 1, selected + radius)
74
+ for (let i = lo; i <= hi; i++) out.push(i)
75
+ return out
76
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Question sent-time formatting for the question-nav surface. Pure: no DSH
3
+ * imports, deterministic for a given (time, now) pair — unit-testable in
4
+ * isolation and safe to inline into the client bundle.
5
+ *
6
+ * @module dsh-question-nav/time
7
+ */
8
+
9
+ function pad2(n: number): string {
10
+ return n < 10 ? `0${n}` : String(n)
11
+ }
12
+
13
+ /** Whether `time` falls on the same calendar day as `now`. */
14
+ export function isSameDay(time: number, now: number): boolean {
15
+ const a = new Date(time)
16
+ const b = new Date(now)
17
+ return a.getFullYear() === b.getFullYear()
18
+ && a.getMonth() === b.getMonth()
19
+ && a.getDate() === b.getDate()
20
+ }
21
+
22
+ /**
23
+ * Smart question sent-time, relative to `now`:
24
+ * - same calendar day → `HH:MM`
25
+ * - same calendar year → `MM-DD HH:MM`
26
+ * - otherwise → `YYYY-MM-DD HH:MM`
27
+ *
28
+ * Returns `''` for a missing/invalid timestamp (e.g. a live node that never
29
+ * reported one), so callers can hide the time line without branching.
30
+ */
31
+ export function formatQuestionTime(time: number, now: number): string {
32
+ if (!Number.isFinite(time) || time <= 0) return ''
33
+ const d = new Date(time)
34
+ const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
35
+ if (isSameDay(time, now)) return hm
36
+ const md = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
37
+ if (d.getFullYear() === new Date(now).getFullYear()) return `${md} ${hm}`
38
+ return `${d.getFullYear()}-${md} ${hm}`
39
+ }
package/src/index.ts CHANGED
@@ -9,19 +9,24 @@
9
9
  */
10
10
  import type { Context } from '@deepseek-ai/cordis'
11
11
  import { questionIndexProjectionDefinition } from './projection.ts'
12
+ import { questionNavSettingsNamespace, QuestionNavSettingsSchema } from './settings.ts'
12
13
 
13
14
  /** Cordis plugin name. */
14
15
  export const name = 'dsh-question-nav'
15
16
 
16
17
  /**
17
- * Register the `questionIndex` unit. The registry is an optional capability
18
- * (absent in headless compositions), so registration rides `ctx.inject`:
19
- * without it the host half simply contributes nothing and the browser strip
20
- * falls back to live-window questions.
18
+ * Register the `questionIndex` unit and the plugin's durable settings
19
+ * namespace. Both registries are optional capabilities (absent in headless
20
+ * compositions), so each registration rides `ctx.inject`: without them the
21
+ * host half simply contributes nothing and the browser strip falls back to
22
+ * live-window questions and the default rail alignment.
21
23
  * @param ctx - plugin context.
22
24
  */
23
25
  export function apply(ctx: Context): void {
24
26
  ctx.inject(['sessionProjections'], (inner) => {
25
27
  inner.sessionProjections.register(questionIndexProjectionDefinition)
26
28
  })
29
+ ctx.inject(['settings'], (settingsCtx) => {
30
+ settingsCtx.settings.register(questionNavSettingsNamespace, QuestionNavSettingsSchema)
31
+ })
27
32
  }
@@ -0,0 +1,33 @@
1
+ /**
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`.
7
+ *
8
+ * @module dsh-question-nav/settings
9
+ */
10
+
11
+ import z from '@deepseek-ai/schemastery'
12
+ import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
13
+ import {
14
+ ALIGN_FIELD, ALIGN_OPTIONS, DEFAULT_ALIGN, QUESTION_NAV_SETTINGS_NS,
15
+ type AlignPreference,
16
+ } from './core/align.ts'
17
+
18
+ /** Durable settings section shared by the Host schema and the browser scope. */
19
+ export interface QuestionNavSettings {
20
+ /** Anchor edge of the rail. */
21
+ align: AlignPreference
22
+ }
23
+
24
+ /** Durable settings schema; also the wire envelope the browser scope validates against. */
25
+ export const QuestionNavSettingsSchema: z<QuestionNavSettings> = z.object({
26
+ [ALIGN_FIELD]: z.union([...ALIGN_OPTIONS]).default(DEFAULT_ALIGN),
27
+ })
28
+
29
+ /** The settings namespace this plugin owns, branded for the Host registry.
30
+ * `question-nav` matches the registry pattern (`^[a-z][a-z0-9-]*$`), so the
31
+ * constant needs no runtime validator — keeping this module type-only on the
32
+ * settings package avoids inlining it (and cosmokit) into the host bundle. */
33
+ export const questionNavSettingsNamespace = QUESTION_NAV_SETTINGS_NS as SettingsNamespace