create-fluxstack 1.13.0 → 1.14.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 (61) hide show
  1. package/LLMD/patterns/anti-patterns.md +100 -0
  2. package/LLMD/reference/routing.md +39 -39
  3. package/LLMD/resources/live-auth.md +20 -2
  4. package/LLMD/resources/live-components.md +94 -10
  5. package/LLMD/resources/live-logging.md +95 -33
  6. package/LLMD/resources/live-upload.md +59 -8
  7. package/app/client/index.html +2 -2
  8. package/app/client/public/favicon.svg +46 -0
  9. package/app/client/src/App.tsx +2 -1
  10. package/app/client/src/assets/fluxstack-static.svg +46 -0
  11. package/app/client/src/assets/fluxstack.svg +183 -0
  12. package/app/client/src/components/AppLayout.tsx +138 -9
  13. package/app/client/src/components/BackButton.tsx +13 -13
  14. package/app/client/src/components/DemoPage.tsx +4 -4
  15. package/app/client/src/live/AuthDemo.tsx +23 -21
  16. package/app/client/src/live/ChatDemo.tsx +2 -2
  17. package/app/client/src/live/CounterDemo.tsx +12 -12
  18. package/app/client/src/live/FormDemo.tsx +2 -2
  19. package/app/client/src/live/LiveDebuggerPanel.tsx +779 -0
  20. package/app/client/src/live/RoomChatDemo.tsx +24 -16
  21. package/app/client/src/main.tsx +13 -13
  22. package/app/client/src/pages/ApiTestPage.tsx +6 -6
  23. package/app/client/src/pages/HomePage.tsx +80 -52
  24. package/app/server/live/LiveAdminPanel.ts +1 -0
  25. package/app/server/live/LiveChat.ts +78 -77
  26. package/app/server/live/LiveCounter.ts +1 -1
  27. package/app/server/live/LiveForm.ts +1 -0
  28. package/app/server/live/LiveLocalCounter.ts +38 -37
  29. package/app/server/live/LiveProtectedChat.ts +1 -0
  30. package/app/server/live/LiveRoomChat.ts +1 -0
  31. package/app/server/live/LiveUpload.ts +1 -0
  32. package/app/server/live/register-components.ts +19 -19
  33. package/config/system/runtime.config.ts +4 -0
  34. package/core/build/optimizer.ts +235 -235
  35. package/core/client/components/Live.tsx +17 -11
  36. package/core/client/components/LiveDebugger.tsx +1324 -0
  37. package/core/client/hooks/AdaptiveChunkSizer.ts +215 -215
  38. package/core/client/hooks/useLiveComponent.ts +11 -1
  39. package/core/client/hooks/useLiveDebugger.ts +392 -0
  40. package/core/client/index.ts +14 -0
  41. package/core/plugins/built-in/index.ts +134 -134
  42. package/core/plugins/built-in/live-components/commands/create-live-component.ts +4 -0
  43. package/core/plugins/built-in/vite/index.ts +75 -21
  44. package/core/server/index.ts +15 -15
  45. package/core/server/live/ComponentRegistry.ts +55 -26
  46. package/core/server/live/FileUploadManager.ts +188 -24
  47. package/core/server/live/LiveDebugger.ts +462 -0
  48. package/core/server/live/LiveLogger.ts +38 -5
  49. package/core/server/live/LiveRoomManager.ts +17 -1
  50. package/core/server/live/StateSignature.ts +87 -27
  51. package/core/server/live/WebSocketConnectionManager.ts +11 -10
  52. package/core/server/live/auto-generated-components.ts +1 -1
  53. package/core/server/live/websocket-plugin.ts +233 -8
  54. package/core/server/plugins/static-files-plugin.ts +179 -69
  55. package/core/types/build.ts +219 -219
  56. package/core/types/plugin.ts +107 -107
  57. package/core/types/types.ts +145 -9
  58. package/core/utils/logger/startup-banner.ts +82 -82
  59. package/core/utils/version.ts +6 -6
  60. package/package.json +1 -1
  61. package/app/client/src/assets/react.svg +0 -1
