@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.
- package/README.md +29 -9
- package/README.zh.md +22 -7
- package/lib/client.js +408 -50
- package/lib/client.js.map +1 -1
- package/lib/index.js +832 -4
- package/lib/types/client/QuestionNavSettingsTab.d.ts +14 -0
- package/lib/types/client/QuestionNavStrip.d.ts +7 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/client/settings.d.ts +52 -0
- package/lib/types/core/align.d.ts +18 -0
- package/lib/types/core/focus.d.ts +44 -0
- package/lib/types/core/time.d.ts +19 -0
- package/lib/types/index.d.ts +5 -4
- package/lib/types/settings.d.ts +24 -0
- package/package.json +7 -3
- package/src/client/QuestionNavSettingsTab.tsx +53 -0
- package/src/client/QuestionNavStrip.tsx +225 -46
- package/src/client/index.ts +28 -2
- package/src/client/locales.ts +10 -0
- package/src/client/question-nav.module.css +147 -16
- package/src/client/settings.ts +105 -0
- package/src/core/align.ts +23 -0
- package/src/core/focus.ts +76 -0
- package/src/core/time.ts +39 -0
- package/src/index.ts +9 -4
- package/src/settings.ts +33 -0
|
@@ -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
|
+
}
|
package/src/core/time.ts
ADDED
|
@@ -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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* falls back to
|
|
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
|
}
|
package/src/settings.ts
ADDED
|
@@ -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
|