@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.
@@ -0,0 +1,502 @@
1
+ /**
2
+ * Observation Capture Modal Component
3
+ *
4
+ * Modal for capturing user observations and insights.
5
+ * Triggered by Cmd+Shift+O keyboard shortcut.
6
+ * Implements TASK-203 requirements.
7
+ */
8
+
9
+ import { useState, useCallback, useEffect, useRef } from 'react'
10
+ import { useObservationCapture } from '../hooks/useObservationCapture'
11
+ import type { Observation } from '../types'
12
+
13
+ /**
14
+ * Observation type for display
15
+ */
16
+ type ObservationType = Observation['type']
17
+
18
+ /**
19
+ * Props for ObservationCaptureModal
20
+ */
21
+ export interface ObservationCaptureModalProps {
22
+ /** Whether the modal is visible */
23
+ isOpen: boolean
24
+ /** Callback to close the modal */
25
+ onClose: () => void
26
+ /** Callback when observation is captured */
27
+ onCapture?: (observation: Observation) => void
28
+ /** Current component context */
29
+ componentContext?: {
30
+ name?: string
31
+ effect?: string
32
+ lensAddress?: string
33
+ }
34
+ /** Custom class name */
35
+ className?: string
36
+ }
37
+
38
+ /**
39
+ * Type labels and colors
40
+ */
41
+ const typeConfig: Record<ObservationType, { label: string; color: string; bgColor: string; description: string }> = {
42
+ 'user-truth': {
43
+ label: 'User Truth',
44
+ color: '#22c55e',
45
+ bgColor: 'rgba(34, 197, 94, 0.1)',
46
+ description: 'What users actually do vs. what we assumed',
47
+ },
48
+ issue: {
49
+ label: 'Issue',
50
+ color: '#ef4444',
51
+ bgColor: 'rgba(239, 68, 68, 0.1)',
52
+ description: 'Problems, bugs, or friction points',
53
+ },
54
+ insight: {
55
+ label: 'Insight',
56
+ color: '#3b82f6',
57
+ bgColor: 'rgba(59, 130, 246, 0.1)',
58
+ description: 'Patterns, discoveries, or aha moments',
59
+ },
60
+ }
61
+
62
+ /**
63
+ * Suggested tags by type
64
+ */
65
+ const suggestedTags: Record<ObservationType, string[]> = {
66
+ 'user-truth': ['assumption-violated', 'behavior-change', 'feedback', 'preference'],
67
+ issue: ['ux', 'performance', 'physics-violation', 'accessibility', 'mobile'],
68
+ insight: ['pattern', 'optimization', 'physics', 'taste', 'workflow'],
69
+ }
70
+
71
+ /**
72
+ * Observation Capture Modal
73
+ */
74
+ export function ObservationCaptureModal({
75
+ isOpen,
76
+ onClose,
77
+ onCapture,
78
+ componentContext,
79
+ className = '',
80
+ }: ObservationCaptureModalProps) {
81
+ const [type, setType] = useState<ObservationType>('insight')
82
+ const [content, setContent] = useState('')
83
+ const [tags, setTags] = useState<string[]>([])
84
+ const [customTag, setCustomTag] = useState('')
85
+ const contentRef = useRef<HTMLTextAreaElement>(null)
86
+
87
+ const { capture } = useObservationCapture({
88
+ enabled: true,
89
+ onObservation: onCapture,
90
+ })
91
+
92
+ // Focus textarea when modal opens
93
+ useEffect(() => {
94
+ if (isOpen && contentRef.current) {
95
+ contentRef.current.focus()
96
+ }
97
+ }, [isOpen])
98
+
99
+ // Reset form when modal closes
100
+ useEffect(() => {
101
+ if (!isOpen) {
102
+ setType('insight')
103
+ setContent('')
104
+ setTags([])
105
+ setCustomTag('')
106
+ }
107
+ }, [isOpen])
108
+
109
+ // Handle escape key
110
+ useEffect(() => {
111
+ if (!isOpen) return
112
+
113
+ const handleKeyDown = (e: KeyboardEvent) => {
114
+ if (e.key === 'Escape') {
115
+ onClose()
116
+ }
117
+ if (e.key === 'Enter' && e.metaKey) {
118
+ handleSubmit()
119
+ }
120
+ }
121
+
122
+ window.addEventListener('keydown', handleKeyDown)
123
+ return () => window.removeEventListener('keydown', handleKeyDown)
124
+ }, [isOpen, onClose, content])
125
+
126
+ // Toggle a tag
127
+ const toggleTag = useCallback((tag: string) => {
128
+ setTags((prev) =>
129
+ prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
130
+ )
131
+ }, [])
132
+
133
+ // Add custom tag
134
+ const addCustomTag = useCallback(() => {
135
+ if (customTag && !tags.includes(customTag)) {
136
+ setTags((prev) => [...prev, customTag])
137
+ setCustomTag('')
138
+ }
139
+ }, [customTag, tags])
140
+
141
+ // Handle submit
142
+ const handleSubmit = useCallback(async () => {
143
+ if (!content.trim()) return
144
+
145
+ await capture(
146
+ content.trim(),
147
+ type,
148
+ componentContext,
149
+ tags
150
+ )
151
+
152
+ onClose()
153
+ }, [content, type, componentContext, tags, capture, onClose])
154
+
155
+ if (!isOpen) return null
156
+
157
+ const config = typeConfig[type]
158
+ const suggested = suggestedTags[type]
159
+
160
+ return (
161
+ <div className={className} style={styles.overlay}>
162
+ <div style={styles.modal}>
163
+ {/* Header */}
164
+ <div style={styles.header}>
165
+ <h2 style={styles.title}>Capture Observation</h2>
166
+ <button onClick={onClose} style={styles.closeButton}>
167
+ ×
168
+ </button>
169
+ </div>
170
+
171
+ {/* Type Selection */}
172
+ <div style={styles.section}>
173
+ <label style={styles.label}>Type</label>
174
+ <div style={styles.typeButtons}>
175
+ {(Object.keys(typeConfig) as ObservationType[]).map((t) => {
176
+ const c = typeConfig[t]
177
+ const isSelected = type === t
178
+ return (
179
+ <button
180
+ key={t}
181
+ onClick={() => setType(t)}
182
+ style={{
183
+ ...styles.typeButton,
184
+ backgroundColor: isSelected ? c.bgColor : 'rgba(255, 255, 255, 0.02)',
185
+ borderColor: isSelected ? c.color : 'rgba(255, 255, 255, 0.1)',
186
+ color: isSelected ? c.color : '#888',
187
+ }}
188
+ >
189
+ {c.label}
190
+ </button>
191
+ )
192
+ })}
193
+ </div>
194
+ <p style={styles.typeDescription}>{config.description}</p>
195
+ </div>
196
+
197
+ {/* Content */}
198
+ <div style={styles.section}>
199
+ <label style={styles.label}>What did you observe?</label>
200
+ <textarea
201
+ ref={contentRef}
202
+ value={content}
203
+ onChange={(e) => setContent(e.target.value)}
204
+ placeholder={
205
+ type === 'user-truth'
206
+ ? 'e.g., "Users expected the claim button to show pending state, but it shows stale balance..."'
207
+ : type === 'issue'
208
+ ? 'e.g., "Dialog animation causes layout shift on mobile Safari..."'
209
+ : 'e.g., "Power users prefer 500ms timing over 800ms for financial operations..."'
210
+ }
211
+ rows={4}
212
+ style={styles.textarea}
213
+ />
214
+ </div>
215
+
216
+ {/* Tags */}
217
+ <div style={styles.section}>
218
+ <label style={styles.label}>Tags</label>
219
+ <div style={styles.tagContainer}>
220
+ {/* Suggested tags */}
221
+ {suggested.map((tag) => (
222
+ <button
223
+ key={tag}
224
+ onClick={() => toggleTag(tag)}
225
+ style={{
226
+ ...styles.tag,
227
+ backgroundColor: tags.includes(tag)
228
+ ? config.bgColor
229
+ : 'rgba(255, 255, 255, 0.02)',
230
+ borderColor: tags.includes(tag)
231
+ ? config.color
232
+ : 'rgba(255, 255, 255, 0.1)',
233
+ color: tags.includes(tag) ? config.color : '#888',
234
+ }}
235
+ >
236
+ {tag}
237
+ </button>
238
+ ))}
239
+ {/* Custom tags */}
240
+ {tags
241
+ .filter((t) => !suggested.includes(t))
242
+ .map((tag) => (
243
+ <button
244
+ key={tag}
245
+ onClick={() => toggleTag(tag)}
246
+ style={{
247
+ ...styles.tag,
248
+ backgroundColor: config.bgColor,
249
+ borderColor: config.color,
250
+ color: config.color,
251
+ }}
252
+ >
253
+ {tag} ×
254
+ </button>
255
+ ))}
256
+ </div>
257
+ {/* Custom tag input */}
258
+ <div style={styles.customTagRow}>
259
+ <input
260
+ type="text"
261
+ value={customTag}
262
+ onChange={(e) => setCustomTag(e.target.value)}
263
+ onKeyDown={(e) => e.key === 'Enter' && addCustomTag()}
264
+ placeholder="Add custom tag..."
265
+ style={styles.customTagInput}
266
+ />
267
+ <button
268
+ onClick={addCustomTag}
269
+ disabled={!customTag}
270
+ style={{
271
+ ...styles.addTagButton,
272
+ opacity: customTag ? 1 : 0.5,
273
+ }}
274
+ >
275
+ +
276
+ </button>
277
+ </div>
278
+ </div>
279
+
280
+ {/* Context (if available) */}
281
+ {componentContext && (
282
+ <div style={styles.section}>
283
+ <label style={styles.label}>Context</label>
284
+ <div style={styles.contextRow}>
285
+ {componentContext.name && (
286
+ <span style={styles.contextBadge}>
287
+ Component: {componentContext.name}
288
+ </span>
289
+ )}
290
+ {componentContext.effect && (
291
+ <span style={styles.contextBadge}>
292
+ Effect: {componentContext.effect}
293
+ </span>
294
+ )}
295
+ {componentContext.lensAddress && (
296
+ <span style={styles.contextBadge}>
297
+ Lens: {componentContext.lensAddress.slice(0, 6)}...
298
+ </span>
299
+ )}
300
+ </div>
301
+ </div>
302
+ )}
303
+
304
+ {/* Actions */}
305
+ <div style={styles.actions}>
306
+ <button onClick={onClose} style={styles.cancelButton}>
307
+ Cancel
308
+ </button>
309
+ <button
310
+ onClick={handleSubmit}
311
+ disabled={!content.trim()}
312
+ style={{
313
+ ...styles.submitButton,
314
+ backgroundColor: content.trim() ? config.bgColor : 'rgba(255, 255, 255, 0.02)',
315
+ borderColor: content.trim() ? config.color : 'rgba(255, 255, 255, 0.1)',
316
+ color: content.trim() ? config.color : '#666',
317
+ cursor: content.trim() ? 'pointer' : 'not-allowed',
318
+ }}
319
+ >
320
+ Capture (⌘+Enter)
321
+ </button>
322
+ </div>
323
+ </div>
324
+ </div>
325
+ )
326
+ }
327
+
328
+ /**
329
+ * Styles
330
+ */
331
+ const styles: Record<string, React.CSSProperties> = {
332
+ overlay: {
333
+ position: 'fixed',
334
+ top: 0,
335
+ left: 0,
336
+ right: 0,
337
+ bottom: 0,
338
+ backgroundColor: 'rgba(0, 0, 0, 0.6)',
339
+ backdropFilter: 'blur(4px)',
340
+ display: 'flex',
341
+ alignItems: 'center',
342
+ justifyContent: 'center',
343
+ zIndex: 10000,
344
+ },
345
+ modal: {
346
+ width: '100%',
347
+ maxWidth: '480px',
348
+ backgroundColor: '#1a1a1a',
349
+ borderRadius: '12px',
350
+ border: '1px solid rgba(255, 255, 255, 0.1)',
351
+ boxShadow: '0 20px 40px rgba(0, 0, 0, 0.4)',
352
+ overflow: 'hidden',
353
+ },
354
+ header: {
355
+ display: 'flex',
356
+ justifyContent: 'space-between',
357
+ alignItems: 'center',
358
+ padding: '16px 20px',
359
+ borderBottom: '1px solid rgba(255, 255, 255, 0.05)',
360
+ },
361
+ title: {
362
+ margin: 0,
363
+ fontSize: '14px',
364
+ fontWeight: 600,
365
+ color: '#fff',
366
+ },
367
+ closeButton: {
368
+ width: '28px',
369
+ height: '28px',
370
+ display: 'flex',
371
+ alignItems: 'center',
372
+ justifyContent: 'center',
373
+ backgroundColor: 'transparent',
374
+ border: 'none',
375
+ borderRadius: '4px',
376
+ color: '#888',
377
+ fontSize: '20px',
378
+ cursor: 'pointer',
379
+ },
380
+ section: {
381
+ padding: '16px 20px',
382
+ },
383
+ label: {
384
+ display: 'block',
385
+ fontSize: '11px',
386
+ fontWeight: 600,
387
+ color: '#888',
388
+ textTransform: 'uppercase',
389
+ letterSpacing: '0.5px',
390
+ marginBottom: '8px',
391
+ },
392
+ typeButtons: {
393
+ display: 'flex',
394
+ gap: '8px',
395
+ },
396
+ typeButton: {
397
+ flex: 1,
398
+ padding: '8px 12px',
399
+ borderRadius: '6px',
400
+ border: '1px solid',
401
+ fontSize: '12px',
402
+ fontWeight: 500,
403
+ cursor: 'pointer',
404
+ transition: 'all 0.15s ease-out',
405
+ },
406
+ typeDescription: {
407
+ margin: '8px 0 0 0',
408
+ fontSize: '11px',
409
+ color: '#666',
410
+ fontStyle: 'italic',
411
+ },
412
+ textarea: {
413
+ width: '100%',
414
+ padding: '12px',
415
+ backgroundColor: 'rgba(255, 255, 255, 0.02)',
416
+ border: '1px solid rgba(255, 255, 255, 0.1)',
417
+ borderRadius: '6px',
418
+ color: '#fff',
419
+ fontSize: '13px',
420
+ fontFamily: 'inherit',
421
+ resize: 'vertical',
422
+ lineHeight: 1.5,
423
+ },
424
+ tagContainer: {
425
+ display: 'flex',
426
+ flexWrap: 'wrap',
427
+ gap: '6px',
428
+ },
429
+ tag: {
430
+ padding: '4px 8px',
431
+ borderRadius: '4px',
432
+ border: '1px solid',
433
+ fontSize: '11px',
434
+ cursor: 'pointer',
435
+ transition: 'all 0.15s ease-out',
436
+ },
437
+ customTagRow: {
438
+ display: 'flex',
439
+ gap: '8px',
440
+ marginTop: '8px',
441
+ },
442
+ customTagInput: {
443
+ flex: 1,
444
+ padding: '6px 10px',
445
+ backgroundColor: 'rgba(255, 255, 255, 0.02)',
446
+ border: '1px solid rgba(255, 255, 255, 0.1)',
447
+ borderRadius: '4px',
448
+ color: '#fff',
449
+ fontSize: '11px',
450
+ fontFamily: 'inherit',
451
+ },
452
+ addTagButton: {
453
+ width: '28px',
454
+ height: '28px',
455
+ display: 'flex',
456
+ alignItems: 'center',
457
+ justifyContent: 'center',
458
+ backgroundColor: 'rgba(255, 255, 255, 0.02)',
459
+ border: '1px solid rgba(255, 255, 255, 0.1)',
460
+ borderRadius: '4px',
461
+ color: '#888',
462
+ fontSize: '16px',
463
+ cursor: 'pointer',
464
+ },
465
+ contextRow: {
466
+ display: 'flex',
467
+ flexWrap: 'wrap',
468
+ gap: '6px',
469
+ },
470
+ contextBadge: {
471
+ padding: '4px 8px',
472
+ backgroundColor: 'rgba(255, 255, 255, 0.02)',
473
+ border: '1px solid rgba(255, 255, 255, 0.05)',
474
+ borderRadius: '4px',
475
+ fontSize: '10px',
476
+ color: '#888',
477
+ },
478
+ actions: {
479
+ display: 'flex',
480
+ justifyContent: 'flex-end',
481
+ gap: '8px',
482
+ padding: '16px 20px',
483
+ borderTop: '1px solid rgba(255, 255, 255, 0.05)',
484
+ },
485
+ cancelButton: {
486
+ padding: '8px 16px',
487
+ backgroundColor: 'rgba(255, 255, 255, 0.02)',
488
+ border: '1px solid rgba(255, 255, 255, 0.1)',
489
+ borderRadius: '6px',
490
+ color: '#888',
491
+ fontSize: '12px',
492
+ cursor: 'pointer',
493
+ },
494
+ submitButton: {
495
+ padding: '8px 16px',
496
+ borderRadius: '6px',
497
+ border: '1px solid',
498
+ fontSize: '12px',
499
+ fontWeight: 500,
500
+ transition: 'all 0.15s ease-out',
501
+ },
502
+ }