@thehoneyjar/sigil-hud 0.1.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/LICENSE.md +660 -0
- package/README.md +146 -0
- package/dist/index.d.ts +911 -0
- package/dist/index.js +3079 -0
- package/package.json +68 -0
- package/src/components/DataSourceIndicator.tsx +185 -0
- package/src/components/DiagnosticsPanel.tsx +444 -0
- package/src/components/FeedbackPrompt.tsx +348 -0
- package/src/components/HudPanel.tsx +179 -0
- package/src/components/HudTrigger.tsx +81 -0
- package/src/components/IssueList.tsx +228 -0
- package/src/components/LensPanel.tsx +286 -0
- package/src/components/ObservationCaptureModal.tsx +502 -0
- package/src/components/PhysicsAnalysis.tsx +273 -0
- package/src/components/SimulationPanel.tsx +173 -0
- package/src/components/StateComparison.tsx +238 -0
- package/src/hooks/useDataSource.ts +324 -0
- package/src/hooks/useKeyboardShortcuts.ts +125 -0
- package/src/hooks/useObservationCapture.ts +154 -0
- package/src/hooks/useSignalCapture.ts +138 -0
- package/src/index.ts +112 -0
- package/src/providers/HudProvider.tsx +115 -0
- package/src/store.ts +60 -0
- package/src/styles/theme.ts +256 -0
- package/src/types.ts +276 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Source Hook
|
|
3
|
+
*
|
|
4
|
+
* Track data source and staleness for displayed values.
|
|
5
|
+
* Implements TASK-202: State source tracing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
9
|
+
import type { DataSourceType } from '../components/DataSourceIndicator'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Data source metadata
|
|
13
|
+
*/
|
|
14
|
+
export interface DataSourceMeta {
|
|
15
|
+
/** Data source type */
|
|
16
|
+
source: DataSourceType
|
|
17
|
+
/** When the data was fetched */
|
|
18
|
+
fetchedAt: number
|
|
19
|
+
/** Block number (for on-chain data) */
|
|
20
|
+
blockNumber?: number
|
|
21
|
+
/** Current block (for staleness calculation) */
|
|
22
|
+
currentBlock?: number
|
|
23
|
+
/** Whether the data is stale */
|
|
24
|
+
isStale: boolean
|
|
25
|
+
/** Staleness level */
|
|
26
|
+
stalenessLevel: 'fresh' | 'stale' | 'very-stale'
|
|
27
|
+
/** Human-readable staleness label */
|
|
28
|
+
stalenessLabel: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Props for useDataSource
|
|
33
|
+
*/
|
|
34
|
+
export interface UseDataSourceProps {
|
|
35
|
+
/** Data source type */
|
|
36
|
+
source: DataSourceType
|
|
37
|
+
/** Block number for on-chain data */
|
|
38
|
+
blockNumber?: number
|
|
39
|
+
/** Interval to check staleness (ms) */
|
|
40
|
+
pollInterval?: number
|
|
41
|
+
/** Threshold for "stale" in seconds (for time-based) */
|
|
42
|
+
staleThresholdSeconds?: number
|
|
43
|
+
/** Threshold for "very stale" in seconds (for time-based) */
|
|
44
|
+
veryStaleThresholdSeconds?: number
|
|
45
|
+
/** Threshold for "stale" in blocks (for block-based) */
|
|
46
|
+
staleThresholdBlocks?: number
|
|
47
|
+
/** Threshold for "very stale" in blocks (for block-based) */
|
|
48
|
+
veryStaleThresholdBlocks?: number
|
|
49
|
+
/** Callback when data becomes stale */
|
|
50
|
+
onStale?: () => void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Default staleness thresholds
|
|
55
|
+
*/
|
|
56
|
+
const DEFAULT_STALE_SECONDS = 60 // 1 minute
|
|
57
|
+
const DEFAULT_VERY_STALE_SECONDS = 300 // 5 minutes
|
|
58
|
+
const DEFAULT_STALE_BLOCKS = 5
|
|
59
|
+
const DEFAULT_VERY_STALE_BLOCKS = 20
|
|
60
|
+
const DEFAULT_POLL_INTERVAL = 10000 // 10 seconds
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Calculate staleness for time-based data
|
|
64
|
+
*/
|
|
65
|
+
function calculateTimeStaleness(
|
|
66
|
+
fetchedAt: number,
|
|
67
|
+
staleThreshold: number,
|
|
68
|
+
veryStaleThreshold: number
|
|
69
|
+
): { isStale: boolean; level: 'fresh' | 'stale' | 'very-stale'; label: string } {
|
|
70
|
+
const secondsAgo = Math.floor((Date.now() - fetchedAt) / 1000)
|
|
71
|
+
|
|
72
|
+
if (secondsAgo < 30) {
|
|
73
|
+
return { isStale: false, level: 'fresh', label: 'just now' }
|
|
74
|
+
}
|
|
75
|
+
if (secondsAgo < 60) {
|
|
76
|
+
return { isStale: false, level: 'fresh', label: `${secondsAgo}s ago` }
|
|
77
|
+
}
|
|
78
|
+
if (secondsAgo < staleThreshold) {
|
|
79
|
+
return { isStale: false, level: 'fresh', label: `${Math.floor(secondsAgo / 60)}m ago` }
|
|
80
|
+
}
|
|
81
|
+
if (secondsAgo < veryStaleThreshold) {
|
|
82
|
+
return { isStale: true, level: 'stale', label: `${Math.floor(secondsAgo / 60)}m ago` }
|
|
83
|
+
}
|
|
84
|
+
return { isStale: true, level: 'very-stale', label: `${Math.floor(secondsAgo / 60)}m ago` }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Calculate staleness for block-based data
|
|
89
|
+
*/
|
|
90
|
+
function calculateBlockStaleness(
|
|
91
|
+
blockNumber: number,
|
|
92
|
+
currentBlock: number,
|
|
93
|
+
staleThreshold: number,
|
|
94
|
+
veryStaleThreshold: number
|
|
95
|
+
): { isStale: boolean; level: 'fresh' | 'stale' | 'very-stale'; label: string } {
|
|
96
|
+
const blocksBehind = currentBlock - blockNumber
|
|
97
|
+
|
|
98
|
+
if (blocksBehind <= 1) {
|
|
99
|
+
return { isStale: false, level: 'fresh', label: 'current' }
|
|
100
|
+
}
|
|
101
|
+
if (blocksBehind <= staleThreshold) {
|
|
102
|
+
return { isStale: false, level: 'fresh', label: `${blocksBehind} blocks behind` }
|
|
103
|
+
}
|
|
104
|
+
if (blocksBehind <= veryStaleThreshold) {
|
|
105
|
+
return { isStale: true, level: 'stale', label: `${blocksBehind} blocks behind` }
|
|
106
|
+
}
|
|
107
|
+
return { isStale: true, level: 'very-stale', label: `${blocksBehind} blocks behind` }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Hook to track data source and staleness
|
|
112
|
+
*/
|
|
113
|
+
export function useDataSource({
|
|
114
|
+
source,
|
|
115
|
+
blockNumber,
|
|
116
|
+
pollInterval = DEFAULT_POLL_INTERVAL,
|
|
117
|
+
staleThresholdSeconds = DEFAULT_STALE_SECONDS,
|
|
118
|
+
veryStaleThresholdSeconds = DEFAULT_VERY_STALE_SECONDS,
|
|
119
|
+
staleThresholdBlocks = DEFAULT_STALE_BLOCKS,
|
|
120
|
+
veryStaleThresholdBlocks = DEFAULT_VERY_STALE_BLOCKS,
|
|
121
|
+
onStale,
|
|
122
|
+
}: UseDataSourceProps) {
|
|
123
|
+
const [meta, setMeta] = useState<DataSourceMeta>(() => ({
|
|
124
|
+
source,
|
|
125
|
+
fetchedAt: Date.now(),
|
|
126
|
+
blockNumber,
|
|
127
|
+
currentBlock: blockNumber,
|
|
128
|
+
isStale: false,
|
|
129
|
+
stalenessLevel: 'fresh',
|
|
130
|
+
stalenessLabel: 'just now',
|
|
131
|
+
}))
|
|
132
|
+
|
|
133
|
+
const wasStaleRef = useRef(false)
|
|
134
|
+
const onStaleRef = useRef(onStale)
|
|
135
|
+
onStaleRef.current = onStale
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Mark data as refreshed
|
|
139
|
+
*/
|
|
140
|
+
const markRefreshed = useCallback(
|
|
141
|
+
(newBlockNumber?: number) => {
|
|
142
|
+
setMeta((prev) => ({
|
|
143
|
+
...prev,
|
|
144
|
+
fetchedAt: Date.now(),
|
|
145
|
+
blockNumber: newBlockNumber ?? prev.blockNumber,
|
|
146
|
+
currentBlock: newBlockNumber ?? prev.currentBlock,
|
|
147
|
+
isStale: false,
|
|
148
|
+
stalenessLevel: 'fresh',
|
|
149
|
+
stalenessLabel: 'just now',
|
|
150
|
+
}))
|
|
151
|
+
wasStaleRef.current = false
|
|
152
|
+
},
|
|
153
|
+
[]
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Update current block (for on-chain data)
|
|
158
|
+
*/
|
|
159
|
+
const updateCurrentBlock = useCallback((newCurrentBlock: number) => {
|
|
160
|
+
setMeta((prev) => ({
|
|
161
|
+
...prev,
|
|
162
|
+
currentBlock: newCurrentBlock,
|
|
163
|
+
}))
|
|
164
|
+
}, [])
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Recalculate staleness
|
|
168
|
+
*/
|
|
169
|
+
const recalculateStaleness = useCallback(() => {
|
|
170
|
+
setMeta((prev) => {
|
|
171
|
+
let staleness: { isStale: boolean; level: 'fresh' | 'stale' | 'very-stale'; label: string }
|
|
172
|
+
|
|
173
|
+
if (source === 'on-chain' && prev.blockNumber && prev.currentBlock) {
|
|
174
|
+
staleness = calculateBlockStaleness(
|
|
175
|
+
prev.blockNumber,
|
|
176
|
+
prev.currentBlock,
|
|
177
|
+
staleThresholdBlocks,
|
|
178
|
+
veryStaleThresholdBlocks
|
|
179
|
+
)
|
|
180
|
+
} else {
|
|
181
|
+
staleness = calculateTimeStaleness(
|
|
182
|
+
prev.fetchedAt,
|
|
183
|
+
staleThresholdSeconds,
|
|
184
|
+
veryStaleThresholdSeconds
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Trigger callback when becoming stale
|
|
189
|
+
if (staleness.isStale && !wasStaleRef.current) {
|
|
190
|
+
wasStaleRef.current = true
|
|
191
|
+
onStaleRef.current?.()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
...prev,
|
|
196
|
+
isStale: staleness.isStale,
|
|
197
|
+
stalenessLevel: staleness.level,
|
|
198
|
+
stalenessLabel: staleness.label,
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
}, [
|
|
202
|
+
source,
|
|
203
|
+
staleThresholdSeconds,
|
|
204
|
+
veryStaleThresholdSeconds,
|
|
205
|
+
staleThresholdBlocks,
|
|
206
|
+
veryStaleThresholdBlocks,
|
|
207
|
+
])
|
|
208
|
+
|
|
209
|
+
// Poll for staleness updates
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
const interval = setInterval(recalculateStaleness, pollInterval)
|
|
212
|
+
return () => clearInterval(interval)
|
|
213
|
+
}, [recalculateStaleness, pollInterval])
|
|
214
|
+
|
|
215
|
+
// Recalculate when source changes
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
setMeta((prev) => ({
|
|
218
|
+
...prev,
|
|
219
|
+
source,
|
|
220
|
+
blockNumber: blockNumber ?? prev.blockNumber,
|
|
221
|
+
}))
|
|
222
|
+
recalculateStaleness()
|
|
223
|
+
}, [source, blockNumber, recalculateStaleness])
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
meta,
|
|
227
|
+
markRefreshed,
|
|
228
|
+
updateCurrentBlock,
|
|
229
|
+
recalculateStaleness,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Props for multiple data sources
|
|
235
|
+
*/
|
|
236
|
+
export interface DataSourceEntry {
|
|
237
|
+
/** Unique key for this data source */
|
|
238
|
+
key: string
|
|
239
|
+
/** Data source type */
|
|
240
|
+
source: DataSourceType
|
|
241
|
+
/** Block number (for on-chain) */
|
|
242
|
+
blockNumber?: number
|
|
243
|
+
/** Label for display */
|
|
244
|
+
label?: string
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Hook to track multiple data sources
|
|
249
|
+
*/
|
|
250
|
+
export function useMultipleDataSources(entries: DataSourceEntry[]) {
|
|
251
|
+
const [sources, setSources] = useState<Map<string, DataSourceMeta>>(new Map())
|
|
252
|
+
|
|
253
|
+
// Initialize sources
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
const newSources = new Map<string, DataSourceMeta>()
|
|
256
|
+
const now = Date.now()
|
|
257
|
+
|
|
258
|
+
for (const entry of entries) {
|
|
259
|
+
newSources.set(entry.key, {
|
|
260
|
+
source: entry.source,
|
|
261
|
+
fetchedAt: now,
|
|
262
|
+
blockNumber: entry.blockNumber,
|
|
263
|
+
currentBlock: entry.blockNumber,
|
|
264
|
+
isStale: false,
|
|
265
|
+
stalenessLevel: 'fresh',
|
|
266
|
+
stalenessLabel: 'just now',
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
setSources(newSources)
|
|
271
|
+
}, [entries])
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Mark a specific source as refreshed
|
|
275
|
+
*/
|
|
276
|
+
const markRefreshed = useCallback((key: string, newBlockNumber?: number) => {
|
|
277
|
+
setSources((prev) => {
|
|
278
|
+
const newMap = new Map(prev)
|
|
279
|
+
const existing = newMap.get(key)
|
|
280
|
+
if (existing) {
|
|
281
|
+
newMap.set(key, {
|
|
282
|
+
...existing,
|
|
283
|
+
fetchedAt: Date.now(),
|
|
284
|
+
blockNumber: newBlockNumber ?? existing.blockNumber,
|
|
285
|
+
currentBlock: newBlockNumber ?? existing.currentBlock,
|
|
286
|
+
isStale: false,
|
|
287
|
+
stalenessLevel: 'fresh',
|
|
288
|
+
stalenessLabel: 'just now',
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
return newMap
|
|
292
|
+
})
|
|
293
|
+
}, [])
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Get metadata for a specific source
|
|
297
|
+
*/
|
|
298
|
+
const getMeta = useCallback(
|
|
299
|
+
(key: string): DataSourceMeta | undefined => {
|
|
300
|
+
return sources.get(key)
|
|
301
|
+
},
|
|
302
|
+
[sources]
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Get all stale sources
|
|
307
|
+
*/
|
|
308
|
+
const getStaleSources = useCallback((): string[] => {
|
|
309
|
+
const stale: string[] = []
|
|
310
|
+
sources.forEach((meta, key) => {
|
|
311
|
+
if (meta.isStale) {
|
|
312
|
+
stale.push(key)
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
return stale
|
|
316
|
+
}, [sources])
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
sources,
|
|
320
|
+
markRefreshed,
|
|
321
|
+
getMeta,
|
|
322
|
+
getStaleSources,
|
|
323
|
+
}
|
|
324
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard Shortcuts Hook
|
|
3
|
+
*
|
|
4
|
+
* Handle keyboard shortcuts for HUD navigation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useCallback } from 'react'
|
|
8
|
+
import { useHudStore } from '../store'
|
|
9
|
+
import type { HudPanelType } from '../types'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Default keyboard shortcuts
|
|
13
|
+
*/
|
|
14
|
+
const DEFAULT_SHORTCUTS: Record<string, () => void> = {}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Props for useKeyboardShortcuts
|
|
18
|
+
*/
|
|
19
|
+
export interface UseKeyboardShortcutsProps {
|
|
20
|
+
/** Whether shortcuts are enabled */
|
|
21
|
+
enabled?: boolean
|
|
22
|
+
/** Custom shortcuts to add */
|
|
23
|
+
customShortcuts?: Record<string, () => void>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hook to handle keyboard shortcuts for HUD
|
|
28
|
+
*/
|
|
29
|
+
export function useKeyboardShortcuts({
|
|
30
|
+
enabled = true,
|
|
31
|
+
customShortcuts = {},
|
|
32
|
+
}: UseKeyboardShortcutsProps = {}) {
|
|
33
|
+
const toggle = useHudStore((state) => state.toggle)
|
|
34
|
+
const setActivePanel = useHudStore((state) => state.setActivePanel)
|
|
35
|
+
const isOpen = useHudStore((state) => state.isOpen)
|
|
36
|
+
|
|
37
|
+
const handleKeyDown = useCallback(
|
|
38
|
+
(event: KeyboardEvent) => {
|
|
39
|
+
if (!enabled) return
|
|
40
|
+
|
|
41
|
+
// Ignore if typing in an input
|
|
42
|
+
const target = event.target as HTMLElement
|
|
43
|
+
if (
|
|
44
|
+
target.tagName === 'INPUT' ||
|
|
45
|
+
target.tagName === 'TEXTAREA' ||
|
|
46
|
+
target.isContentEditable
|
|
47
|
+
) {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Cmd/Ctrl + Shift + D: Toggle HUD
|
|
52
|
+
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key === 'd') {
|
|
53
|
+
event.preventDefault()
|
|
54
|
+
toggle()
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Only handle panel shortcuts when HUD is open
|
|
59
|
+
if (!isOpen) return
|
|
60
|
+
|
|
61
|
+
// Number keys for panels when HUD is open
|
|
62
|
+
const panelMap: Record<string, HudPanelType> = {
|
|
63
|
+
'1': 'lens',
|
|
64
|
+
'2': 'simulation',
|
|
65
|
+
'3': 'diagnostics',
|
|
66
|
+
'4': 'state',
|
|
67
|
+
'5': 'signals',
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (panelMap[event.key]) {
|
|
71
|
+
event.preventDefault()
|
|
72
|
+
setActivePanel(panelMap[event.key])
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Escape to close
|
|
77
|
+
if (event.key === 'Escape') {
|
|
78
|
+
event.preventDefault()
|
|
79
|
+
toggle()
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Custom shortcuts
|
|
84
|
+
const shortcutKey = getShortcutKey(event)
|
|
85
|
+
if (customShortcuts[shortcutKey]) {
|
|
86
|
+
event.preventDefault()
|
|
87
|
+
customShortcuts[shortcutKey]()
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
[enabled, toggle, setActivePanel, isOpen, customShortcuts]
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!enabled) return
|
|
95
|
+
|
|
96
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
97
|
+
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
98
|
+
}, [enabled, handleKeyDown])
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get shortcut key string from event
|
|
103
|
+
*/
|
|
104
|
+
function getShortcutKey(event: KeyboardEvent): string {
|
|
105
|
+
const parts: string[] = []
|
|
106
|
+
|
|
107
|
+
if (event.metaKey) parts.push('cmd')
|
|
108
|
+
if (event.ctrlKey) parts.push('ctrl')
|
|
109
|
+
if (event.altKey) parts.push('alt')
|
|
110
|
+
if (event.shiftKey) parts.push('shift')
|
|
111
|
+
parts.push(event.key.toLowerCase())
|
|
112
|
+
|
|
113
|
+
return parts.join('+')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get keyboard shortcut help text
|
|
118
|
+
*/
|
|
119
|
+
export function getShortcutHelp(): Array<{ keys: string; description: string }> {
|
|
120
|
+
return [
|
|
121
|
+
{ keys: '⌘⇧D', description: 'Toggle HUD' },
|
|
122
|
+
{ keys: '1-5', description: 'Switch panels (when open)' },
|
|
123
|
+
{ keys: 'Esc', description: 'Close HUD' },
|
|
124
|
+
]
|
|
125
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observation Capture Hook
|
|
3
|
+
*
|
|
4
|
+
* Capture user observations and insights from HUD.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useCallback, useRef } from 'react'
|
|
8
|
+
import type { Observation } from '../types'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Props for useObservationCapture
|
|
12
|
+
*/
|
|
13
|
+
export interface UseObservationCaptureProps {
|
|
14
|
+
/** Whether observation capture is enabled */
|
|
15
|
+
enabled?: boolean
|
|
16
|
+
/** Callback when observation is captured */
|
|
17
|
+
onObservation?: (observation: Observation) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generate a unique observation ID
|
|
22
|
+
*/
|
|
23
|
+
function generateObservationId(): string {
|
|
24
|
+
const timestamp = Date.now().toString(36)
|
|
25
|
+
const random = Math.random().toString(36).substring(2, 7)
|
|
26
|
+
return `obs-${timestamp}-${random}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Hook to capture observations from HUD
|
|
31
|
+
*/
|
|
32
|
+
export function useObservationCapture({
|
|
33
|
+
enabled = true,
|
|
34
|
+
onObservation,
|
|
35
|
+
}: UseObservationCaptureProps = {}) {
|
|
36
|
+
const observationsRef = useRef<Observation[]>([])
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Capture a new observation
|
|
40
|
+
*/
|
|
41
|
+
const capture = useCallback(
|
|
42
|
+
async (
|
|
43
|
+
content: string,
|
|
44
|
+
type: Observation['type'] = 'insight',
|
|
45
|
+
context?: Observation['context'],
|
|
46
|
+
tags: string[] = []
|
|
47
|
+
): Promise<Observation> => {
|
|
48
|
+
const observation: Observation = {
|
|
49
|
+
id: generateObservationId(),
|
|
50
|
+
timestamp: new Date().toISOString(),
|
|
51
|
+
type,
|
|
52
|
+
content,
|
|
53
|
+
tags,
|
|
54
|
+
context,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (enabled) {
|
|
58
|
+
// Store locally
|
|
59
|
+
observationsRef.current.push(observation)
|
|
60
|
+
|
|
61
|
+
// Notify callback
|
|
62
|
+
onObservation?.(observation)
|
|
63
|
+
|
|
64
|
+
// In a full implementation, this would write to grimoires/sigil/observations/
|
|
65
|
+
if (process.env.NODE_ENV === 'development') {
|
|
66
|
+
console.log('[Sigil HUD] Observation captured:', observation)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return observation
|
|
71
|
+
},
|
|
72
|
+
[enabled, onObservation]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Capture a user truth observation
|
|
77
|
+
*/
|
|
78
|
+
const captureUserTruth = useCallback(
|
|
79
|
+
(content: string, context?: Observation['context']) => {
|
|
80
|
+
return capture(content, 'user-truth', context, ['user-truth'])
|
|
81
|
+
},
|
|
82
|
+
[capture]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Capture an issue observation
|
|
87
|
+
*/
|
|
88
|
+
const captureIssue = useCallback(
|
|
89
|
+
(content: string, context?: Observation['context']) => {
|
|
90
|
+
return capture(content, 'issue', context, ['issue'])
|
|
91
|
+
},
|
|
92
|
+
[capture]
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Capture an insight observation
|
|
97
|
+
*/
|
|
98
|
+
const captureInsight = useCallback(
|
|
99
|
+
(content: string, context?: Observation['context']) => {
|
|
100
|
+
return capture(content, 'insight', context, ['insight'])
|
|
101
|
+
},
|
|
102
|
+
[capture]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Link an observation to signals
|
|
107
|
+
*/
|
|
108
|
+
const linkToSignals = useCallback(
|
|
109
|
+
(observationId: string, signalIds: string[]) => {
|
|
110
|
+
const observation = observationsRef.current.find(
|
|
111
|
+
(o) => o.id === observationId
|
|
112
|
+
)
|
|
113
|
+
if (observation) {
|
|
114
|
+
observation.linkedSignals = [
|
|
115
|
+
...(observation.linkedSignals ?? []),
|
|
116
|
+
...signalIds,
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
[]
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get all captured observations
|
|
125
|
+
*/
|
|
126
|
+
const getObservations = useCallback(() => {
|
|
127
|
+
return [...observationsRef.current]
|
|
128
|
+
}, [])
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get observations by type
|
|
132
|
+
*/
|
|
133
|
+
const getObservationsByType = useCallback((type: Observation['type']) => {
|
|
134
|
+
return observationsRef.current.filter((o) => o.type === type)
|
|
135
|
+
}, [])
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Clear captured observations
|
|
139
|
+
*/
|
|
140
|
+
const clearObservations = useCallback(() => {
|
|
141
|
+
observationsRef.current = []
|
|
142
|
+
}, [])
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
capture,
|
|
146
|
+
captureUserTruth,
|
|
147
|
+
captureIssue,
|
|
148
|
+
captureInsight,
|
|
149
|
+
linkToSignals,
|
|
150
|
+
getObservations,
|
|
151
|
+
getObservationsByType,
|
|
152
|
+
clearObservations,
|
|
153
|
+
}
|
|
154
|
+
}
|