@toolr/ui-design 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.
Files changed (112) hide show
  1. package/README.md +63 -0
  2. package/components/content/info-panel-primitives.tsx +297 -0
  3. package/components/diagrams/diagram-utils.tsx +908 -0
  4. package/components/hooks/use-click-outside.ts +27 -0
  5. package/components/hooks/use-dropdown-max-height.ts +20 -0
  6. package/components/hooks/use-navigation-history.ts +94 -0
  7. package/components/lib/ai-tools.tsx +44 -0
  8. package/components/lib/cn.ts +6 -0
  9. package/components/lib/form-colors.ts +32 -0
  10. package/components/lib/theme-engine.ts +97 -0
  11. package/components/lib/toolr-brand.tsx +31 -0
  12. package/components/sections/ai-tools-paths/index.ts +37 -0
  13. package/components/sections/ai-tools-paths/tools-paths-panel.tsx +212 -0
  14. package/components/sections/ai-tools-paths/types.ts +111 -0
  15. package/components/sections/ai-tools-paths/use-tools-paths.ts +159 -0
  16. package/components/sections/captured-issues/captured-issues-panel.tsx +214 -0
  17. package/components/sections/captured-issues/index.ts +38 -0
  18. package/components/sections/captured-issues/types.ts +113 -0
  19. package/components/sections/captured-issues/use-captured-issues.ts +111 -0
  20. package/components/sections/golden-snapshots/file-diff-viewer.tsx +420 -0
  21. package/components/sections/golden-snapshots/golden-sync-panel.tsx +223 -0
  22. package/components/sections/golden-snapshots/index.ts +145 -0
  23. package/components/sections/golden-snapshots/snapshot-manager.tsx +200 -0
  24. package/components/sections/golden-snapshots/status-overview.tsx +305 -0
  25. package/components/sections/golden-snapshots/types.ts +288 -0
  26. package/components/sections/golden-snapshots/use-golden-sync.ts +477 -0
  27. package/components/sections/golden-snapshots/version-manager.tsx +186 -0
  28. package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +210 -0
  29. package/components/sections/prompt-editor/index.ts +121 -0
  30. package/components/sections/prompt-editor/simulator-prompt-editor.tsx +276 -0
  31. package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +514 -0
  32. package/components/sections/prompt-editor/types.ts +101 -0
  33. package/components/sections/prompt-editor/use-prompt-editor.ts +131 -0
  34. package/components/sections/report-bug/error-logger.ts +392 -0
  35. package/components/sections/report-bug/index.ts +59 -0
  36. package/components/sections/report-bug/issue-reporter-api.ts +83 -0
  37. package/components/sections/report-bug/report-bug-form.tsx +282 -0
  38. package/components/sections/report-bug/screenshot-uploader.tsx +228 -0
  39. package/components/sections/report-bug/use-report-bug.ts +170 -0
  40. package/components/sections/snapshot-browser/index.ts +53 -0
  41. package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +147 -0
  42. package/components/sections/snapshot-browser/snapshot-tree.tsx +451 -0
  43. package/components/sections/snapshot-browser/types.ts +106 -0
  44. package/components/sections/snapshot-browser/use-snapshot-browser.ts +125 -0
  45. package/components/sections/snippets-editor/index.ts +31 -0
  46. package/components/sections/snippets-editor/snippets-editor.tsx +381 -0
  47. package/components/sections/snippets-editor/types.ts +48 -0
  48. package/components/sections/snippets-editor/use-snippets-editor.ts +217 -0
  49. package/components/ui/action-dialog.tsx +309 -0
  50. package/components/ui/ai-action-button.tsx +137 -0
  51. package/components/ui/ai-execution-action-buttons.tsx +106 -0
  52. package/components/ui/badge.tsx +67 -0
  53. package/components/ui/bottom-panel-header.tsx +240 -0
  54. package/components/ui/breadcrumb.tsx +168 -0
  55. package/components/ui/checkbox.tsx +102 -0
  56. package/components/ui/collapsible-section.tsx +100 -0
  57. package/components/ui/confirm-badge.tsx +71 -0
  58. package/components/ui/detail-section.tsx +67 -0
  59. package/components/ui/detail-view-wrapper.tsx +55 -0
  60. package/components/ui/editor-placeholder-card.tsx +197 -0
  61. package/components/ui/editor-toolbar.tsx +123 -0
  62. package/components/ui/execution-details-panel.tsx +93 -0
  63. package/components/ui/extension-list-card.tsx +105 -0
  64. package/components/ui/file-structure-section.tsx +373 -0
  65. package/components/ui/file-tree.tsx +171 -0
  66. package/components/ui/files-panel.tsx +251 -0
  67. package/components/ui/filter-dropdown.tsx +173 -0
  68. package/components/ui/form-actions.tsx +127 -0
  69. package/components/ui/frontmatter-form-header.tsx +80 -0
  70. package/components/ui/icon-button.tsx +388 -0
  71. package/components/ui/input.tsx +211 -0
  72. package/components/ui/label.tsx +159 -0
  73. package/components/ui/layout-tab-bar.tsx +289 -0
  74. package/components/ui/modal.tsx +194 -0
  75. package/components/ui/nav-card.tsx +81 -0
  76. package/components/ui/navigation-bar.tsx +285 -0
  77. package/components/ui/number-input.tsx +165 -0
  78. package/components/ui/registry-browser.tsx +261 -0
  79. package/components/ui/registry-card.tsx +710 -0
  80. package/components/ui/registry-detail.tsx +224 -0
  81. package/components/ui/resizable-textarea.tsx +290 -0
  82. package/components/ui/scope-badge.tsx +67 -0
  83. package/components/ui/segmented-toggle.tsx +133 -0
  84. package/components/ui/select.tsx +172 -0
  85. package/components/ui/selection-grid.tsx +313 -0
  86. package/components/ui/setting-row.tsx +97 -0
  87. package/components/ui/snapshot-card.tsx +107 -0
  88. package/components/ui/snippets-panel.tsx +161 -0
  89. package/components/ui/sort-dropdown.tsx +109 -0
  90. package/components/ui/status-card.tsx +96 -0
  91. package/components/ui/tab-bar.tsx +340 -0
  92. package/components/ui/toggle.tsx +142 -0
  93. package/components/ui/tooltip.tsx +326 -0
  94. package/dist/content.d.ts +110 -0
  95. package/dist/content.js +195 -0
  96. package/dist/diagrams.d.ts +371 -0
  97. package/dist/diagrams.js +702 -0
  98. package/dist/index.d.ts +2714 -0
  99. package/dist/index.js +11220 -0
  100. package/dist/preset.d.ts +24 -0
  101. package/dist/preset.js +17 -0
  102. package/dist/tokens/tokens/primitives.css +45 -0
  103. package/dist/tokens/tokens/semantic.css +46 -0
  104. package/dist/tokens/tokens/theme.css +11 -0
  105. package/dist/tokens/tokens/tokens.json +65 -0
  106. package/index.ts +123 -0
  107. package/package.json +63 -0
  108. package/tailwind-preset.ts +22 -0
  109. package/tokens/primitives.css +45 -0
  110. package/tokens/semantic.css +46 -0
  111. package/tokens/theme.css +11 -0
  112. package/tokens/tokens.json +65 -0
