@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,348 @@
1
+ /**
2
+ * Feedback Prompt Component
3
+ *
4
+ * Prompts users for feedback after diagnostics or crafting.
5
+ * Emits MODIFY signals and links to observations.
6
+ * Implements TASK-204 requirements.
7
+ */
8
+
9
+ import { useState, useCallback } from 'react'
10
+ import { useSignalCapture } from '../hooks/useSignalCapture'
11
+ import { useObservationCapture } from '../hooks/useObservationCapture'
12
+ import type { Signal, Observation } from '../types'
13
+
14
+ /**
15
+ * Feedback option
16
+ */
17
+ interface FeedbackOption {
18
+ id: string
19
+ label: string
20
+ description: string
21
+ signal: 'ACCEPT' | 'MODIFY' | 'REJECT'
22
+ }
23
+
24
+ /**
25
+ * Default feedback options
26
+ */
27
+ const defaultOptions: FeedbackOption[] = [
28
+ {
29
+ id: 'looks-good',
30
+ label: 'Looks good',
31
+ description: 'The physics feel right for this use case',
32
+ signal: 'ACCEPT',
33
+ },
34
+ {
35
+ id: 'timing-off',
36
+ label: 'Timing feels off',
37
+ description: 'The duration should be faster or slower',
38
+ signal: 'MODIFY',
39
+ },
40
+ {
41
+ id: 'animation-wrong',
42
+ label: 'Animation wrong',
43
+ description: 'The easing or motion feels incorrect',
44
+ signal: 'MODIFY',
45
+ },
46
+ {
47
+ id: 'needs-confirmation',
48
+ label: 'Needs confirmation',
49
+ description: 'Should have (or shouldn\'t have) a confirmation step',
50
+ signal: 'MODIFY',
51
+ },
52
+ {
53
+ id: 'other',
54
+ label: 'Something else',
55
+ description: 'Describe what feels wrong',
56
+ signal: 'MODIFY',
57
+ },
58
+ ]
59
+
60
+ /**
61
+ * Props for FeedbackPrompt
62
+ */
63
+ export interface FeedbackPromptProps {
64
+ /** Component name being reviewed */
65
+ componentName: string
66
+ /** Detected effect type */
67
+ effect: string
68
+ /** Current physics values */
69
+ physics?: {
70
+ behavioral?: { sync: string; timing: string; confirmation: string }
71
+ animation?: { easing: string; duration: string }
72
+ material?: { surface: string; shadow: string; radius: string }
73
+ }
74
+ /** Custom feedback options */
75
+ options?: FeedbackOption[]
76
+ /** Callback when feedback is submitted */
77
+ onFeedback?: (signal: Signal, observation?: Observation) => void
78
+ /** Callback to close the prompt */
79
+ onClose?: () => void
80
+ /** Whether to show the prompt */
81
+ visible?: boolean
82
+ /** Custom class name */
83
+ className?: string
84
+ }
85
+
86
+ /**
87
+ * Feedback Prompt Component
88
+ */
89
+ export function FeedbackPrompt({
90
+ componentName,
91
+ effect,
92
+ physics,
93
+ options = defaultOptions,
94
+ onFeedback,
95
+ onClose,
96
+ visible = true,
97
+ className = '',
98
+ }: FeedbackPromptProps) {
99
+ const [selectedOption, setSelectedOption] = useState<FeedbackOption | null>(null)
100
+ const [customFeedback, setCustomFeedback] = useState('')
101
+ const [showDetailInput, setShowDetailInput] = useState(false)
102
+
103
+ const { accept, modify, reject } = useSignalCapture({ enabled: true })
104
+ const { captureInsight, captureIssue } = useObservationCapture({ enabled: true })
105
+
106
+ // Handle option selection
107
+ const handleSelectOption = useCallback((option: FeedbackOption) => {
108
+ setSelectedOption(option)
109
+
110
+ if (option.signal === 'ACCEPT') {
111
+ // Immediate accept - no detail needed
112
+ handleSubmit(option, '')
113
+ } else if (option.id === 'other') {
114
+ // Show detail input for custom feedback
115
+ setShowDetailInput(true)
116
+ } else {
117
+ // Show detail input for MODIFY signals
118
+ setShowDetailInput(true)
119
+ }
120
+ }, [])
121
+
122
+ // Handle submit
123
+ const handleSubmit = useCallback(
124
+ async (option: FeedbackOption, detail: string) => {
125
+ let observation: Observation | undefined
126
+
127
+ if (option.signal === 'ACCEPT') {
128
+ await accept(componentName, effect, 'diagnose')
129
+ } else if (option.signal === 'MODIFY') {
130
+ // Capture an observation for the modification
131
+ const content = detail || option.description
132
+ observation = await captureInsight(content, {
133
+ component: componentName,
134
+ effect,
135
+ })
136
+
137
+ // Emit MODIFY signal
138
+ await modify(
139
+ componentName,
140
+ effect,
141
+ { from: 'current', to: content },
142
+ { inference: `User feedback: ${option.label}` }
143
+ )
144
+ } else if (option.signal === 'REJECT') {
145
+ // Capture an issue observation
146
+ observation = await captureIssue(detail || option.description, {
147
+ component: componentName,
148
+ effect,
149
+ })
150
+
151
+ await reject(componentName, effect, detail || option.description)
152
+ }
153
+
154
+ // Notify callback
155
+ const signal: Signal = {
156
+ timestamp: new Date().toISOString(),
157
+ signal: option.signal,
158
+ source: 'hud',
159
+ component: {
160
+ name: componentName,
161
+ effect,
162
+ craft_type: 'diagnose',
163
+ },
164
+ physics,
165
+ hud_context: {
166
+ panel_visible: true,
167
+ diagnostics_shown: true,
168
+ observation_linked: observation?.id,
169
+ },
170
+ }
171
+
172
+ onFeedback?.(signal, observation)
173
+
174
+ // Reset and close
175
+ setSelectedOption(null)
176
+ setCustomFeedback('')
177
+ setShowDetailInput(false)
178
+ onClose?.()
179
+ },
180
+ [componentName, effect, physics, accept, modify, reject, captureInsight, captureIssue, onFeedback, onClose]
181
+ )
182
+
183
+ // Handle cancel
184
+ const handleCancel = useCallback(() => {
185
+ setSelectedOption(null)
186
+ setCustomFeedback('')
187
+ setShowDetailInput(false)
188
+ onClose?.()
189
+ }, [onClose])
190
+
191
+ if (!visible) return null
192
+
193
+ return (
194
+ <div className={className} style={styles.container}>
195
+ {/* Prompt */}
196
+ <div style={styles.promptRow}>
197
+ <span style={styles.promptText}>Does this feel right for your user?</span>
198
+ </div>
199
+
200
+ {/* Options */}
201
+ {!showDetailInput && (
202
+ <div style={styles.optionsGrid}>
203
+ {options.map((option) => (
204
+ <button
205
+ key={option.id}
206
+ onClick={() => handleSelectOption(option)}
207
+ style={{
208
+ ...styles.optionButton,
209
+ borderColor:
210
+ option.signal === 'ACCEPT'
211
+ ? 'rgba(34, 197, 94, 0.3)'
212
+ : option.signal === 'REJECT'
213
+ ? 'rgba(239, 68, 68, 0.3)'
214
+ : 'rgba(255, 255, 255, 0.1)',
215
+ }}
216
+ >
217
+ <span style={styles.optionLabel}>{option.label}</span>
218
+ </button>
219
+ ))}
220
+ </div>
221
+ )}
222
+
223
+ {/* Detail Input */}
224
+ {showDetailInput && selectedOption && (
225
+ <div style={styles.detailSection}>
226
+ <p style={styles.detailPrompt}>
227
+ {selectedOption.id === 'other'
228
+ ? 'What feels wrong?'
229
+ : `Describe what would feel better:`}
230
+ </p>
231
+ <textarea
232
+ value={customFeedback}
233
+ onChange={(e) => setCustomFeedback(e.target.value)}
234
+ placeholder={
235
+ selectedOption.id === 'timing-off'
236
+ ? 'e.g., "Should be faster, around 500ms..."'
237
+ : selectedOption.id === 'animation-wrong'
238
+ ? 'e.g., "Should use spring instead of ease-out..."'
239
+ : 'Describe the issue or desired feel...'
240
+ }
241
+ rows={2}
242
+ style={styles.detailTextarea}
243
+ autoFocus
244
+ />
245
+ <div style={styles.detailActions}>
246
+ <button onClick={handleCancel} style={styles.cancelButton}>
247
+ Cancel
248
+ </button>
249
+ <button
250
+ onClick={() => handleSubmit(selectedOption, customFeedback)}
251
+ disabled={selectedOption.id === 'other' && !customFeedback.trim()}
252
+ style={{
253
+ ...styles.submitButton,
254
+ opacity: selectedOption.id === 'other' && !customFeedback.trim() ? 0.5 : 1,
255
+ }}
256
+ >
257
+ Submit Feedback
258
+ </button>
259
+ </div>
260
+ </div>
261
+ )}
262
+ </div>
263
+ )
264
+ }
265
+
266
+ /**
267
+ * Styles
268
+ */
269
+ const styles: Record<string, React.CSSProperties> = {
270
+ container: {
271
+ padding: '12px',
272
+ backgroundColor: 'rgba(0, 0, 0, 0.2)',
273
+ borderRadius: '8px',
274
+ border: '1px solid rgba(255, 255, 255, 0.05)',
275
+ },
276
+ promptRow: {
277
+ marginBottom: '12px',
278
+ },
279
+ promptText: {
280
+ fontSize: '11px',
281
+ fontWeight: 600,
282
+ color: '#888',
283
+ textTransform: 'uppercase',
284
+ letterSpacing: '0.5px',
285
+ },
286
+ optionsGrid: {
287
+ display: 'grid',
288
+ gridTemplateColumns: 'repeat(2, 1fr)',
289
+ gap: '6px',
290
+ },
291
+ optionButton: {
292
+ padding: '8px 10px',
293
+ backgroundColor: 'rgba(255, 255, 255, 0.02)',
294
+ border: '1px solid',
295
+ borderRadius: '4px',
296
+ textAlign: 'left',
297
+ cursor: 'pointer',
298
+ transition: 'all 0.15s ease-out',
299
+ },
300
+ optionLabel: {
301
+ fontSize: '11px',
302
+ color: '#fff',
303
+ },
304
+ detailSection: {
305
+ display: 'flex',
306
+ flexDirection: 'column',
307
+ gap: '8px',
308
+ },
309
+ detailPrompt: {
310
+ margin: 0,
311
+ fontSize: '12px',
312
+ color: '#888',
313
+ },
314
+ detailTextarea: {
315
+ width: '100%',
316
+ padding: '8px',
317
+ backgroundColor: 'rgba(255, 255, 255, 0.05)',
318
+ border: '1px solid rgba(255, 255, 255, 0.1)',
319
+ borderRadius: '4px',
320
+ color: '#fff',
321
+ fontSize: '11px',
322
+ fontFamily: 'inherit',
323
+ resize: 'vertical',
324
+ },
325
+ detailActions: {
326
+ display: 'flex',
327
+ justifyContent: 'flex-end',
328
+ gap: '8px',
329
+ },
330
+ cancelButton: {
331
+ padding: '6px 12px',
332
+ backgroundColor: 'rgba(255, 255, 255, 0.02)',
333
+ border: '1px solid rgba(255, 255, 255, 0.1)',
334
+ borderRadius: '4px',
335
+ color: '#888',
336
+ fontSize: '11px',
337
+ cursor: 'pointer',
338
+ },
339
+ submitButton: {
340
+ padding: '6px 12px',
341
+ backgroundColor: 'rgba(59, 130, 246, 0.2)',
342
+ border: '1px solid rgba(59, 130, 246, 0.3)',
343
+ borderRadius: '4px',
344
+ color: '#3b82f6',
345
+ fontSize: '11px',
346
+ cursor: 'pointer',
347
+ },
348
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * HUD Panel Component
3
+ *
4
+ * Main panel container for the HUD.
5
+ */
6
+
7
+ import type { ReactNode } from 'react'
8
+ import { useHud } from '../providers/HudProvider'
9
+ import type { HudPanelType } from '../types'
10
+
11
+ /**
12
+ * Props for HudPanel
13
+ */
14
+ export interface HudPanelProps {
15
+ /** Panel content */
16
+ children?: ReactNode
17
+ /** Custom class name */
18
+ className?: string
19
+ }
20
+
21
+ /**
22
+ * Panel tab configuration
23
+ */
24
+ interface PanelTab {
25
+ id: HudPanelType
26
+ label: string
27
+ available: boolean
28
+ }
29
+
30
+ /**
31
+ * Main HUD panel component
32
+ */
33
+ export function HudPanel({ children, className = '' }: HudPanelProps) {
34
+ const {
35
+ isOpen,
36
+ isMinimized,
37
+ activePanel,
38
+ position,
39
+ setActivePanel,
40
+ toggleMinimized,
41
+ close,
42
+ lensService,
43
+ forkService,
44
+ simulationService,
45
+ diagnosticsService,
46
+ } = useHud()
47
+
48
+ if (!isOpen) return null
49
+
50
+ // Determine which tabs are available based on services
51
+ const tabs: PanelTab[] = [
52
+ { id: 'lens', label: 'Lens', available: lensService !== null },
53
+ { id: 'simulation', label: 'Simulation', available: simulationService !== null },
54
+ { id: 'diagnostics', label: 'Diagnostics', available: diagnosticsService !== null },
55
+ { id: 'state', label: 'State', available: forkService !== null },
56
+ { id: 'signals', label: 'Signals', available: true },
57
+ ]
58
+
59
+ const availableTabs = tabs.filter((t) => t.available)
60
+
61
+ // Position classes
62
+ const positionClasses: Record<typeof position, string> = {
63
+ 'bottom-right': 'bottom-4 right-4',
64
+ 'bottom-left': 'bottom-4 left-4',
65
+ 'top-right': 'top-4 right-4',
66
+ 'top-left': 'top-4 left-4',
67
+ }
68
+
69
+ return (
70
+ <div
71
+ className={`fixed ${positionClasses[position]} z-50 ${className}`}
72
+ style={{
73
+ width: isMinimized ? '200px' : '400px',
74
+ maxHeight: isMinimized ? '40px' : '600px',
75
+ backgroundColor: 'rgba(0, 0, 0, 0.9)',
76
+ borderRadius: '8px',
77
+ border: '1px solid rgba(255, 255, 255, 0.1)',
78
+ boxShadow: '0 4px 20px rgba(0, 0, 0, 0.5)',
79
+ fontFamily: 'ui-monospace, monospace',
80
+ fontSize: '12px',
81
+ color: '#fff',
82
+ overflow: 'hidden',
83
+ }}
84
+ >
85
+ {/* Header */}
86
+ <div
87
+ style={{
88
+ display: 'flex',
89
+ alignItems: 'center',
90
+ justifyContent: 'space-between',
91
+ padding: '8px 12px',
92
+ borderBottom: isMinimized ? 'none' : '1px solid rgba(255, 255, 255, 0.1)',
93
+ backgroundColor: 'rgba(255, 255, 255, 0.05)',
94
+ }}
95
+ >
96
+ <span style={{ fontWeight: 600, color: '#10b981' }}>◆ Sigil HUD</span>
97
+ <div style={{ display: 'flex', gap: '8px' }}>
98
+ <button
99
+ onClick={toggleMinimized}
100
+ style={{
101
+ background: 'none',
102
+ border: 'none',
103
+ color: '#666',
104
+ cursor: 'pointer',
105
+ fontSize: '14px',
106
+ }}
107
+ >
108
+ {isMinimized ? '◻' : '–'}
109
+ </button>
110
+ <button
111
+ onClick={close}
112
+ style={{
113
+ background: 'none',
114
+ border: 'none',
115
+ color: '#666',
116
+ cursor: 'pointer',
117
+ fontSize: '14px',
118
+ }}
119
+ >
120
+ ×
121
+ </button>
122
+ </div>
123
+ </div>
124
+
125
+ {!isMinimized && (
126
+ <>
127
+ {/* Tabs */}
128
+ <div
129
+ style={{
130
+ display: 'flex',
131
+ gap: '2px',
132
+ padding: '4px',
133
+ backgroundColor: 'rgba(255, 255, 255, 0.02)',
134
+ }}
135
+ >
136
+ {availableTabs.map((tab) => (
137
+ <button
138
+ key={tab.id}
139
+ onClick={() => setActivePanel(tab.id)}
140
+ style={{
141
+ flex: 1,
142
+ padding: '6px 8px',
143
+ background:
144
+ activePanel === tab.id
145
+ ? 'rgba(16, 185, 129, 0.2)'
146
+ : 'transparent',
147
+ border: 'none',
148
+ borderRadius: '4px',
149
+ color: activePanel === tab.id ? '#10b981' : '#888',
150
+ cursor: 'pointer',
151
+ fontSize: '11px',
152
+ fontWeight: 500,
153
+ }}
154
+ >
155
+ {tab.label}
156
+ </button>
157
+ ))}
158
+ </div>
159
+
160
+ {/* Content */}
161
+ <div
162
+ style={{
163
+ padding: '12px',
164
+ overflowY: 'auto',
165
+ maxHeight: '500px',
166
+ }}
167
+ >
168
+ {children}
169
+ {!activePanel && (
170
+ <div style={{ color: '#666', textAlign: 'center', padding: '20px' }}>
171
+ Select a panel to get started
172
+ </div>
173
+ )}
174
+ </div>
175
+ </>
176
+ )}
177
+ </div>
178
+ )
179
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * HUD Trigger Component
3
+ *
4
+ * Floating button to open the HUD.
5
+ */
6
+
7
+ import type { ReactNode } from 'react'
8
+ import { useHud } from '../providers/HudProvider'
9
+ import type { HudPosition } from '../types'
10
+
11
+ /**
12
+ * Props for HudTrigger
13
+ */
14
+ export interface HudTriggerProps {
15
+ /** Custom class name */
16
+ className?: string
17
+ /** Custom children (replaces default icon) */
18
+ children?: ReactNode
19
+ /** Override position (defaults to HUD position) */
20
+ position?: HudPosition
21
+ }
22
+
23
+ /**
24
+ * Floating trigger button to open the HUD
25
+ */
26
+ export function HudTrigger({
27
+ className = '',
28
+ children,
29
+ position: overridePosition,
30
+ }: HudTriggerProps) {
31
+ const { isOpen, toggle, position: hudPosition } = useHud()
32
+
33
+ const position = overridePosition ?? hudPosition
34
+
35
+ // Position classes
36
+ const positionClasses: Record<typeof position, string> = {
37
+ 'bottom-right': 'bottom-4 right-4',
38
+ 'bottom-left': 'bottom-4 left-4',
39
+ 'top-right': 'top-4 right-4',
40
+ 'top-left': 'top-4 left-4',
41
+ }
42
+
43
+ // Don't show trigger when HUD is open
44
+ if (isOpen) return null
45
+
46
+ return (
47
+ <button
48
+ onClick={toggle}
49
+ className={`fixed ${positionClasses[position]} z-50 ${className}`}
50
+ style={{
51
+ width: '44px',
52
+ height: '44px',
53
+ borderRadius: '50%',
54
+ backgroundColor: 'rgba(16, 185, 129, 0.9)',
55
+ border: '2px solid rgba(16, 185, 129, 0.3)',
56
+ boxShadow: '0 4px 12px rgba(16, 185, 129, 0.3)',
57
+ cursor: 'pointer',
58
+ display: 'flex',
59
+ alignItems: 'center',
60
+ justifyContent: 'center',
61
+ transition: 'transform 0.2s, box-shadow 0.2s',
62
+ }}
63
+ onMouseEnter={(e) => {
64
+ e.currentTarget.style.transform = 'scale(1.1)'
65
+ e.currentTarget.style.boxShadow = '0 6px 16px rgba(16, 185, 129, 0.4)'
66
+ }}
67
+ onMouseLeave={(e) => {
68
+ e.currentTarget.style.transform = 'scale(1)'
69
+ e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.3)'
70
+ }}
71
+ aria-label="Open Sigil HUD"
72
+ title="Sigil HUD (⌘⇧D)"
73
+ >
74
+ {children ?? (
75
+ <span style={{ color: '#fff', fontSize: '18px', fontWeight: 600 }}>
76
+
77
+ </span>
78
+ )}
79
+ </button>
80
+ )
81
+ }