@@ -0,0 +1,1324 @@
1
+ // FluxStack Live Debugger - Draggable Floating Window
2
+ //
3
+ // A floating, draggable, resizable debug panel for inspecting Live Components.
4
+ // Toggle with Ctrl+Shift+D or click the small badge in the corner.
5
+ //
6
+ // Usage:
7
+ // import { LiveDebugger } from '@/core/client'
8
+ // <LiveDebugger />
9
+
10
+ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
11
+ import { useLiveDebugger, type DebugEvent, type DebugEventType, type ComponentSnapshot } from '../hooks/useLiveDebugger'
12
+
13
+ // ===== Debugger Settings =====
14
+
15
+ export interface DebuggerSettings {
16
+ fontSize: 'xs' | 'sm' | 'md' | 'lg'
17
+ showTimestamps: boolean
18
+ compactMode: boolean
19
+ wordWrap: boolean
20
+ maxEvents: number
21
+ }
22
+
23
+ const FONT_SIZES: Record<DebuggerSettings['fontSize'], number> = {
24
+ xs: 9,
25
+ sm: 10,
26
+ md: 11,
27
+ lg: 13,
28
+ }
29
+
30
+ const DEFAULT_SETTINGS: DebuggerSettings = {
31
+ fontSize: 'sm',
32
+ showTimestamps: true,
33
+ compactMode: false,
34
+ wordWrap: false,
35
+ maxEvents: 300,
36
+ }
37
+
38
+ const SETTINGS_KEY = 'fluxstack-debugger-settings'
39
+
40
+ function loadSettings(): DebuggerSettings {
41
+ try {
42
+ const stored = localStorage.getItem(SETTINGS_KEY)
43
+ if (stored) return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) }
44
+ } catch { /* ignore */ }
45
+ return { ...DEFAULT_SETTINGS }
46
+ }
47
+
48
+ function saveSettings(settings: DebuggerSettings) {
49
+ try {
50
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings))
51
+ } catch { /* ignore */ }
52
+ }
53
+
54
+ // ===== Event Type Groups =====
55
+
56
+ type EventGroup = 'lifecycle' | 'state' | 'actions' | 'rooms' | 'connection' | 'errors'
57
+
58
+ const EVENT_GROUPS: Record<EventGroup, { label: string; color: string; types: DebugEventType[] }> = {
59
+ lifecycle: {
60
+ label: 'Lifecycle',
61
+ color: '#22c55e',
62
+ types: ['COMPONENT_MOUNT', 'COMPONENT_UNMOUNT', 'COMPONENT_REHYDRATE'],
63
+ },
64
+ state: {
65
+ label: 'State',
66
+ color: '#3b82f6',
67
+ types: ['STATE_CHANGE'],
68
+ },
69
+ actions: {
70
+ label: 'Actions',
71
+ color: '#8b5cf6',
72
+ types: ['ACTION_CALL', 'ACTION_RESULT', 'ACTION_ERROR'],
73
+ },
74
+ rooms: {
75
+ label: 'Rooms',
76
+ color: '#10b981',
77
+ types: ['ROOM_JOIN', 'ROOM_LEAVE', 'ROOM_EMIT', 'ROOM_EVENT_RECEIVED'],
78
+ },
79
+ connection: {
80
+ label: 'WS',
81
+ color: '#06b6d4',
82
+ types: ['WS_CONNECT', 'WS_DISCONNECT'],
83
+ },
84
+ errors: {
85
+ label: 'Errors',
86
+ color: '#ef4444',
87
+ types: ['ERROR'],
88
+ },
89
+ }
90
+
91
+ const ALL_GROUPS = Object.keys(EVENT_GROUPS) as EventGroup[]
92
+
93
+ const COLORS: Record<string, string> = {
94
+ COMPONENT_MOUNT: '#22c55e',
95
+ COMPONENT_UNMOUNT: '#ef4444',
96
+ COMPONENT_REHYDRATE: '#f59e0b',
97
+ STATE_CHANGE: '#3b82f6',
98
+ ACTION_CALL: '#8b5cf6',
99
+ ACTION_RESULT: '#06b6d4',
100
+ ACTION_ERROR: '#ef4444',
101
+ ROOM_JOIN: '#10b981',
102
+ ROOM_LEAVE: '#f97316',
103
+ ROOM_EMIT: '#6366f1',
104
+ ROOM_EVENT_RECEIVED: '#6366f1',
105
+ WS_CONNECT: '#22c55e',
106
+ WS_DISCONNECT: '#ef4444',
107
+ ERROR: '#dc2626',
108
+ }
109
+
110
+ const LABELS: Record<string, string> = {
111
+ COMPONENT_MOUNT: 'MOUNT',
112
+ COMPONENT_UNMOUNT: 'UNMOUNT',
113
+ COMPONENT_REHYDRATE: 'REHYDRATE',
114
+ STATE_CHANGE: 'STATE',
115
+ ACTION_CALL: 'ACTION',
116
+ ACTION_RESULT: 'RESULT',
117
+ ACTION_ERROR: 'ERR',
118
+ ROOM_JOIN: 'JOIN',
119
+ ROOM_LEAVE: 'LEAVE',
120
+ ROOM_EMIT: 'EMIT',
121
+ ROOM_EVENT_RECEIVED: 'ROOM_EVT',
122
+ WS_CONNECT: 'CONNECT',
123
+ WS_DISCONNECT: 'DISCONNECT',
124
+ ERROR: 'ERROR',
125
+ }
126
+
127
+ // ===== Collapsible JSON Tree Viewer =====
128
+
129
+ function jsonPreview(data: unknown): string {
130
+ if (data === null || data === undefined) return String(data)
131
+ if (typeof data !== 'object') return JSON.stringify(data)
132
+ if (Array.isArray(data)) {
133
+ if (data.length === 0) return '[]'
134
+ const items = data.slice(0, 3).map(jsonPreview).join(', ')
135
+ return data.length <= 3 ? `[${items}]` : `[${items}, ...+${data.length - 3}]`
136
+ }
137
+ const entries = Object.entries(data as Record<string, unknown>)
138
+ if (entries.length === 0) return '{}'
139
+ const items = entries.slice(0, 3).map(([k, v]) => {
140
+ const val = typeof v === 'object' && v !== null
141
+ ? (Array.isArray(v) ? `[${v.length}]` : `{${Object.keys(v).length}}`)
142
+ : JSON.stringify(v)
143
+ return `${k}: ${val}`
144
+ }).join(', ')
145
+ return entries.length <= 3 ? `{ ${items} }` : `{ ${items}, ...+${entries.length - 3} }`
146
+ }
147
+
148
+ function isExpandable(data: unknown): boolean {
149
+ return data !== null && typeof data === 'object' && (
150
+ Array.isArray(data) ? data.length > 0 : Object.keys(data as object).length > 0
151
+ )
152
+ }
153
+
154
+ function JsonNode({ label, data, defaultOpen = false }: {
155
+ label?: string | number
156
+ data: unknown
157
+ defaultOpen?: boolean
158
+ }) {
159
+ const [open, setOpen] = useState(defaultOpen)
160
+ const expandable = isExpandable(data)
161
+
162
+ // Primitives
163
+ if (!expandable) {
164
+ let rendered: React.ReactNode
165
+ if (data === null || data === undefined) rendered = <span style={{ color: '#6b7280' }}>{String(data)}</span>
166
+ else if (typeof data === 'boolean') rendered = <span style={{ color: '#f59e0b' }}>{String(data)}</span>
167
+ else if (typeof data === 'number') rendered = <span style={{ color: '#60a5fa' }}>{data}</span>
168
+ else if (typeof data === 'string') {
169
+ const display = data.length > 120 ? data.slice(0, 120) + '...' : data
170
+ rendered = <span style={{ color: '#34d399' }}>"{display}"</span>
171
+ } else {
172
+ rendered = <span>{String(data)}</span>
173
+ }
174
+
175
+ return (
176
+ <div style={{ lineHeight: '1.6', paddingLeft: 2 }}>
177
+ {label !== undefined && (
178
+ <>
179
+ <span style={{ color: typeof label === 'number' ? '#60a5fa' : '#c084fc' }}>{label}</span>
180
+ <span style={{ color: '#6b7280' }}>: </span>
181
+ </>
182
+ )}
183
+ {rendered}
184
+ </div>
185
+ )
186
+ }
187
+
188
+ // Expandable (object / array)
189
+ const isArray = Array.isArray(data)
190
+ const entries = isArray
191
+ ? (data as unknown[]).map((v, i) => [i, v] as [number, unknown])
192
+ : Object.entries(data as Record<string, unknown>)
193
+ const bracketOpen = isArray ? '[' : '{'
194
+ const bracketClose = isArray ? ']' : '}'
195
+
196
+ return (
197
+ <div style={{ lineHeight: '1.6' }}>
198
+ <div
199
+ onClick={() => setOpen(!open)}
200
+ style={{ cursor: 'pointer', display: 'flex', alignItems: 'flex-start', gap: 2, paddingLeft: 2 }}
201
+ >
202
+ <span style={{
203
+ color: '#475569', fontSize: 8, width: 10, flexShrink: 0,
204
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
205
+ paddingTop: 4, userSelect: 'none',
206
+ }}>
207
+ {open ? '\u25BC' : '\u25B6'}
208
+ </span>
209
+ <span>
210
+ {label !== undefined && (
211
+ <>
212
+ <span style={{ color: typeof label === 'number' ? '#60a5fa' : '#c084fc' }}>{label}</span>
213
+ <span style={{ color: '#6b7280' }}>: </span>
214
+ </>
215
+ )}
216
+ {!open && (
217
+ <span style={{ color: '#64748b' }}>
218
+ {bracketOpen} {jsonPreview(data).slice(1, -1)} {bracketClose}
219
+ </span>
220
+ )}
221
+ {open && (
222
+ <span style={{ color: '#64748b' }}>
223
+ {bracketOpen}
224
+ <span style={{ color: '#475569', fontSize: 9, marginLeft: 4 }}>
225
+ {entries.length} {isArray ? (entries.length === 1 ? 'item' : 'items') : (entries.length === 1 ? 'key' : 'keys')}
226
+ </span>
227
+ </span>
228
+ )}
229
+ </span>
230
+ </div>
231
+ {open && (
232
+ <div style={{ paddingLeft: 14 }}>
233
+ {entries.map(([key, val]) => (
234
+ <JsonNode
235
+ key={String(key)}
236
+ label={key}
237
+ data={val}
238
+ defaultOpen={false}
239
+ />
240
+ ))}
241
+ <div style={{ color: '#64748b', paddingLeft: 2 }}>{bracketClose}</div>
242
+ </div>
243
+ )}
244
+ </div>
245
+ )
246
+ }
247
+
248
+ function Json({ data, depth = 0 }: { data: unknown; depth?: number }) {
249
+ return <JsonNode data={data} defaultOpen={depth === 0} />
250
+ }
251
+
252
+ // ===== Event Summary =====
253
+
254
+ function eventSummary(e: DebugEvent): string {
255
+ switch (e.type) {
256
+ case 'STATE_CHANGE': {
257
+ const delta = e.data.delta as Record<string, unknown> | undefined
258
+ if (!delta) return ''
259
+ const keys = Object.keys(delta)
260
+ if (keys.length <= 2) return keys.map(k => `${k}=${JSON.stringify(delta[k])}`).join(' ')
261
+ return `${keys.length} props`
262
+ }
263
+ case 'ACTION_CALL':
264
+ return String(e.data.action || '')
265
+ case 'ACTION_RESULT':
266
+ return `${e.data.action} ${e.data.duration}ms`
267
+ case 'ACTION_ERROR':
268
+ return `${e.data.action}: ${e.data.error}`
269
+ case 'ROOM_JOIN':
270
+ case 'ROOM_LEAVE':
271
+ return String(e.data.roomId || '')
272
+ case 'ROOM_EMIT':
273
+ return `${e.data.event} -> ${e.data.roomId}`
274
+ case 'ERROR':
275
+ return String(e.data.error || '')
276
+ default:
277
+ return ''
278
+ }
279
+ }
280
+
281
+ function displayName(comp: ComponentSnapshot): string {
282
+ return comp.debugLabel || comp.componentName
283
+ }
284
+
285
+ // ===== Drag Hook =====
286
+
287
+ function useDrag(
288
+ initialPos: { x: number; y: number },
289
+ onDragEnd?: (pos: { x: number; y: number }) => void
290
+ ) {
291
+ const [pos, setPos] = useState(initialPos)
292
+ const dragging = useRef(false)
293
+ const offset = useRef({ x: 0, y: 0 })
294
+
295
+ const onMouseDown = useCallback((e: React.MouseEvent) => {
296
+ // Only drag from title bar, not from buttons
297
+ if ((e.target as HTMLElement).closest('button, input, select')) return
298
+ dragging.current = true
299
+ offset.current = { x: e.clientX - pos.x, y: e.clientY - pos.y }
300
+ e.preventDefault()
301
+ }, [pos])
302
+
303
+ useEffect(() => {
304
+ const onMouseMove = (e: MouseEvent) => {
305
+ if (!dragging.current) return
306
+ const newX = Math.max(0, Math.min(window.innerWidth - 100, e.clientX - offset.current.x))
307
+ const newY = Math.max(0, Math.min(window.innerHeight - 40, e.clientY - offset.current.y))
308
+ setPos({ x: newX, y: newY })
309
+ }
310
+ const onMouseUp = () => {
311
+ if (dragging.current) {
312
+ dragging.current = false
313
+ onDragEnd?.(pos)
314
+ }
315
+ }
316
+ window.addEventListener('mousemove', onMouseMove)
317
+ window.addEventListener('mouseup', onMouseUp)
318
+ return () => {
319
+ window.removeEventListener('mousemove', onMouseMove)
320
+ window.removeEventListener('mouseup', onMouseUp)
321
+ }
322
+ }, [pos, onDragEnd])
323
+
324
+ return { pos, setPos, onMouseDown }
325
+ }
326
+
327
+ // ===== Resize Hook =====
328
+
329
+ function useResize(
330
+ initialSize: { w: number; h: number },
331
+ minSize = { w: 420, h: 300 }
332
+ ) {
333
+ const [size, setSize] = useState(initialSize)
334
+ const resizing = useRef(false)
335
+ const startData = useRef({ mouseX: 0, mouseY: 0, w: 0, h: 0 })
336
+
337
+ const onResizeStart = useCallback((e: React.MouseEvent) => {
338
+ resizing.current = true
339
+ startData.current = { mouseX: e.clientX, mouseY: e.clientY, w: size.w, h: size.h }
340
+ e.preventDefault()
341
+ e.stopPropagation()
342
+ }, [size])
343
+
344
+ useEffect(() => {
345
+ const onMouseMove = (e: MouseEvent) => {
346
+ if (!resizing.current) return
347
+ const dw = e.clientX - startData.current.mouseX
348
+ const dh = e.clientY - startData.current.mouseY
349
+ setSize({
350
+ w: Math.max(minSize.w, startData.current.w + dw),
351
+ h: Math.max(minSize.h, startData.current.h + dh),
352
+ })
353
+ }
354
+ const onMouseUp = () => { resizing.current = false }
355
+ window.addEventListener('mousemove', onMouseMove)
356
+ window.addEventListener('mouseup', onMouseUp)
357
+ return () => {
358
+ window.removeEventListener('mousemove', onMouseMove)
359
+ window.removeEventListener('mouseup', onMouseUp)
360
+ }
361
+ }, [minSize.w, minSize.h])
362
+
363
+ return { size, onResizeStart }
364
+ }
365
+
366
+ // ===== Component Card =====
367
+
368
+ function ComponentCard({
369
+ comp,
370
+ isSelected,
371
+ onSelect,
372
+ }: {
373
+ comp: ComponentSnapshot
374
+ isSelected: boolean
375
+ onSelect: () => void
376
+ }) {
377
+ return (
378
+ <button
379
+ onClick={onSelect}
380
+ style={{
381
+ width: '100%', textAlign: 'left', cursor: 'pointer',
382
+ padding: '6px 8px', borderRadius: 4, border: 'none',
383
+ background: isSelected ? '#1e293b' : 'transparent',
384
+ color: '#e2e8f0', fontFamily: 'monospace', fontSize: 11,
385
+ display: 'flex', flexDirection: 'column', gap: 1,
386
+ transition: 'background 0.15s',
387
+ }}
388
+ >
389
+ <div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
390
+ <span style={{ color: '#22c55e', fontSize: 6 }}>&#9679;</span>
391
+ <strong style={{ fontSize: 11 }}>{displayName(comp)}</strong>
392
+ </div>
393
+ {comp.debugLabel && (
394
+ <div style={{ fontSize: 9, color: '#64748b', paddingLeft: 11 }}>{comp.componentName}</div>
395
+ )}
396
+ <div style={{ display: 'flex', gap: 8, color: '#64748b', fontSize: 9, paddingLeft: 11 }}>
397
+ <span>S:{comp.stateChangeCount}</span>
398
+ <span>A:{comp.actionCount}</span>
399
+ {comp.errorCount > 0 && <span style={{ color: '#f87171' }}>E:{comp.errorCount}</span>}
400
+ </div>
401
+ </button>
402
+ )
403
+ }
404
+
405
+ // ===== Filter Bar =====
406
+
407
+ function FilterBar({
408
+ activeGroups,
409
+ toggleGroup,
410
+ search,
411
+ setSearch,
412
+ groupCounts,
413
+ }: {
414
+ activeGroups: Set<EventGroup>
415
+ toggleGroup: (g: EventGroup) => void
416
+ search: string
417
+ setSearch: (s: string) => void
418
+ groupCounts: Record<EventGroup, number>
419
+ }) {
420
+ return (
421
+ <div style={{
422
+ display: 'flex', alignItems: 'center', gap: 4,
423
+ padding: '4px 8px', borderBottom: '1px solid #1e293b',
424
+ flexShrink: 0, flexWrap: 'wrap',
425
+ }}>
426
+ {ALL_GROUPS.map(g => {
427
+ const group = EVENT_GROUPS[g]
428
+ const active = activeGroups.has(g)
429
+ const count = groupCounts[g] || 0
430
+ return (
431
+ <button
432
+ key={g}
433
+ onClick={() => toggleGroup(g)}
434
+ style={{
435
+ padding: '2px 6px', borderRadius: 3,
436
+ border: `1px solid ${active ? group.color + '60' : '#1e293b'}`,
437
+ cursor: 'pointer', fontFamily: 'monospace', fontSize: 9,
438
+ background: active ? group.color + '20' : 'transparent',
439
+ color: active ? group.color : '#475569',
440
+ transition: 'all 0.15s',
441
+ display: 'flex', alignItems: 'center', gap: 3,
442
+ }}
443
+ >
444
+ {group.label}
445
+ {count > 0 && (
446
+ <span style={{
447
+ fontSize: 8, color: active ? group.color : '#374151',
448
+ fontWeight: 600,
449
+ }}>
450
+ {count}
451
+ </span>
452
+ )}
453
+ </button>
454
+ )
455
+ })}
456
+ <div style={{ flex: 1 }} />
457
+ <input
458
+ type="text"
459
+ value={search}
460
+ onChange={e => setSearch(e.target.value)}
461
+ placeholder="Search..."
462
+ style={{
463
+ width: 120, padding: '2px 6px', borderRadius: 3,
464
+ border: '1px solid #1e293b', background: '#0f172a',
465
+ color: '#e2e8f0', fontFamily: 'monospace', fontSize: 10,
466
+ outline: 'none',
467
+ }}
468
+ onFocus={e => { e.target.style.borderColor = '#334155' }}
469
+ onBlur={e => { e.target.style.borderColor = '#1e293b' }}
470
+ />
471
+ </div>
472
+ )
473
+ }
474
+
475
+ // ===== Settings Panel =====
476
+
477
+ function SettingsPanel({
478
+ settings,
479
+ onChange,
480
+ }: {
481
+ settings: DebuggerSettings
482
+ onChange: (patch: Partial<DebuggerSettings>) => void
483
+ }) {
484
+ const sectionStyle: React.CSSProperties = {
485
+ marginBottom: 14,
486
+ }
487
+ const labelStyle: React.CSSProperties = {
488
+ fontFamily: 'monospace', fontSize: 9, color: '#64748b',
489
+ textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6,
490
+ display: 'block',
491
+ }
492
+ const rowStyle: React.CSSProperties = {
493
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
494
+ padding: '5px 0',
495
+ }
496
+ const descStyle: React.CSSProperties = {
497
+ fontFamily: 'monospace', fontSize: 10, color: '#94a3b8',
498
+ }
499
+
500
+ return (
501
+ <div style={{ flex: 1, overflow: 'auto', padding: 10 }}>
502
+ {/* Font Size */}
503
+ <div style={sectionStyle}>
504
+ <span style={labelStyle}>Font Size</span>
505
+ <div style={{ display: 'flex', gap: 4 }}>
506
+ {(['xs', 'sm', 'md', 'lg'] as const).map(size => (
507
+ <button
508
+ key={size}
509
+ onClick={() => onChange({ fontSize: size })}
510
+ style={{
511
+ flex: 1, padding: '5px 0', borderRadius: 4, border: 'none', cursor: 'pointer',
512
+ fontFamily: 'monospace', fontSize: FONT_SIZES[size], fontWeight: 600,
513
+ background: settings.fontSize === size ? '#1e3a5f' : '#1e293b',
514
+ color: settings.fontSize === size ? '#60a5fa' : '#64748b',
515
+ transition: 'all 0.15s',
516
+ }}
517
+ >
518
+ {size.toUpperCase()}
519
+ </button>
520
+ ))}
521
+ </div>
522
+ <div style={{
523
+ fontFamily: 'monospace', fontSize: 9, color: '#475569',
524
+ marginTop: 4, textAlign: 'center',
525
+ }}>
526
+ Preview: {FONT_SIZES[settings.fontSize]}px
527
+ </div>
528
+ </div>
529
+
530
+ {/* Max Events */}
531
+ <div style={sectionStyle}>
532
+ <span style={labelStyle}>Max Events in Buffer</span>
533
+ <div style={{ display: 'flex', gap: 4 }}>
534
+ {[100, 300, 500, 1000].map(n => (
535
+ <button
536
+ key={n}
537
+ onClick={() => onChange({ maxEvents: n })}
538
+ style={{
539
+ flex: 1, padding: '5px 0', borderRadius: 4, border: 'none', cursor: 'pointer',
540
+ fontFamily: 'monospace', fontSize: 10, fontWeight: 600,
541
+ background: settings.maxEvents === n ? '#1e3a5f' : '#1e293b',
542
+ color: settings.maxEvents === n ? '#60a5fa' : '#64748b',
543
+ transition: 'all 0.15s',
544
+ }}
545
+ >
546
+ {n}
547
+ </button>
548
+ ))}
549
+ </div>
550
+ </div>
551
+
552
+ {/* Toggles */}
553
+ <div style={sectionStyle}>
554
+ <span style={labelStyle}>Display</span>
555
+
556
+ <div style={rowStyle}>
557
+ <span style={descStyle}>Show timestamps</span>
558
+ <ToggleSwitch
559
+ checked={settings.showTimestamps}
560
+ onChange={v => onChange({ showTimestamps: v })}
561
+ />
562
+ </div>
563
+
564
+ <div style={rowStyle}>
565
+ <span style={descStyle}>Compact mode</span>
566
+ <ToggleSwitch
567
+ checked={settings.compactMode}
568
+ onChange={v => onChange({ compactMode: v })}
569
+ />
570
+ </div>
571
+
572
+ <div style={rowStyle}>
573
+ <span style={descStyle}>Word wrap in data</span>
574
+ <ToggleSwitch
575
+ checked={settings.wordWrap}
576
+ onChange={v => onChange({ wordWrap: v })}
577
+ />
578
+ </div>
579
+ </div>
580
+
581
+ {/* Reset */}
582
+ <button
583
+ onClick={() => onChange(DEFAULT_SETTINGS)}
584
+ style={{
585
+ width: '100%', padding: '6px 0', borderRadius: 4, border: '1px solid #1e293b',
586
+ cursor: 'pointer', fontFamily: 'monospace', fontSize: 10,
587
+ background: 'transparent', color: '#64748b',
588
+ transition: 'all 0.15s',
589
+ }}
590
+ >
591
+ Reset to defaults
592
+ </button>
593
+ </div>
594
+ )
595
+ }
596
+
597
+ function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
598
+ return (
599
+ <button
600
+ onClick={() => onChange(!checked)}
601
+ style={{
602
+ width: 32, height: 18, borderRadius: 9, border: 'none', cursor: 'pointer',
603
+ background: checked ? '#2563eb' : '#334155',
604
+ position: 'relative', padding: 0, transition: 'background 0.2s',
605
+ flexShrink: 0,
606
+ }}
607
+ >
608
+ <span style={{
609
+ position: 'absolute', top: 2, left: checked ? 16 : 2,
610
+ width: 14, height: 14, borderRadius: '50%',
611
+ background: '#fff', transition: 'left 0.2s',
612
+ boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
613
+ }} />
614
+ </button>
615
+ )
616
+ }
617
+
618
+ // ===== Main Component =====
619
+
620
+ export interface LiveDebuggerProps {
621
+ /** Start open. Default: false */
622
+ defaultOpen?: boolean
623
+ /** Initial position. Default: bottom-right corner */
624
+ defaultPosition?: { x: number; y: number }
625
+ /** Initial size. Default: 680x420 */
626
+ defaultSize?: { w: number; h: number }
627
+ /** Force enable even in production. Default: false */
628
+ force?: boolean
629
+ }
630
+
631
+ export function LiveDebugger({
632
+ defaultOpen = false,
633
+ defaultPosition,
634
+ defaultSize = { w: 680, h: 420 },
635
+ force = false,
636
+ }: LiveDebuggerProps) {
637
+ const [settings, setSettingsState] = useState<DebuggerSettings>(loadSettings)
638
+ const updateSettings = useCallback((patch: Partial<DebuggerSettings>) => {
639
+ setSettingsState(prev => {
640
+ const next = { ...prev, ...patch }
641
+ saveSettings(next)
642
+ return next
643
+ })
644
+ }, [])
645
+ const fs = FONT_SIZES[settings.fontSize]
646
+
647
+ const dbg = useLiveDebugger({ maxEvents: settings.maxEvents })
648
+ const [open, setOpen] = useState(defaultOpen)
649
+ const [tab, setTab] = useState<'events' | 'state' | 'rooms' | 'settings'>('events')
650
+ const [selectedId, setSelectedId] = useState<string | null>(null)
651
+ const [expandedEvents, setExpandedEvents] = useState<Set<string>>(new Set())
652
+ const [activeGroups, setActiveGroups] = useState<Set<EventGroup>>(new Set(ALL_GROUPS))
653
+ const [search, setSearch] = useState('')
654
+ const feedRef = useRef<HTMLDivElement>(null)
655
+ const [autoScroll, setAutoScroll] = useState(true)
656
+
657
+ // Default position: bottom-right with padding
658
+ const initPos = defaultPosition ?? {
659
+ x: typeof window !== 'undefined' ? window.innerWidth - defaultSize.w - 16 : 100,
660
+ y: typeof window !== 'undefined' ? window.innerHeight - defaultSize.h - 16 : 100,
661
+ }
662
+
663
+ const { pos, onMouseDown } = useDrag(initPos)
664
+ const { size, onResizeStart } = useResize(defaultSize)
665
+
666
+ // Keyboard shortcut: Ctrl+Shift+D
667
+ useEffect(() => {
668
+ const handler = (e: KeyboardEvent) => {
669
+ if (e.ctrlKey && e.shiftKey && e.key === 'D') {
670
+ e.preventDefault()
671
+ setOpen(prev => !prev)
672
+ }
673
+ }
674
+ window.addEventListener('keydown', handler)
675
+ return () => window.removeEventListener('keydown', handler)
676
+ }, [])
677
+
678
+ const selectedComp = selectedId
679
+ ? dbg.components.find(c => c.componentId === selectedId) ?? null
680
+ : null
681
+
682
+ // Compute allowed event types from active groups
683
+ const allowedTypes = useMemo(() => {
684
+ const types = new Set<DebugEventType>()
685
+ for (const g of activeGroups) {
686
+ for (const t of EVENT_GROUPS[g].types) types.add(t)
687
+ }
688
+ return types
689
+ }, [activeGroups])
690
+
691
+ // Filter events
692
+ const visibleEvents = useMemo(() => {
693
+ return dbg.events.filter(e => {
694
+ if (!allowedTypes.has(e.type)) return false
695
+ if (selectedId && e.componentId !== selectedId) return false
696
+ if (search) {
697
+ const s = search.toLowerCase()
698
+ const inData = JSON.stringify(e.data).toLowerCase().includes(s)
699
+ const inName = e.componentName?.toLowerCase().includes(s)
700
+ const inType = e.type.toLowerCase().includes(s)
701
+ if (!inData && !inName && !inType) return false
702
+ }
703
+ return true
704
+ })
705
+ }, [dbg.events, allowedTypes, selectedId, search])
706
+
707
+ // Count events per group (unfiltered by group, but filtered by component)
708
+ const groupCounts = useMemo(() => {
709
+ const counts = {} as Record<EventGroup, number>
710
+ for (const g of ALL_GROUPS) counts[g] = 0
711
+ const baseEvents = selectedId
712
+ ? dbg.events.filter(e => e.componentId === selectedId)
713
+ : dbg.events
714
+ for (const e of baseEvents) {
715
+ for (const g of ALL_GROUPS) {
716
+ if (EVENT_GROUPS[g].types.includes(e.type)) {
717
+ counts[g]++
718
+ break
719
+ }
720
+ }
721
+ }
722
+ return counts
723
+ }, [dbg.events, selectedId])
724
+
725
+ // Build rooms map: roomId -> list of components in that room
726
+ const roomsMap = useMemo(() => {
727
+ const map = new Map<string, ComponentSnapshot[]>()
728
+ for (const comp of dbg.components) {
729
+ for (const roomId of comp.rooms) {
730
+ if (!map.has(roomId)) map.set(roomId, [])
731
+ map.get(roomId)!.push(comp)
732
+ }
733
+ }
734
+ return map
735
+ }, [dbg.components])
736
+
737
+ // Room events (filtered to room-related types)
738
+ const roomEvents = useMemo(() => {
739
+ return dbg.events.filter(e =>
740
+ e.type === 'ROOM_JOIN' || e.type === 'ROOM_LEAVE' ||
741
+ e.type === 'ROOM_EMIT' || e.type === 'ROOM_EVENT_RECEIVED'
742
+ ).slice(-50)
743
+ }, [dbg.events])
744
+
745
+ // Auto-scroll feed
746
+ useEffect(() => {
747
+ if (feedRef.current && autoScroll && !dbg.paused) {
748
+ feedRef.current.scrollTop = feedRef.current.scrollHeight
749
+ }
750
+ }, [visibleEvents.length, dbg.paused, autoScroll])
751
+
752
+ // Detect manual scroll to disable auto-scroll
753
+ const handleFeedScroll = useCallback(() => {
754
+ if (!feedRef.current) return
755
+ const { scrollTop, scrollHeight, clientHeight } = feedRef.current
756
+ const atBottom = scrollHeight - scrollTop - clientHeight < 30
757
+ setAutoScroll(atBottom)
758
+ }, [])
759
+
760
+ const toggleEvent = useCallback((id: string) => {
761
+ setExpandedEvents(prev => {
762
+ const next = new Set(prev)
763
+ if (next.has(id)) next.delete(id)
764
+ else next.add(id)
765
+ return next
766
+ })
767
+ }, [])
768
+
769
+ const toggleGroup = useCallback((g: EventGroup) => {
770
+ setActiveGroups(prev => {
771
+ const next = new Set(prev)
772
+ if (next.has(g)) next.delete(g)
773
+ else next.add(g)
774
+ return next
775
+ })
776
+ }, [])
777
+
778
+ // Server has debugging disabled — render nothing, no resources used
779
+ if (dbg.serverDisabled && !force) return null
780
+
781
+ // Badge (when closed) - small floating circle in bottom-right
782
+ if (!open) {
783
+ const hasErrors = dbg.events.some(e => e.type === 'ERROR' || e.type === 'ACTION_ERROR')
784
+ return (
785
+ <button
786
+ onClick={() => setOpen(true)}
787
+ title="Live Debugger (Ctrl+Shift+D)"
788
+ style={{
789
+ position: 'fixed',
790
+ bottom: 16,
791
+ right: 16,
792
+ zIndex: 99999,
793
+ width: 40,
794
+ height: 40,
795
+ borderRadius: '50%',
796
+ border: `2px solid ${dbg.connected ? '#22c55e50' : '#ef444450'}`,
797
+ background: '#020617e0',
798
+ color: dbg.connected ? '#22c55e' : '#ef4444',
799
+ cursor: 'pointer',
800
+ display: 'flex',
801
+ alignItems: 'center',
802
+ justifyContent: 'center',
803
+ fontSize: 16,
804
+ boxShadow: '0 2px 12px rgba(0,0,0,0.4)',
805
+ transition: 'all 0.2s',
806
+ fontFamily: 'monospace',
807
+ padding: 0,
808
+ }}
809
+ >
810
+ {hasErrors ? (
811
+ <span style={{ color: '#ef4444', fontSize: 14, fontWeight: 700 }}>!</span>
812
+ ) : (
813
+ <span style={{ fontSize: 14 }}>{dbg.componentCount}</span>
814
+ )}
815
+ </button>
816
+ )
817
+ }
818
+
819
+ // Floating window
820
+ return (
821
+ <div
822
+ style={{
823
+ position: 'fixed',
824
+ left: pos.x,
825
+ top: pos.y,
826
+ width: size.w,
827
+ height: size.h,
828
+ zIndex: 99999,
829
+ display: 'flex',
830
+ flexDirection: 'column',
831
+ background: '#020617',
832
+ border: '1px solid #1e293b',
833
+ borderRadius: 8,
834
+ boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
835
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
836
+ color: '#e2e8f0',
837
+ overflow: 'hidden',
838
+ }}
839
+ >
840
+ {/* Title bar - draggable */}
841
+ <div
842
+ onMouseDown={onMouseDown}
843
+ style={{
844
+ display: 'flex', alignItems: 'center', gap: 8,
845
+ padding: '5px 10px', borderBottom: '1px solid #1e293b',
846
+ flexShrink: 0, cursor: 'grab', userSelect: 'none',
847
+ background: '#0f172a',
848
+ }}
849
+ >
850
+ {/* Status dot */}
851
+ <span style={{
852
+ width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
853
+ background: dbg.connected ? '#22c55e' : '#ef4444',
854
+ }} />
855
+
856
+ <span style={{
857
+ fontFamily: 'monospace', fontSize: 11, fontWeight: 700,
858
+ letterSpacing: 0.5, color: '#94a3b8',
859
+ }}>
860
+ LIVE DEBUGGER
861
+ </span>
862
+
863
+ {/* Tabs */}
864
+ <div style={{ display: 'flex', gap: 1, marginLeft: 4 }}>
865
+ {(['events', 'state', 'rooms', 'settings'] as const).map(t => (
866
+ <button
867
+ key={t}
868
+ onClick={() => setTab(t)}
869
+ style={{
870
+ padding: '2px 8px', borderRadius: 3, border: 'none', cursor: 'pointer',
871
+ fontFamily: 'monospace', fontSize: 10, textTransform: 'uppercase',
872
+ background: tab === t ? '#1e293b' : 'transparent',
873
+ color: tab === t
874
+ ? (t === 'settings' ? '#f59e0b' : '#e2e8f0')
875
+ : '#475569',
876
+ }}
877
+ >
878
+ {t === 'settings' ? '\u2699' : t}
879
+ </button>
880
+ ))}
881
+ </div>
882
+
883
+ <div style={{ flex: 1 }} />
884
+
885
+ {/* Controls */}
886
+ <button
887
+ onClick={dbg.togglePause}
888
+ title={dbg.paused ? 'Resume' : 'Pause'}
889
+ style={{
890
+ padding: '2px 6px', borderRadius: 3, border: 'none', cursor: 'pointer',
891
+ fontFamily: 'monospace', fontSize: 10,
892
+ background: dbg.paused ? '#7c2d12' : '#1e293b',
893
+ color: dbg.paused ? '#fdba74' : '#94a3b8',
894
+ }}
895
+ >
896
+ {dbg.paused ? '\u25B6' : '\u23F8'}
897
+ </button>
898
+ <button
899
+ onClick={dbg.clearEvents}
900
+ title="Clear events"
901
+ style={{
902
+ padding: '2px 6px', borderRadius: 3, border: 'none', cursor: 'pointer',
903
+ fontFamily: 'monospace', fontSize: 10,
904
+ background: '#1e293b', color: '#94a3b8',
905
+ }}
906
+ >
907
+ Clear
908
+ </button>
909
+ <span style={{ fontFamily: 'monospace', fontSize: 9, color: '#475569' }}>
910
+ {dbg.componentCount}C {visibleEvents.length}/{dbg.eventCount}E
911
+ </span>
912
+ <button
913
+ onClick={() => setOpen(false)}
914
+ title="Close (Ctrl+Shift+D)"
915
+ style={{
916
+ width: 18, height: 18, borderRadius: 3, border: 'none', cursor: 'pointer',
917
+ fontFamily: 'monospace', fontSize: 12, lineHeight: '18px',
918
+ background: 'transparent', color: '#64748b',
919
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
920
+ padding: 0,
921
+ }}
922
+ >
923
+ &#x2715;
924
+ </button>
925
+ </div>
926
+
927
+ {/* Body */}
928
+ <div style={{ display: 'flex', flex: 1, overflow: 'hidden', minHeight: 0 }}>
929
+ {/* Component sidebar */}
930
+ <div style={{
931
+ width: 160, borderRight: '1px solid #1e293b',
932
+ overflow: 'auto', flexShrink: 0, padding: '3px',
933
+ }}>
934
+ <button
935
+ onClick={() => setSelectedId(null)}
936
+ style={{
937
+ width: '100%', textAlign: 'left', cursor: 'pointer',
938
+ padding: '5px 8px', borderRadius: 4, border: 'none',
939
+ background: selectedId === null ? '#1e293b' : 'transparent',
940
+ color: '#94a3b8', fontFamily: 'monospace', fontSize: 10,
941
+ }}
942
+ >
943
+ All ({dbg.componentCount})
944
+ </button>
945
+
946
+ {dbg.components.map(comp => (
947
+ <ComponentCard
948
+ key={comp.componentId}
949
+ comp={comp}
950
+ isSelected={selectedId === comp.componentId}
951
+ onSelect={() => {
952
+ setSelectedId(selectedId === comp.componentId ? null : comp.componentId)
953
+ }}
954
+ />
955
+ ))}
956
+
957
+ {dbg.components.length === 0 && (
958
+ <div style={{
959
+ padding: 10, textAlign: 'center', color: '#475569',
960
+ fontFamily: 'monospace', fontSize: 10,
961
+ }}>
962
+ No components
963
+ </div>
964
+ )}
965
+ </div>
966
+
967
+ {/* Main content */}
968
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 0 }}>
969
+ {/* === Events Tab === */}
970
+ {tab === 'events' && (
971
+ <>
972
+ <FilterBar
973
+ activeGroups={activeGroups}
974
+ toggleGroup={toggleGroup}
975
+ search={search}
976
+ setSearch={setSearch}
977
+ groupCounts={groupCounts}
978
+ />
979
+ <div
980
+ ref={feedRef}
981
+ onScroll={handleFeedScroll}
982
+ style={{ flex: 1, overflow: 'auto' }}
983
+ >
984
+ {visibleEvents.length === 0 ? (
985
+ <div style={{
986
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
987
+ height: '100%', color: '#475569', fontFamily: 'monospace', fontSize: 11,
988
+ }}>
989
+ {dbg.connected
990
+ ? dbg.paused ? 'Paused' : 'Waiting for events...'
991
+ : 'Connecting...'}
992
+ </div>
993
+ ) : (
994
+ visibleEvents.map(event => {
995
+ const color = COLORS[event.type] || '#6b7280'
996
+ const label = LABELS[event.type] || event.type
997
+ const summary = eventSummary(event)
998
+ const isExpanded = expandedEvents.has(event.id)
999
+ const time = new Date(event.timestamp)
1000
+ const ts = `${time.toLocaleTimeString('en-US', { hour12: false })}.${String(time.getMilliseconds()).padStart(3, '0')}`
1001
+ const py = settings.compactMode ? 1 : 3
1002
+
1003
+ return (
1004
+ <div
1005
+ key={event.id}
1006
+ style={{ borderBottom: '1px solid #0f172a', cursor: 'pointer' }}
1007
+ onClick={() => toggleEvent(event.id)}
1008
+ >
1009
+ <div style={{
1010
+ display: 'flex', alignItems: 'center', gap: 5,
1011
+ padding: `${py}px 8px`, fontSize: fs, fontFamily: 'monospace',
1012
+ background: isExpanded ? '#0f172a' : 'transparent',
1013
+ }}>
1014
+ {settings.showTimestamps && (
1015
+ <span style={{ color: '#4b5563', fontSize: fs - 1, flexShrink: 0 }}>{ts}</span>
1016
+ )}
1017
+ <span style={{
1018
+ display: 'inline-block', padding: '0 4px', borderRadius: 2,
1019
+ fontSize: Math.max(8, fs - 2), fontWeight: 700, color: '#fff',
1020
+ background: color, flexShrink: 0, lineHeight: `${fs + 4}px`,
1021
+ }}>
1022
+ {label}
1023
+ </span>
1024
+ {!selectedId && event.componentName && (() => {
1025
+ const comp = dbg.components.find(c => c.componentId === event.componentId)
1026
+ const name = comp?.debugLabel || event.componentName
1027
+ return (
1028
+ <span style={{ color: '#64748b', flexShrink: 0, fontSize: fs - 1 }}>
1029
+ {name}
1030
+ </span>
1031
+ )
1032
+ })()}
1033
+ <span style={{
1034
+ color: '#94a3b8', fontSize: fs - 1, overflow: 'hidden',
1035
+ textOverflow: 'ellipsis',
1036
+ whiteSpace: settings.wordWrap ? 'normal' : 'nowrap',
1037
+ flex: 1,
1038
+ }}>
1039
+ {summary}
1040
+ </span>
1041
+ </div>
1042
+ {isExpanded && (
1043
+ <div
1044
+ onClick={e => e.stopPropagation()}
1045
+ style={{
1046
+ padding: '3px 8px 6px 42px', fontSize: fs - 1,
1047
+ fontFamily: 'monospace', color: '#cbd5e1',
1048
+ background: '#0f172a',
1049
+ wordBreak: settings.wordWrap ? 'break-all' : undefined,
1050
+ }}>
1051
+ <Json data={event.data} />
1052
+ </div>
1053
+ )}
1054
+ </div>
1055
+ )
1056
+ })
1057
+ )}
1058
+ {!autoScroll && visibleEvents.length > 0 && (
1059
+ <button
1060
+ onClick={() => {
1061
+ setAutoScroll(true)
1062
+ if (feedRef.current) feedRef.current.scrollTop = feedRef.current.scrollHeight
1063
+ }}
1064
+ style={{
1065
+ position: 'sticky', bottom: 4, left: '50%',
1066
+ transform: 'translateX(-50%)',
1067
+ padding: '3px 10px', borderRadius: 10,
1068
+ border: '1px solid #1e293b',
1069
+ background: '#0f172ae0', color: '#94a3b8',
1070
+ fontFamily: 'monospace', fontSize: 9, cursor: 'pointer',
1071
+ }}
1072
+ >
1073
+ &#8595; Scroll to bottom
1074
+ </button>
1075
+ )}
1076
+ </div>
1077
+ </>
1078
+ )}
1079
+
1080
+ {/* === State Tab === */}
1081
+ {tab === 'state' && (
1082
+ <div style={{ flex: 1, overflow: 'auto', padding: 10 }}>
1083
+ {selectedComp ? (
1084
+ <div>
1085
+ <div style={{
1086
+ fontFamily: 'monospace', fontSize: 12, fontWeight: 700,
1087
+ marginBottom: 2, color: '#f1f5f9',
1088
+ }}>
1089
+ {displayName(selectedComp)}
1090
+ </div>
1091
+ {selectedComp.debugLabel && (
1092
+ <div style={{
1093
+ fontFamily: 'monospace', fontSize: 9, color: '#64748b',
1094
+ marginBottom: 6,
1095
+ }}>
1096
+ {selectedComp.componentName}
1097
+ </div>
1098
+ )}
1099
+
1100
+ <div style={{
1101
+ display: 'flex', gap: 4, marginBottom: 10, flexWrap: 'wrap',
1102
+ fontFamily: 'monospace', fontSize: 9,
1103
+ }}>
1104
+ {[
1105
+ { label: 'state', value: selectedComp.stateChangeCount, color: '#3b82f6' },
1106
+ { label: 'actions', value: selectedComp.actionCount, color: '#8b5cf6' },
1107
+ { label: 'errors', value: selectedComp.errorCount, color: selectedComp.errorCount > 0 ? '#ef4444' : '#22c55e' },
1108
+ ].map(s => (
1109
+ <span key={s.label} style={{
1110
+ padding: '2px 6px', borderRadius: 3, background: '#1e293b',
1111
+ }}>
1112
+ <span style={{ color: s.color }}>{s.value}</span>
1113
+ <span style={{ color: '#64748b' }}> {s.label}</span>
1114
+ </span>
1115
+ ))}
1116
+ {selectedComp.rooms.length > 0 && (
1117
+ <span style={{
1118
+ padding: '2px 6px', borderRadius: 3, background: '#1e293b', color: '#64748b',
1119
+ }}>
1120
+ rooms: {selectedComp.rooms.join(', ')}
1121
+ </span>
1122
+ )}
1123
+ </div>
1124
+
1125
+ <div style={{
1126
+ fontFamily: 'monospace', fontSize: 9, color: '#475569', marginBottom: 8,
1127
+ }}>
1128
+ {selectedComp.componentId}
1129
+ </div>
1130
+
1131
+ <div style={{
1132
+ fontFamily: 'monospace', fontSize: 9, color: '#64748b',
1133
+ textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 4,
1134
+ }}>
1135
+ Current State
1136
+ </div>
1137
+ <div style={{
1138
+ padding: 8, background: '#0f172a', borderRadius: 4,
1139
+ fontFamily: 'monospace', fontSize: fs, color: '#e2e8f0',
1140
+ overflow: 'auto',
1141
+ wordBreak: settings.wordWrap ? 'break-all' : undefined,
1142
+ }}>
1143
+ <Json data={selectedComp.state} />
1144
+ </div>
1145
+ </div>
1146
+ ) : (
1147
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
1148
+ {dbg.components.length === 0 ? (
1149
+ <div style={{
1150
+ textAlign: 'center', color: '#475569',
1151
+ fontFamily: 'monospace', fontSize: 11, padding: 20,
1152
+ }}>
1153
+ No active components
1154
+ </div>
1155
+ ) : (
1156
+ dbg.components.map(comp => (
1157
+ <div key={comp.componentId} style={{
1158
+ padding: 8, background: '#0f172a', borderRadius: 4,
1159
+ }}>
1160
+ <div style={{
1161
+ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4,
1162
+ }}>
1163
+ <span style={{
1164
+ fontFamily: 'monospace', fontSize: 11, fontWeight: 700, color: '#f1f5f9',
1165
+ }}>
1166
+ {displayName(comp)}
1167
+ </span>
1168
+ <span style={{ fontFamily: 'monospace', fontSize: 9, color: '#475569' }}>
1169
+ S:{comp.stateChangeCount} A:{comp.actionCount}
1170
+ {comp.errorCount > 0 && <span style={{ color: '#f87171' }}> E:{comp.errorCount}</span>}
1171
+ </span>
1172
+ </div>
1173
+ <div style={{ fontFamily: 'monospace', fontSize: fs, color: '#e2e8f0' }}>
1174
+ <Json data={comp.state} />
1175
+ </div>
1176
+ </div>
1177
+ ))
1178
+ )}
1179
+ </div>
1180
+ )}
1181
+ </div>
1182
+ )}
1183
+
1184
+ {/* === Rooms Tab === */}
1185
+ {tab === 'rooms' && (
1186
+ <div style={{ flex: 1, overflow: 'auto', padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
1187
+ {roomsMap.size === 0 ? (
1188
+ <div style={{
1189
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
1190
+ height: '100%', color: '#475569', fontFamily: 'monospace', fontSize: 11,
1191
+ }}>
1192
+ No active rooms
1193
+ </div>
1194
+ ) : (
1195
+ <>
1196
+ {/* Room cards */}
1197
+ {Array.from(roomsMap.entries()).map(([roomId, members]) => (
1198
+ <div key={roomId} style={{
1199
+ padding: 10, background: '#0f172a', borderRadius: 6,
1200
+ border: '1px solid #1e293b',
1201
+ }}>
1202
+ {/* Room header */}
1203
+ <div style={{
1204
+ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
1205
+ }}>
1206
+ <span style={{
1207
+ width: 8, height: 8, borderRadius: '50%',
1208
+ background: '#10b981', flexShrink: 0,
1209
+ }} />
1210
+ <span style={{
1211
+ fontFamily: 'monospace', fontSize: 12, fontWeight: 700, color: '#f1f5f9',
1212
+ }}>
1213
+ {roomId}
1214
+ </span>
1215
+ <span style={{
1216
+ fontFamily: 'monospace', fontSize: 9, color: '#64748b',
1217
+ padding: '1px 6px', borderRadius: 3, background: '#1e293b',
1218
+ }}>
1219
+ {members.length} {members.length === 1 ? 'member' : 'members'}
1220
+ </span>
1221
+ </div>
1222
+
1223
+ {/* Members list */}
1224
+ <div style={{
1225
+ display: 'flex', flexDirection: 'column', gap: 4,
1226
+ paddingLeft: 16,
1227
+ }}>
1228
+ {members.map(comp => (
1229
+ <div key={comp.componentId} style={{
1230
+ display: 'flex', alignItems: 'center', gap: 6,
1231
+ fontFamily: 'monospace', fontSize: 10,
1232
+ }}>
1233
+ <span style={{ color: '#22c55e', fontSize: 5 }}>&#9679;</span>
1234
+ <span style={{ color: '#e2e8f0' }}>{displayName(comp)}</span>
1235
+ {comp.debugLabel && (
1236
+ <span style={{ color: '#475569', fontSize: 9 }}>({comp.componentName})</span>
1237
+ )}
1238
+ <span style={{ color: '#475569', fontSize: 9 }}>
1239
+ S:{comp.stateChangeCount} A:{comp.actionCount}
1240
+ </span>
1241
+ </div>
1242
+ ))}
1243
+ </div>
1244
+ </div>
1245
+ ))}
1246
+
1247
+ {/* Recent room events */}
1248
+ {roomEvents.length > 0 && (
1249
+ <div style={{ marginTop: 4 }}>
1250
+ <div style={{
1251
+ fontFamily: 'monospace', fontSize: 9, color: '#64748b',
1252
+ textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6,
1253
+ }}>
1254
+ Recent Room Activity
1255
+ </div>
1256
+ <div style={{
1257
+ background: '#0f172a', borderRadius: 4, border: '1px solid #1e293b',
1258
+ overflow: 'auto', maxHeight: 160,
1259
+ }}>
1260
+ {roomEvents.map(event => {
1261
+ const color = COLORS[event.type] || '#6b7280'
1262
+ const label = LABELS[event.type] || event.type
1263
+ const summary = eventSummary(event)
1264
+ const time = new Date(event.timestamp)
1265
+ const ts = `${time.toLocaleTimeString('en-US', { hour12: false })}.${String(time.getMilliseconds()).padStart(3, '0')}`
1266
+ const comp = dbg.components.find(c => c.componentId === event.componentId)
1267
+ const name = comp?.debugLabel || event.componentName
1268
+
1269
+ return (
1270
+ <div key={event.id} style={{
1271
+ display: 'flex', alignItems: 'center', gap: 5,
1272
+ padding: '2px 8px', fontSize: 10, fontFamily: 'monospace',
1273
+ borderBottom: '1px solid #020617',
1274
+ }}>
1275
+ <span style={{ color: '#4b5563', fontSize: 9, flexShrink: 0 }}>{ts}</span>
1276
+ <span style={{
1277
+ display: 'inline-block', padding: '0 4px', borderRadius: 2,
1278
+ fontSize: 8, fontWeight: 700, color: '#fff',
1279
+ background: color, flexShrink: 0, lineHeight: '14px',
1280
+ }}>
1281
+ {label}
1282
+ </span>
1283
+ {name && <span style={{ color: '#64748b', fontSize: 9 }}>{name}</span>}
1284
+ <span style={{
1285
+ color: '#94a3b8', fontSize: 9, overflow: 'hidden',
1286
+ textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1,
1287
+ }}>
1288
+ {summary}
1289
+ </span>
1290
+ </div>
1291
+ )
1292
+ })}
1293
+ </div>
1294
+ </div>
1295
+ )}
1296
+ </>
1297
+ )}
1298
+ </div>
1299
+ )}
1300
+
1301
+ {/* === Settings Tab === */}
1302
+ {tab === 'settings' && (
1303
+ <SettingsPanel settings={settings} onChange={updateSettings} />
1304
+ )}
1305
+ </div>
1306
+ </div>
1307
+
1308
+ {/* Resize handle - bottom-right corner */}
1309
+ <div
1310
+ onMouseDown={onResizeStart}
1311
+ style={{
1312
+ position: 'absolute', bottom: 0, right: 0,
1313
+ width: 14, height: 14, cursor: 'nwse-resize',
1314
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
1315
+ }}
1316
+ >
1317
+ <svg width="8" height="8" viewBox="0 0 8 8" style={{ opacity: 0.3 }}>
1318
+ <line x1="7" y1="1" x2="1" y2="7" stroke="#94a3b8" strokeWidth="1" />
1319
+ <line x1="7" y1="4" x2="4" y2="7" stroke="#94a3b8" strokeWidth="1" />
1320
+ </svg>
1321
+ </div>
1322
+ </div>
1323
+ )
1324
+ }