@@ -0,0 +1,131 @@
1
+ /**
2
+ * usePromptEditor — Hook managing prompt editor state
3
+ *
4
+ * Part of: Sections > Prompt Editor
5
+ *
6
+ * Handles:
7
+ * - Local content state (separate from saved content for dirty detection)
8
+ * - Dirty state detection
9
+ * - Save with Ctrl/Cmd+S keyboard shortcut
10
+ * - Variable search/filter
11
+ * - Active tab state
12
+ */
13
+
14
+ import { useState, useCallback, useEffect, useMemo } from 'react'
15
+ import type { ToolTab, PromptPlaceholder } from './types.ts'
16
+
17
+ export interface UsePromptEditorOptions {
18
+ /** Prompt content keyed by tool id */
19
+ prompts: Record<string, string>
20
+ /** Called when saving (all modified prompts at once) */
21
+ onPromptChange: (tool: string, value: string) => void
22
+ /** Available tool tabs */
23
+ tools: ToolTab[]
24
+ /** Available template variables */
25
+ variables?: PromptPlaceholder[]
26
+ }
27
+
28
+ export interface UsePromptEditorReturn {
29
+ /** Currently active tool tab id */
30
+ activeTab: string
31
+ setActiveTab: (tab: string) => void
32
+ /** Local editor content (may differ from saved prompts) */
33
+ localContent: Record<string, string>
34
+ /** Current prompt for the active tab */
35
+ currentPrompt: string
36
+ /** Whether any tab has unsaved changes */
37
+ isDirty: boolean
38
+ /** Handle editor content change */
39
+ handleEditorChange: (value: string | undefined) => void
40
+ /** Save all modified prompts */
41
+ handleSave: () => void
42
+ /** Variable search state */
43
+ variableSearch: string
44
+ setVariableSearch: (search: string) => void
45
+ /** Filtered variables based on search */
46
+ filteredVariables: PromptPlaceholder[]
47
+ }
48
+
49
+ export function usePromptEditor({
50
+ prompts,
51
+ onPromptChange,
52
+ tools,
53
+ variables,
54
+ }: UsePromptEditorOptions): UsePromptEditorReturn {
55
+ const [activeTab, setActiveTab] = useState(tools[0]?.id ?? '')
56
+ const [localContent, setLocalContent] = useState<Record<string, string>>(prompts)
57
+ const [isDirty, setIsDirty] = useState(false)
58
+ const [variableSearch, setVariableSearch] = useState('')
59
+
60
+ // Sync local content when prompts change externally
61
+ useEffect(() => {
62
+ setLocalContent(prompts)
63
+ setIsDirty(false)
64
+ }, [prompts])
65
+
66
+ const handleEditorChange = useCallback(
67
+ (value: string | undefined) => {
68
+ const newValue = value ?? ''
69
+ setLocalContent((prev) => {
70
+ const updated = { ...prev, [activeTab]: newValue }
71
+ const hasDiff = Object.keys(prompts).some(
72
+ (tool) => updated[tool] !== prompts[tool],
73
+ )
74
+ setIsDirty(hasDiff)
75
+ return updated
76
+ })
77
+ },
78
+ [activeTab, prompts],
79
+ )
80
+
81
+ const handleSave = useCallback(() => {
82
+ for (const tool of Object.keys(prompts)) {
83
+ if (localContent[tool] !== prompts[tool]) {
84
+ onPromptChange(tool, localContent[tool])
85
+ }
86
+ }
87
+ setIsDirty(false)
88
+ }, [localContent, prompts, onPromptChange])
89
+
90
+ // Keyboard shortcut: Cmd/Ctrl + S
91
+ useEffect(() => {
92
+ const handleKeyDown = (e: KeyboardEvent) => {
93
+ if ((e.metaKey || e.ctrlKey) && e.key === 's') {
94
+ e.preventDefault()
95
+ if (isDirty) {
96
+ handleSave()
97
+ }
98
+ }
99
+ }
100
+ window.addEventListener('keydown', handleKeyDown)
101
+ return () => window.removeEventListener('keydown', handleKeyDown)
102
+ }, [isDirty, handleSave])
103
+
104
+ const currentPrompt = localContent[activeTab] ?? ''
105
+
106
+ // Filter variables based on search
107
+ const filteredVariables = useMemo(() => {
108
+ if (!variables) return []
109
+ if (!variableSearch.trim()) return variables
110
+ const search = variableSearch.toLowerCase()
111
+ return variables.filter(
112
+ (v) =>
113
+ v.name.toLowerCase().includes(search) ||
114
+ v.description.toLowerCase().includes(search) ||
115
+ (v.example && v.example.toLowerCase().includes(search)),
116
+ )
117
+ }, [variables, variableSearch])
118
+
119
+ return {
120
+ activeTab,
121
+ setActiveTab,
122
+ localContent,
123
+ currentPrompt,
124
+ isDirty,
125
+ handleEditorChange,
126
+ handleSave,
127
+ variableSearch,
128
+ setVariableSearch,
129
+ filteredVariables,
130
+ }
131
+ }
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Error Logger — Automatic console error capture for issue reporting
3
+ *
4
+ * Part of: Sections > Report a Bug
5
+ *
6
+ * Provides a factory function to create app-specific error loggers.
7
+ * Each logger instance has its own localStorage namespace and in-memory state.
8
+ *
9
+ * Usage:
10
+ * const logger = createErrorLogger('myapp')
11
+ * logger.init() // Call early in app startup to begin capturing
12
+ * // Later, pass captured data to ReportBugForm:
13
+ * <ReportBugForm
14
+ * errorReport={logger.getFormattedReport()}
15
+ * errorCount={logger.getErrorCount()}
16
+ * warnCount={logger.getWarnCount()}
17
+ * onErrorsSubmitted={() => { logger.markAsSubmitted(); logger.clearLogs() }}
18
+ * />
19
+ *
20
+ * AI agent notes:
21
+ * - createErrorLogger returns an isolated instance with its own storage prefix
22
+ * - Three-list architecture: Current (active) -> Submitted (reported) | Dismissed (ignored)
23
+ * - Errors are deduplicated by fingerprint (djb2 hash of first error line)
24
+ * - init() intercepts console.error, console.warn, and window error events
25
+ * - subscribe/getSnapshot are for React's useSyncExternalStore
26
+ * - The form component (ReportBugForm) does NOT depend on this directly;
27
+ * it accepts errorReport/errorCount as props, keeping the two decoupled
28
+ */
29
+
30
+ export interface TrackedError {
31
+ fingerprint: string
32
+ firstMessage: string
33
+ count: number
34
+ firstSeen: string
35
+ lastSeen: string
36
+ stack?: string
37
+ }
38
+
39
+ export interface ErrorLogger {
40
+ /** Start capturing console.error, console.warn, and unhandled errors */
41
+ init(): void
42
+ /** Number of unique current (unreported) errors */
43
+ getErrorCount(): number
44
+ /** Number of warnings in the log buffer */
45
+ getWarnCount(): number
46
+ /** Current errors not yet submitted or dismissed */
47
+ getCurrentErrors(): TrackedError[]
48
+ /** Previously submitted errors */
49
+ getSubmittedErrors(): TrackedError[]
50
+ /** Markdown-formatted error report for Linear issue submission */
51
+ getFormattedReport(): string
52
+ /** Combined fingerprint of all current errors (for duplicate detection) */
53
+ getErrorFingerprint(): string
54
+ /** Raw log output as a string */
55
+ getLogs(): string
56
+ /** Move all current errors to the submitted list */
57
+ markAsSubmitted(): void
58
+ /** Move all current errors to the dismissed list */
59
+ markAsDismissed(): void
60
+ /** Clear the in-memory log buffer (does not affect tracked errors) */
61
+ clearLogs(): void
62
+ /** For React useSyncExternalStore */
63
+ subscribe(listener: () => void): () => void
64
+ /** For React useSyncExternalStore */
65
+ getSnapshot(): number
66
+ }
67
+
68
+ interface LogEntry {
69
+ timestamp: string
70
+ level: 'error' | 'warn' | 'info'
71
+ message: string
72
+ stack?: string
73
+ }
74
+
75
+ function generateFingerprint(message: string): string {
76
+ const normalized = message.split('\n')[0].trim()
77
+ let hash = 5381
78
+ for (let i = 0; i < normalized.length; i++) {
79
+ hash = ((hash << 5) + hash) ^ normalized.charCodeAt(i)
80
+ }
81
+ return (hash >>> 0).toString(16).padStart(8, '0')
82
+ }
83
+
84
+ function cleanStackTrace(stack: string): string {
85
+ return stack
86
+ .replace(/https?:\/\/localhost:\d+\//g, '')
87
+ .replace(/https?:\/\/127\.0\.0\.1:\d+\//g, '')
88
+ .replace(/node_modules\/\.vite\/deps\//g, '')
89
+ .split('\n')
90
+ .map((line) => line.trim())
91
+ .filter((line) => line.length > 0)
92
+ .join('\n')
93
+ }
94
+
95
+ function formatTimestamp(isoString: string): string {
96
+ try {
97
+ return new Date(isoString).toLocaleString('en-US', {
98
+ year: 'numeric',
99
+ month: 'short',
100
+ day: 'numeric',
101
+ hour: '2-digit',
102
+ minute: '2-digit',
103
+ second: '2-digit',
104
+ hour12: false,
105
+ })
106
+ } catch {
107
+ return isoString
108
+ }
109
+ }
110
+
111
+ const MAX_LOGS = 100
112
+ const MAX_TRACKED = 100
113
+
114
+ export function createErrorLogger(storagePrefix: string): ErrorLogger {
115
+ const CURRENT_KEY = `${storagePrefix}_current_errors`
116
+ const SUBMITTED_KEY = `${storagePrefix}_submitted_errors`
117
+ const DISMISSED_KEY = `${storagePrefix}_dismissed_errors`
118
+
119
+ const logs: LogEntry[] = []
120
+ let currentErrors: Map<string, TrackedError> = new Map()
121
+ const listeners = new Set<() => void>()
122
+ let snapshotVersion = 0
123
+
124
+ function notifyListeners() {
125
+ snapshotVersion++
126
+ listeners.forEach((l) => l())
127
+ }
128
+
129
+ function getTrackedErrors(key: string): Map<string, TrackedError> {
130
+ try {
131
+ if (typeof window === 'undefined') return new Map()
132
+ const stored = localStorage.getItem(key)
133
+ if (!stored) return new Map()
134
+ const arr: TrackedError[] = JSON.parse(stored)
135
+ return new Map(arr.map((e) => [e.fingerprint, e]))
136
+ } catch {
137
+ return new Map()
138
+ }
139
+ }
140
+
141
+ function saveTrackedErrors(key: string, errors: Map<string, TrackedError>): void {
142
+ try {
143
+ if (typeof window === 'undefined') return
144
+ const arr = Array.from(errors.values()).slice(-MAX_TRACKED)
145
+ localStorage.setItem(key, JSON.stringify(arr))
146
+ } catch {
147
+ // Ignore storage errors
148
+ }
149
+ }
150
+
151
+ function loadCurrentErrors(): void {
152
+ currentErrors = getTrackedErrors(CURRENT_KEY)
153
+ }
154
+
155
+ function saveCurrentErrors(): void {
156
+ saveTrackedErrors(CURRENT_KEY, currentErrors)
157
+ }
158
+
159
+ function tryUpdateTrackedError(storageKey: string, fingerprint: string, now: string, entry: LogEntry): boolean {
160
+ const errors = getTrackedErrors(storageKey)
161
+ if (!errors.has(fingerprint)) return false
162
+ const existing = errors.get(fingerprint)!
163
+ existing.count++
164
+ existing.lastSeen = now
165
+ saveTrackedErrors(storageKey, errors)
166
+ logs.push(entry)
167
+ if (logs.length > MAX_LOGS) logs.shift()
168
+ return true
169
+ }
170
+
171
+ function addLog(level: LogEntry['level'], args: unknown[]): void {
172
+ const message = args
173
+ .map((arg) => {
174
+ if (arg instanceof Error) return `${arg.message}\n${arg.stack || ''}`
175
+ if (typeof arg === 'object') {
176
+ try {
177
+ return JSON.stringify(arg, null, 2)
178
+ } catch {
179
+ return String(arg)
180
+ }
181
+ }
182
+ return String(arg)
183
+ })
184
+ .join(' ')
185
+
186
+ const entry: LogEntry = { timestamp: new Date().toISOString(), level, message }
187
+
188
+ const errorArg = args.find((arg) => arg instanceof Error) as Error | undefined
189
+ if (errorArg?.stack) entry.stack = errorArg.stack
190
+
191
+ if (level !== 'error') {
192
+ logs.push(entry)
193
+ if (logs.length > MAX_LOGS) logs.shift()
194
+ if (level === 'warn') notifyListeners()
195
+ return
196
+ }
197
+
198
+ const fingerprint = generateFingerprint(message)
199
+ const now = new Date().toISOString()
200
+ const firstLine = message.split('\n')[0]
201
+ const truncatedMessage = firstLine.length > 300 ? firstLine.slice(0, 300) + '...' : firstLine
202
+
203
+ if (tryUpdateTrackedError(DISMISSED_KEY, fingerprint, now, entry)) return
204
+ if (tryUpdateTrackedError(SUBMITTED_KEY, fingerprint, now, entry)) return
205
+
206
+ if (currentErrors.has(fingerprint)) {
207
+ const existing = currentErrors.get(fingerprint)!
208
+ existing.count++
209
+ existing.lastSeen = now
210
+ saveCurrentErrors()
211
+ logs.push(entry)
212
+ if (logs.length > MAX_LOGS) logs.shift()
213
+ return
214
+ }
215
+
216
+ currentErrors.set(fingerprint, {
217
+ fingerprint,
218
+ firstMessage: truncatedMessage,
219
+ count: 1,
220
+ firstSeen: now,
221
+ lastSeen: now,
222
+ stack: entry.stack,
223
+ })
224
+ saveCurrentErrors()
225
+ notifyListeners()
226
+
227
+ logs.push(entry)
228
+ if (logs.length > MAX_LOGS) logs.shift()
229
+ }
230
+
231
+ return {
232
+ init() {
233
+ loadCurrentErrors()
234
+
235
+ const originalError = console.error
236
+ const originalWarn = console.warn
237
+
238
+ console.error = (...args: unknown[]) => {
239
+ addLog('error', args)
240
+ originalError.apply(console, args)
241
+ }
242
+
243
+ console.warn = (...args: unknown[]) => {
244
+ addLog('warn', args)
245
+ originalWarn.apply(console, args)
246
+ }
247
+
248
+ if (typeof window !== 'undefined') {
249
+ window.addEventListener('error', (event) => {
250
+ addLog('error', [
251
+ `Unhandled error: ${event.message}`,
252
+ event.error || { filename: event.filename, lineno: event.lineno, colno: event.colno },
253
+ ])
254
+ })
255
+
256
+ window.addEventListener('unhandledrejection', (event) => {
257
+ addLog('error', [`Unhandled promise rejection: ${event.reason}`])
258
+ })
259
+ }
260
+ },
261
+
262
+ getErrorCount() {
263
+ return currentErrors.size
264
+ },
265
+
266
+ getWarnCount() {
267
+ return logs.filter((e) => e.level === 'warn').length
268
+ },
269
+
270
+ getCurrentErrors() {
271
+ return Array.from(currentErrors.values())
272
+ },
273
+
274
+ getSubmittedErrors() {
275
+ return Array.from(getTrackedErrors(SUBMITTED_KEY).values())
276
+ },
277
+
278
+ getFormattedReport() {
279
+ const errors = Array.from(currentErrors.values())
280
+ if (errors.length === 0) return 'No errors captured.'
281
+
282
+ const parts: string[] = []
283
+ parts.push('## Error Report')
284
+ parts.push('')
285
+ parts.push(`**${errors.length} unique error${errors.length > 1 ? 's' : ''} captured**`)
286
+ parts.push('')
287
+
288
+ for (let i = 0; i < errors.length; i++) {
289
+ const error = errors[i]
290
+ const countInfo = error.count > 1 ? ` (x${error.count})` : ''
291
+ parts.push(`### ${i + 1}. ${error.firstMessage}${countInfo}`)
292
+ parts.push('')
293
+ parts.push('| Field | Value |')
294
+ parts.push('|-------|-------|')
295
+ parts.push(`| Fingerprint | \`${error.fingerprint}\` |`)
296
+ parts.push(`| First seen | ${formatTimestamp(error.firstSeen)} |`)
297
+ if (error.count > 1) {
298
+ parts.push(`| Last seen | ${formatTimestamp(error.lastSeen)} |`)
299
+ parts.push(`| Occurrences | ${error.count} |`)
300
+ }
301
+ parts.push('')
302
+
303
+ if (error.stack) {
304
+ parts.push('**Stack trace:**')
305
+ parts.push('```')
306
+ parts.push(cleanStackTrace(error.stack))
307
+ parts.push('```')
308
+ parts.push('')
309
+ }
310
+
311
+ if (i < errors.length - 1) {
312
+ parts.push('---')
313
+ parts.push('')
314
+ }
315
+ }
316
+
317
+ return parts.join('\n')
318
+ },
319
+
320
+ getErrorFingerprint() {
321
+ if (currentErrors.size === 0) return ''
322
+ const fingerprints = Array.from(currentErrors.keys()).sort().join('|')
323
+ let hash = 5381
324
+ for (let i = 0; i < fingerprints.length; i++) {
325
+ hash = ((hash << 5) + hash) ^ fingerprints.charCodeAt(i)
326
+ }
327
+ return (hash >>> 0).toString(16).padStart(8, '0')
328
+ },
329
+
330
+ getLogs() {
331
+ if (logs.length === 0) return 'No console logs captured.'
332
+ return logs
333
+ .map((log) => {
334
+ const parts = [`[${log.timestamp}] [${log.level.toUpperCase()}] ${log.message}`]
335
+ if (log.stack) parts.push(`Stack: ${log.stack}`)
336
+ return parts.join('\n')
337
+ })
338
+ .join('\n\n')
339
+ },
340
+
341
+ markAsSubmitted() {
342
+ if (currentErrors.size === 0) return
343
+ const submittedErrors = getTrackedErrors(SUBMITTED_KEY)
344
+ const now = new Date().toISOString()
345
+ for (const [fp, error] of currentErrors) {
346
+ const existing = submittedErrors.get(fp)
347
+ if (existing) {
348
+ existing.count += error.count
349
+ existing.lastSeen = now
350
+ } else {
351
+ submittedErrors.set(fp, { ...error, lastSeen: now })
352
+ }
353
+ }
354
+ saveTrackedErrors(SUBMITTED_KEY, submittedErrors)
355
+ currentErrors.clear()
356
+ saveCurrentErrors()
357
+ notifyListeners()
358
+ },
359
+
360
+ markAsDismissed() {
361
+ if (currentErrors.size === 0) return
362
+ const dismissedErrors = getTrackedErrors(DISMISSED_KEY)
363
+ const now = new Date().toISOString()
364
+ for (const [fp, error] of currentErrors) {
365
+ const existing = dismissedErrors.get(fp)
366
+ if (existing) {
367
+ existing.count += error.count
368
+ existing.lastSeen = now
369
+ } else {
370
+ dismissedErrors.set(fp, { ...error, lastSeen: now })
371
+ }
372
+ }
373
+ saveTrackedErrors(DISMISSED_KEY, dismissedErrors)
374
+ currentErrors.clear()
375
+ saveCurrentErrors()
376
+ notifyListeners()
377
+ },
378
+
379
+ clearLogs() {
380
+ logs.length = 0
381
+ },
382
+
383
+ subscribe(listener: () => void) {
384
+ listeners.add(listener)
385
+ return () => listeners.delete(listener)
386
+ },
387
+
388
+ getSnapshot() {
389
+ return snapshotVersion
390
+ },
391
+ }
392
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Report a Bug — Section barrel export
3
+ *
4
+ * This section provides a complete, reusable bug reporting feature that
5
+ * submits issues to Linear via a Cloudflare Worker.
6
+ *
7
+ * File structure:
8
+ * - report-bug-form.tsx — Main form component (drop-in usage)
9
+ * - screenshot-uploader.tsx — Drag & drop image upload (used by form, also standalone)
10
+ * - use-report-bug.ts — Form state & submission hook (used by form, also standalone)
11
+ * - issue-reporter-api.ts — Types and HTTP client for the worker endpoint
12
+ * - error-logger.ts — Optional error capture utility (decoupled from form)
13
+ *
14
+ * Quick start for consuming apps:
15
+ * import { ReportBugForm } from '@toolr/ui-design'
16
+ *
17
+ * <ReportBugForm
18
+ * workerUrl="https://your-issue-reporter.workers.dev"
19
+ * getSystemInfo={yourGetSystemInfoFn}
20
+ * linearTeamId="your-linear-team-uuid"
21
+ * onSuccess={(r) => toast(`Created ${r.issueId}`)}
22
+ * />
23
+ *
24
+ * For error log integration:
25
+ * import { createErrorLogger, ReportBugForm } from '@toolr/ui-design'
26
+ *
27
+ * const logger = createErrorLogger('yourapp')
28
+ * logger.init()
29
+ *
30
+ * <ReportBugForm
31
+ * {...config}
32
+ * errorReport={logger.getFormattedReport()}
33
+ * errorCount={logger.getErrorCount()}
34
+ * onErrorsSubmitted={() => { logger.markAsSubmitted(); logger.clearLogs() }}
35
+ * />
36
+ */
37
+
38
+ // Main form component
39
+ export { ReportBugForm, type ReportBugFormProps } from './report-bug-form.tsx'
40
+
41
+ // Screenshot uploader (standalone usage)
42
+ export { ScreenshotUploader, type ScreenshotUploaderProps, type Screenshot } from './screenshot-uploader.tsx'
43
+
44
+ // Hook for custom form UIs
45
+ export { useReportBug, type UseReportBugOptions, type UseReportBugReturn } from './use-report-bug.ts'
46
+
47
+ // API types and client
48
+ export {
49
+ submitIssueReport,
50
+ ISSUE_TYPES,
51
+ type IssueType,
52
+ type IssueReport,
53
+ type IssueReportResult,
54
+ type SystemInfo,
55
+ type ScreenshotAttachment,
56
+ } from './issue-reporter-api.ts'
57
+
58
+ // Error logger utility
59
+ export { createErrorLogger, type ErrorLogger, type TrackedError } from './error-logger.ts'
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Issue Reporter API — Types and submission client
3
+ *
4
+ * Part of: Sections > Report a Bug
5
+ *
6
+ * This module defines the shared types for issue reporting and provides
7
+ * the HTTP client that submits reports to a Cloudflare Worker endpoint.
8
+ * The worker creates Linear issues with optional screenshot uploads and
9
+ * duplicate detection.
10
+ *
11
+ * AI agent notes:
12
+ * - workerUrl is passed per-call so different apps can use different endpoints
13
+ * - linearTeamId/linearProjectId are sent in the request body, allowing one
14
+ * worker to serve multiple apps (the worker must read these from the body)
15
+ * - The worker source lives at /reviewr/workers/issue-reporter/
16
+ * - ScreenshotAttachment.data is base64-encoded (no data URL prefix)
17
+ * - SystemInfo must be provided by the consuming app (e.g. via Tauri invoke,
18
+ * navigator.userAgent, or a custom endpoint)
19
+ */
20
+
21
+ export type IssueType = 'bug' | 'feature' | 'question' | 'improvement' | 'documentation'
22
+
23
+ export const ISSUE_TYPES: { value: IssueType; label: string }[] = [
24
+ { value: 'bug', label: 'Bug' },
25
+ { value: 'feature', label: 'Feature' },
26
+ { value: 'question', label: 'Question' },
27
+ { value: 'improvement', label: 'Improvement' },
28
+ { value: 'documentation', label: 'Documentation' },
29
+ ]
30
+
31
+ export interface SystemInfo {
32
+ os: string
33
+ osVersion: string
34
+ appVersion: string
35
+ arch: string
36
+ }
37
+
38
+ export interface ScreenshotAttachment {
39
+ filename: string
40
+ data: string
41
+ contentType: string
42
+ }
43
+
44
+ export interface IssueReport {
45
+ title: string
46
+ description: string
47
+ type: IssueType
48
+ stackTrace?: string
49
+ errorFingerprint?: string
50
+ systemInfo: SystemInfo
51
+ userEmail?: string
52
+ screenshots?: ScreenshotAttachment[]
53
+ /** Target Linear team — allows one worker to serve multiple apps */
54
+ linearTeamId?: string
55
+ /** Target Linear project (optional) */
56
+ linearProjectId?: string
57
+ }
58
+
59
+ export interface IssueReportResult {
60
+ success: boolean
61
+ issueId?: string
62
+ message: string
63
+ isDuplicate?: boolean
64
+ url?: string
65
+ }
66
+
67
+ export async function submitIssueReport(
68
+ workerUrl: string,
69
+ report: IssueReport,
70
+ ): Promise<IssueReportResult> {
71
+ const response = await fetch(workerUrl, {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify(report),
75
+ })
76
+
77
+ if (!response.ok) {
78
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
79
+ return { success: false, message: error.error || 'Failed to submit issue' }
80
+ }
81
+
82
+ return await response.json()
83
+ }