@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,228 @@
1
+ /**
2
+ * Issue List Component
3
+ *
4
+ * Displays detected diagnostic issues with severity and suggestions.
5
+ */
6
+
7
+ import type { DiagnosticIssue } from '../types'
8
+
9
+ type Severity = 'error' | 'warning' | 'info'
10
+
11
+ /**
12
+ * Props for IssueList
13
+ */
14
+ export interface IssueListProps {
15
+ /** Issues to display */
16
+ issues: DiagnosticIssue[]
17
+ /** Maximum issues to show (expandable) */
18
+ maxVisible?: number
19
+ /** Callback when issue is clicked */
20
+ onIssueClick?: (issue: DiagnosticIssue) => void
21
+ /** Custom class name */
22
+ className?: string
23
+ }
24
+
25
+ /**
26
+ * Severity colors
27
+ */
28
+ const severityColors: Record<Severity, { bg: string; border: string; text: string }> = {
29
+ error: { bg: 'rgba(239, 68, 68, 0.1)', border: 'rgba(239, 68, 68, 0.3)', text: '#ef4444' },
30
+ warning: { bg: 'rgba(234, 179, 8, 0.1)', border: 'rgba(234, 179, 8, 0.3)', text: '#eab308' },
31
+ info: { bg: 'rgba(59, 130, 246, 0.1)', border: 'rgba(59, 130, 246, 0.3)', text: '#3b82f6' },
32
+ }
33
+
34
+ /**
35
+ * Severity icons
36
+ */
37
+ const severityIcons: Record<Severity, string> = {
38
+ error: '✕',
39
+ warning: '⚠',
40
+ info: 'ℹ',
41
+ }
42
+
43
+ /**
44
+ * Issue List component
45
+ */
46
+ export function IssueList({
47
+ issues,
48
+ maxVisible = 5,
49
+ onIssueClick,
50
+ className = '',
51
+ }: IssueListProps) {
52
+ const hasMore = issues.length > maxVisible
53
+ const visibleIssues = hasMore ? issues.slice(0, maxVisible) : issues
54
+
55
+ if (issues.length === 0) {
56
+ return (
57
+ <div className={className} style={styles.container}>
58
+ <div style={styles.header}>
59
+ <span style={styles.title}>Issues</span>
60
+ <span style={styles.count}>0</span>
61
+ </div>
62
+ <div style={styles.empty}>
63
+ <span style={styles.checkIcon}>✓</span>
64
+ No issues detected
65
+ </div>
66
+ </div>
67
+ )
68
+ }
69
+
70
+ // Count by severity
71
+ const errorCount = issues.filter((i) => i.severity === 'error').length
72
+ const warningCount = issues.filter((i) => i.severity === 'warning').length
73
+
74
+ return (
75
+ <div className={className} style={styles.container}>
76
+ {/* Header */}
77
+ <div style={styles.header}>
78
+ <span style={styles.title}>Issues</span>
79
+ <div style={styles.counts}>
80
+ {errorCount > 0 && (
81
+ <span style={{ ...styles.countBadge, ...severityColors.error }}>
82
+ {errorCount} error{errorCount !== 1 ? 's' : ''}
83
+ </span>
84
+ )}
85
+ {warningCount > 0 && (
86
+ <span style={{ ...styles.countBadge, ...severityColors.warning }}>
87
+ {warningCount} warning{warningCount !== 1 ? 's' : ''}
88
+ </span>
89
+ )}
90
+ </div>
91
+ </div>
92
+
93
+ {/* Issue List */}
94
+ <div style={styles.list}>
95
+ {visibleIssues.map((issue, index) => {
96
+ const colors = severityColors[issue.severity]
97
+ const icon = severityIcons[issue.severity]
98
+
99
+ return (
100
+ <button
101
+ key={`${issue.code}-${index}`}
102
+ onClick={() => onIssueClick?.(issue)}
103
+ style={{
104
+ ...styles.issue,
105
+ backgroundColor: colors.bg,
106
+ borderColor: colors.border,
107
+ }}
108
+ >
109
+ <span style={{ ...styles.icon, color: colors.text }}>{icon}</span>
110
+ <div style={styles.issueContent}>
111
+ <span style={styles.issueCode}>[{issue.code}]</span>
112
+ <span style={styles.issueMessage}>{issue.message}</span>
113
+ {issue.suggestion && (
114
+ <span style={styles.issueSuggestion}>Fix: {issue.suggestion}</span>
115
+ )}
116
+ </div>
117
+ </button>
118
+ )
119
+ })}
120
+ </div>
121
+
122
+ {/* More indicator */}
123
+ {hasMore && (
124
+ <div style={styles.more}>+ {issues.length - maxVisible} more issues</div>
125
+ )}
126
+ </div>
127
+ )
128
+ }
129
+
130
+ /**
131
+ * Styles
132
+ */
133
+ const styles: Record<string, React.CSSProperties> = {
134
+ container: {
135
+ padding: '12px',
136
+ backgroundColor: 'rgba(0, 0, 0, 0.2)',
137
+ borderRadius: '8px',
138
+ border: '1px solid rgba(255, 255, 255, 0.05)',
139
+ },
140
+ header: {
141
+ display: 'flex',
142
+ justifyContent: 'space-between',
143
+ alignItems: 'center',
144
+ marginBottom: '12px',
145
+ },
146
+ title: {
147
+ fontSize: '11px',
148
+ fontWeight: 600,
149
+ color: '#888',
150
+ textTransform: 'uppercase',
151
+ letterSpacing: '0.5px',
152
+ },
153
+ count: {
154
+ fontSize: '10px',
155
+ color: '#666',
156
+ },
157
+ counts: {
158
+ display: 'flex',
159
+ gap: '6px',
160
+ },
161
+ countBadge: {
162
+ padding: '2px 6px',
163
+ borderRadius: '4px',
164
+ fontSize: '10px',
165
+ border: '1px solid',
166
+ },
167
+ empty: {
168
+ display: 'flex',
169
+ alignItems: 'center',
170
+ gap: '6px',
171
+ fontSize: '11px',
172
+ color: '#22c55e',
173
+ },
174
+ checkIcon: {
175
+ fontWeight: 600,
176
+ },
177
+ list: {
178
+ display: 'flex',
179
+ flexDirection: 'column',
180
+ gap: '6px',
181
+ },
182
+ issue: {
183
+ display: 'flex',
184
+ alignItems: 'flex-start',
185
+ gap: '8px',
186
+ padding: '8px',
187
+ border: '1px solid',
188
+ borderRadius: '4px',
189
+ cursor: 'pointer',
190
+ textAlign: 'left',
191
+ width: '100%',
192
+ transition: 'opacity 0.15s ease-out',
193
+ },
194
+ icon: {
195
+ fontSize: '12px',
196
+ fontWeight: 600,
197
+ flexShrink: 0,
198
+ marginTop: '1px',
199
+ },
200
+ issueContent: {
201
+ display: 'flex',
202
+ flexDirection: 'column',
203
+ gap: '2px',
204
+ overflow: 'hidden',
205
+ },
206
+ issueCode: {
207
+ fontSize: '10px',
208
+ fontWeight: 600,
209
+ color: '#888',
210
+ fontFamily: 'monospace',
211
+ },
212
+ issueMessage: {
213
+ fontSize: '11px',
214
+ color: '#fff',
215
+ lineHeight: 1.4,
216
+ },
217
+ issueSuggestion: {
218
+ fontSize: '10px',
219
+ color: '#888',
220
+ fontStyle: 'italic',
221
+ },
222
+ more: {
223
+ marginTop: '8px',
224
+ fontSize: '10px',
225
+ color: '#666',
226
+ textAlign: 'center',
227
+ },
228
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Lens Panel Component
3
+ *
4
+ * Panel for address impersonation controls.
5
+ * FR-005 fix: All hooks called unconditionally at top level.
6
+ */
7
+
8
+ import { useState, useCallback } from 'react'
9
+ import { useHud } from '../providers/HudProvider'
10
+ import { DEFAULT_LENS_STATE } from '../types'
11
+
12
+ /**
13
+ * Props for LensPanel
14
+ */
15
+ export interface LensPanelProps {
16
+ /** Custom class name */
17
+ className?: string
18
+ }
19
+
20
+ /**
21
+ * Lens panel for address impersonation
22
+ */
23
+ export function LensPanel({ className = '' }: LensPanelProps) {
24
+ // FR-005 fix: All hooks called unconditionally at top level
25
+ const { lensService, activePanel } = useHud()
26
+ const [inputAddress, setInputAddress] = useState('')
27
+ const [inputLabel, setInputLabel] = useState('')
28
+
29
+ // Get state from service or use default
30
+ const state = lensService?.getState() ?? DEFAULT_LENS_STATE
31
+ const isImpersonating = state.enabled && state.impersonatedAddress !== null
32
+
33
+ // Handle impersonation
34
+ const handleImpersonate = useCallback(() => {
35
+ if (!lensService || !inputAddress) return
36
+
37
+ // Basic validation
38
+ if (!/^0x[a-fA-F0-9]{40}$/.test(inputAddress)) {
39
+ alert('Invalid address format')
40
+ return
41
+ }
42
+
43
+ lensService.setImpersonatedAddress(inputAddress as `0x${string}`)
44
+ setInputAddress('')
45
+ }, [lensService, inputAddress])
46
+
47
+ // Handle save address
48
+ const handleSaveAddress = useCallback(() => {
49
+ if (!lensService || !inputAddress || !inputLabel) return
50
+
51
+ lensService.saveAddress({
52
+ address: inputAddress as `0x${string}`,
53
+ label: inputLabel,
54
+ })
55
+ setInputAddress('')
56
+ setInputLabel('')
57
+ }, [lensService, inputAddress, inputLabel])
58
+
59
+ // Handle stop impersonation
60
+ const handleStopImpersonation = useCallback(() => {
61
+ if (!lensService) return
62
+ lensService.clearImpersonation()
63
+ }, [lensService])
64
+
65
+ // Don't render if not the active panel
66
+ if (activePanel !== 'lens') return null
67
+
68
+ // Show message if lens service not available
69
+ if (!lensService) {
70
+ return (
71
+ <div className={className} style={{ color: '#666' }}>
72
+ <p>Lens service not available.</p>
73
+ <p style={{ fontSize: '10px', marginTop: '8px' }}>
74
+ Install @sigil/lens to enable address impersonation.
75
+ </p>
76
+ </div>
77
+ )
78
+ }
79
+
80
+ return (
81
+ <div className={className}>
82
+ {/* Current Status */}
83
+ <div style={{ marginBottom: '16px' }}>
84
+ <div
85
+ style={{
86
+ display: 'flex',
87
+ alignItems: 'center',
88
+ gap: '8px',
89
+ marginBottom: '8px',
90
+ }}
91
+ >
92
+ <div
93
+ style={{
94
+ width: '8px',
95
+ height: '8px',
96
+ borderRadius: '50%',
97
+ backgroundColor: isImpersonating ? '#10b981' : '#666',
98
+ }}
99
+ />
100
+ <span style={{ color: isImpersonating ? '#10b981' : '#888' }}>
101
+ {isImpersonating ? 'Impersonating' : 'Not impersonating'}
102
+ </span>
103
+ </div>
104
+
105
+ {isImpersonating && (
106
+ <div style={{ marginLeft: '16px' }}>
107
+ <code
108
+ style={{
109
+ fontSize: '11px',
110
+ color: '#10b981',
111
+ wordBreak: 'break-all',
112
+ }}
113
+ >
114
+ {state.impersonatedAddress}
115
+ </code>
116
+ <button
117
+ onClick={handleStopImpersonation}
118
+ style={{
119
+ display: 'block',
120
+ marginTop: '8px',
121
+ padding: '4px 8px',
122
+ backgroundColor: 'rgba(239, 68, 68, 0.2)',
123
+ border: '1px solid rgba(239, 68, 68, 0.3)',
124
+ borderRadius: '4px',
125
+ color: '#ef4444',
126
+ fontSize: '10px',
127
+ cursor: 'pointer',
128
+ }}
129
+ >
130
+ Stop Impersonation
131
+ </button>
132
+ </div>
133
+ )}
134
+
135
+ {state.realAddress && (
136
+ <div style={{ marginTop: '8px', color: '#666', fontSize: '10px' }}>
137
+ Real: {state.realAddress.slice(0, 6)}...{state.realAddress.slice(-4)}
138
+ </div>
139
+ )}
140
+ </div>
141
+
142
+ {/* Impersonate Address */}
143
+ <div style={{ marginBottom: '16px' }}>
144
+ <label
145
+ style={{
146
+ display: 'block',
147
+ color: '#888',
148
+ fontSize: '10px',
149
+ marginBottom: '4px',
150
+ }}
151
+ >
152
+ Impersonate Address
153
+ </label>
154
+ <input
155
+ type="text"
156
+ value={inputAddress}
157
+ onChange={(e) => setInputAddress(e.target.value)}
158
+ placeholder="0x..."
159
+ style={{
160
+ width: '100%',
161
+ padding: '8px',
162
+ backgroundColor: 'rgba(255, 255, 255, 0.05)',
163
+ border: '1px solid rgba(255, 255, 255, 0.1)',
164
+ borderRadius: '4px',
165
+ color: '#fff',
166
+ fontSize: '11px',
167
+ fontFamily: 'ui-monospace, monospace',
168
+ }}
169
+ />
170
+ <div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
171
+ <button
172
+ onClick={handleImpersonate}
173
+ disabled={!inputAddress}
174
+ style={{
175
+ flex: 1,
176
+ padding: '6px 12px',
177
+ backgroundColor: inputAddress
178
+ ? 'rgba(16, 185, 129, 0.2)'
179
+ : 'rgba(255, 255, 255, 0.05)',
180
+ border: '1px solid rgba(16, 185, 129, 0.3)',
181
+ borderRadius: '4px',
182
+ color: inputAddress ? '#10b981' : '#666',
183
+ fontSize: '11px',
184
+ cursor: inputAddress ? 'pointer' : 'not-allowed',
185
+ }}
186
+ >
187
+ Impersonate
188
+ </button>
189
+ </div>
190
+ </div>
191
+
192
+ {/* Save Address */}
193
+ <div style={{ marginBottom: '16px' }}>
194
+ <label
195
+ style={{
196
+ display: 'block',
197
+ color: '#888',
198
+ fontSize: '10px',
199
+ marginBottom: '4px',
200
+ }}
201
+ >
202
+ Save with Label
203
+ </label>
204
+ <input
205
+ type="text"
206
+ value={inputLabel}
207
+ onChange={(e) => setInputLabel(e.target.value)}
208
+ placeholder="Label (e.g., Whale)"
209
+ style={{
210
+ width: '100%',
211
+ padding: '8px',
212
+ backgroundColor: 'rgba(255, 255, 255, 0.05)',
213
+ border: '1px solid rgba(255, 255, 255, 0.1)',
214
+ borderRadius: '4px',
215
+ color: '#fff',
216
+ fontSize: '11px',
217
+ marginBottom: '8px',
218
+ }}
219
+ />
220
+ <button
221
+ onClick={handleSaveAddress}
222
+ disabled={!inputAddress || !inputLabel}
223
+ style={{
224
+ width: '100%',
225
+ padding: '6px 12px',
226
+ backgroundColor:
227
+ inputAddress && inputLabel
228
+ ? 'rgba(59, 130, 246, 0.2)'
229
+ : 'rgba(255, 255, 255, 0.05)',
230
+ border: '1px solid rgba(59, 130, 246, 0.3)',
231
+ borderRadius: '4px',
232
+ color: inputAddress && inputLabel ? '#3b82f6' : '#666',
233
+ fontSize: '11px',
234
+ cursor: inputAddress && inputLabel ? 'pointer' : 'not-allowed',
235
+ }}
236
+ >
237
+ Save Address
238
+ </button>
239
+ </div>
240
+
241
+ {/* Saved Addresses */}
242
+ {state.savedAddresses.length > 0 && (
243
+ <div>
244
+ <label
245
+ style={{
246
+ display: 'block',
247
+ color: '#888',
248
+ fontSize: '10px',
249
+ marginBottom: '8px',
250
+ }}
251
+ >
252
+ Saved Addresses
253
+ </label>
254
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
255
+ {state.savedAddresses.map((saved) => (
256
+ <button
257
+ key={saved.address}
258
+ onClick={() =>
259
+ lensService?.setImpersonatedAddress(saved.address)
260
+ }
261
+ style={{
262
+ display: 'flex',
263
+ justifyContent: 'space-between',
264
+ alignItems: 'center',
265
+ padding: '8px',
266
+ backgroundColor: 'rgba(255, 255, 255, 0.02)',
267
+ border: '1px solid rgba(255, 255, 255, 0.05)',
268
+ borderRadius: '4px',
269
+ cursor: 'pointer',
270
+ textAlign: 'left',
271
+ }}
272
+ >
273
+ <span style={{ color: '#fff', fontSize: '11px' }}>
274
+ {saved.label}
275
+ </span>
276
+ <code style={{ color: '#666', fontSize: '10px' }}>
277
+ {saved.address.slice(0, 6)}...{saved.address.slice(-4)}
278
+ </code>
279
+ </button>
280
+ ))}
281
+ </div>
282
+ </div>
283
+ )}
284
+ </div>
285
+ )
286
+ }