@neat.is/web 0.2.10 → 0.3.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.
@@ -6,6 +6,8 @@ import { Rail } from './Rail'
6
6
  import { GraphCanvas } from './GraphCanvas'
7
7
  import { Inspector } from './Inspector'
8
8
  import { StatusBar } from './StatusBar'
9
+ import { DebugPanel } from './DebugPanel'
10
+ import { Toaster } from './Toaster'
9
11
  import type { GraphNode, GraphEdge } from '@neat.is/types'
10
12
 
11
13
  export interface GraphData {
@@ -36,10 +38,12 @@ export function AppShell() {
36
38
  const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
37
39
  const [graphData, setGraphData] = useState<GraphData | null>(null)
38
40
  // ADR-057 #2 — start with URL or localStorage (synchronous), then resolve
39
- // against /projects on mount if neither was set.
41
+ // against /projects on mount if neither was set. Safe because AppShell
42
+ // mounts client-only via dynamic({ ssr: false }) in app/page.tsx (ADR-062).
40
43
  const [project, setProjectState] = useState<string>(() => {
41
44
  return readUrlProject() ?? readStoredProject() ?? 'default'
42
45
  })
46
+ const [debugOpen, setDebugOpen] = useState(false)
43
47
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
48
  const cyRef = useRef<any>(null)
45
49
  const resolvedRef = useRef(readUrlProject() !== null || readStoredProject() !== null)
@@ -85,6 +89,18 @@ export function AppShell() {
85
89
  if (nodeId) setSelectedNodeId(nodeId)
86
90
  }, [])
87
91
 
92
+ // ADR-058 #4 — Ctrl+Shift+D / Cmd+Shift+D toggles the debug panel.
93
+ useEffect(() => {
94
+ const handler = (e: KeyboardEvent) => {
95
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'D' || e.key === 'd')) {
96
+ e.preventDefault()
97
+ setDebugOpen((v) => !v)
98
+ }
99
+ }
100
+ document.addEventListener('keydown', handler)
101
+ return () => document.removeEventListener('keydown', handler)
102
+ }, [])
103
+
88
104
  return (
89
105
  <div className="app">
90
106
  <TopBar
@@ -104,6 +120,8 @@ export function AppShell() {
104
120
  />
105
121
  <Inspector project={project} selectedNodeId={selectedNodeId} graphData={graphData} />
106
122
  <StatusBar project={project} graphData={graphData} />
123
+ <Toaster />
124
+ {debugOpen && <DebugPanel project={project} onClose={() => setDebugOpen(false)} />}
107
125
  </div>
108
126
  )
109
127
  }
@@ -0,0 +1,112 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import {
5
+ CORE_URL_PUBLIC,
6
+ apiCallBus,
7
+ connectionBus,
8
+ sseEventBus,
9
+ type ApiCallEvent,
10
+ type ConnectionEvent,
11
+ type SseEvent,
12
+ } from '../../lib/proxy-client'
13
+
14
+ interface DebugPanelProps {
15
+ project: string
16
+ onClose: () => void
17
+ }
18
+
19
+ // ADR-058 #4 — read-only diagnostic overlay toggled via Ctrl+Shift+D.
20
+ // Subscribes to the in-memory event buses populated by trackedFetch and the
21
+ // SSE/health hooks. No POST/PUT/DELETE buttons — observation only.
22
+ export function DebugPanel({ project, onClose }: DebugPanelProps) {
23
+ const [calls, setCalls] = useState<ApiCallEvent[]>([])
24
+ const [sseEvents, setSseEvents] = useState<SseEvent[]>([])
25
+ const [heartbeats, setHeartbeats] = useState<ConnectionEvent[]>([])
26
+
27
+ useEffect(() => {
28
+ const unsubCalls = apiCallBus.subscribe((e) => {
29
+ setCalls((prev) => [e, ...prev].slice(0, 10))
30
+ })
31
+ const unsubSse = sseEventBus.subscribe((e) => {
32
+ setSseEvents((prev) => [e, ...prev].slice(0, 10))
33
+ })
34
+ const unsubConn = connectionBus.subscribe((e) => {
35
+ setHeartbeats((prev) => [e, ...prev].slice(0, 20))
36
+ })
37
+ return () => {
38
+ unsubCalls()
39
+ unsubSse()
40
+ unsubConn()
41
+ }
42
+ }, [])
43
+
44
+ return (
45
+ <div
46
+ role="dialog"
47
+ aria-label="Debug panel"
48
+ style={{
49
+ position: 'fixed',
50
+ top: 60,
51
+ right: 16,
52
+ width: 420,
53
+ maxHeight: '70vh',
54
+ overflow: 'auto',
55
+ background: 'var(--ink-2, #14141a)',
56
+ border: '1px solid var(--rule, #2a2a30)',
57
+ color: 'var(--paper-1, #d8d3c9)',
58
+ fontFamily: 'JetBrains Mono, monospace',
59
+ fontSize: 11,
60
+ padding: 12,
61
+ zIndex: 1000,
62
+ boxShadow: '0 16px 40px rgba(0,0,0,0.45)',
63
+ }}
64
+ >
65
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
66
+ <strong style={{ fontFamily: 'Spectral, serif', fontStyle: 'italic' }}>NEAT debug</strong>
67
+ <button onClick={onClose} aria-label="Close debug panel" title="Close (Ctrl+Shift+D)" style={{ background: 'transparent', border: 'none', color: 'inherit', cursor: 'pointer', fontSize: 14 }}>×</button>
68
+ </div>
69
+
70
+ <section style={{ marginBottom: 10 }}>
71
+ <div style={{ color: 'var(--paper-3)', marginBottom: 4 }}>environment</div>
72
+ <div>project: <code>{project}</code></div>
73
+ <div>NEAT_API_URL: <code>{CORE_URL_PUBLIC}</code></div>
74
+ </section>
75
+
76
+ <section style={{ marginBottom: 10 }}>
77
+ <div style={{ color: 'var(--paper-3)', marginBottom: 4 }}>last {calls.length} api calls</div>
78
+ {calls.length === 0 && <div style={{ opacity: 0.5 }}>none yet</div>}
79
+ {calls.map((c, i) => (
80
+ <div key={i} style={{ display: 'flex', gap: 8, lineHeight: 1.5 }}>
81
+ <span style={{ width: 36, color: c.status >= 400 ? '#e87a7a' : c.status === 0 ? '#d3a847' : 'var(--paper-2)' }}>{c.status || '—'}</span>
82
+ <span style={{ width: 50, opacity: 0.6 }}>{c.durationMs}ms</span>
83
+ <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.path}</span>
84
+ </div>
85
+ ))}
86
+ </section>
87
+
88
+ <section style={{ marginBottom: 10 }}>
89
+ <div style={{ color: 'var(--paper-3)', marginBottom: 4 }}>last {sseEvents.length} sse events</div>
90
+ {sseEvents.length === 0 && <div style={{ opacity: 0.5 }}>none yet</div>}
91
+ {sseEvents.map((e, i) => (
92
+ <div key={i} style={{ display: 'flex', gap: 8 }}>
93
+ <span style={{ width: 90 }}>{new Date(e.timestamp).toLocaleTimeString()}</span>
94
+ <span>{e.type}</span>
95
+ </div>
96
+ ))}
97
+ </section>
98
+
99
+ <section>
100
+ <div style={{ color: 'var(--paper-3)', marginBottom: 4 }}>heartbeats</div>
101
+ {heartbeats.length === 0 && <div style={{ opacity: 0.5 }}>none yet</div>}
102
+ {heartbeats.map((h, i) => (
103
+ <div key={i} style={{ display: 'flex', gap: 8 }}>
104
+ <span style={{ width: 90 }}>{new Date(h.timestamp).toLocaleTimeString()}</span>
105
+ <span style={{ width: 60 }}>{h.state}</span>
106
+ <span style={{ opacity: 0.6 }}>{h.rttMs ? `${h.rttMs}ms` : ''}</span>
107
+ </div>
108
+ ))}
109
+ </section>
110
+ </div>
111
+ )
112
+ }
@@ -486,12 +486,14 @@ export function GraphCanvas({ project, selectedNodeId, onNodeSelect, onGraphLoad
486
486
  })
487
487
  sse.addEventListener('node-removed', (e) => {
488
488
  const { id } = JSON.parse(e.data) as { id: string }
489
- cy.getElementById(id).remove()
489
+ const el = cy.getElementById(id)
490
+ if (el && el.length) el.remove()
490
491
  pushGraphUpdate()
491
492
  })
492
493
  sse.addEventListener('edge-removed', (e) => {
493
494
  const { id } = JSON.parse(e.data) as { id: string }
494
- cy.getElementById(id).remove()
495
+ const el = cy.getElementById(id)
496
+ if (el && el.length) el.remove()
495
497
  pushGraphUpdate()
496
498
  })
497
499
  sse.addEventListener('error', () => {
@@ -62,7 +62,7 @@ interface InspectorProps {
62
62
  export function Inspector({ project, selectedNodeId, graphData }: InspectorProps) {
63
63
  const [node, setNode] = useState<GraphNode | null>(null)
64
64
  const [rootCause, setRootCause] = useState<RootCauseResult | null>(null)
65
- const [activeTab, setActiveTab] = useState<'inspect' | 'edges'>('inspect')
65
+ const [activeTab, setActiveTab] = useState<'inspect' | 'edges' | 'owners' | 'history'>('inspect')
66
66
 
67
67
  // Stable synthetic metrics — re-randomised per node, not per render
68
68
  const metrics = useMemo(() => ({
@@ -99,8 +99,10 @@ export function Inspector({ project, selectedNodeId, graphData }: InspectorProps
99
99
  <div className="inspect-tabs" role="tablist">
100
100
  <div className="inspect-tab on" role="tab" aria-selected={true}>Inspect</div>
101
101
  <div className="inspect-tab" role="tab" aria-selected={false}>Edges</div>
102
- <div className="inspect-tab" role="tab" aria-selected={false}>Owners</div>
103
- <div className="inspect-tab" role="tab" aria-selected={false}>History</div>
102
+ {/* ADR-056 Owners deferred: explicit disabled affordance. */}
103
+ <div className="inspect-tab disabled" role="tab" aria-selected={false} aria-disabled={true} title="Owners — coming in v0.3.x" style={{ opacity: 0.4, cursor: 'not-allowed' }}>Owners</div>
104
+ {/* ADR-056 — History deferred: explicit disabled affordance. */}
105
+ <div className="inspect-tab disabled" role="tab" aria-selected={false} aria-disabled={true} title="History — coming in v0.3.x" style={{ opacity: 0.4, cursor: 'not-allowed' }}>History</div>
104
106
  </div>
105
107
  <div className="insp-section" style={{ paddingTop: 32 }}>
106
108
  <div style={{ fontFamily: 'Spectral, serif', fontStyle: 'italic', color: 'var(--paper-3)', textAlign: 'center' }}>
@@ -156,6 +158,7 @@ export function Inspector({ project, selectedNodeId, graphData }: InspectorProps
156
158
  const showMetrics = !['ConfigNode', 'FrontierNode'].includes(node.type)
157
159
  const p99Num = parseFloat(metrics.p99)
158
160
  const errNum = parseFloat(metrics.err)
161
+ const owner = (node as unknown as { owner?: string }).owner
159
162
 
160
163
  return (
161
164
  <aside className="inspect" id="inspect">
@@ -176,8 +179,26 @@ export function Inspector({ project, selectedNodeId, graphData }: InspectorProps
176
179
  >
177
180
  Edges<span className="ct">{edgeCount}</span>
178
181
  </div>
179
- <div className="inspect-tab" role="tab" aria-selected={false}>Owners</div>
180
- <div className="inspect-tab" role="tab" aria-selected={false}>History</div>
182
+ {/* ADR-056 Owners wired: shows ServiceNode.owner when available (ADR-054). */}
183
+ <div
184
+ className={`inspect-tab${activeTab === 'owners' ? ' on' : ''}`}
185
+ role="tab"
186
+ aria-selected={activeTab === 'owners'}
187
+ onClick={() => setActiveTab('owners')}
188
+ >
189
+ Owners
190
+ </div>
191
+ {/* ADR-056 — History deferred: explicit disabled affordance. */}
192
+ <div
193
+ className="inspect-tab disabled"
194
+ role="tab"
195
+ aria-selected={false}
196
+ aria-disabled={true}
197
+ title="History — coming in v0.3.x"
198
+ style={{ opacity: 0.4, cursor: 'not-allowed' }}
199
+ >
200
+ History
201
+ </div>
181
202
  </div>
182
203
 
183
204
  <div id="inspect-body">
@@ -323,6 +344,21 @@ export function Inspector({ project, selectedNodeId, graphData }: InspectorProps
323
344
  </section>
324
345
  )}
325
346
 
347
+ {activeTab === 'owners' && (
348
+ <section className="insp-section">
349
+ <div className="insp-h">Owners</div>
350
+ {owner ? (
351
+ <dl className="kv">
352
+ <dt>owner</dt>
353
+ <dd>{escapeHtml(owner)}</dd>
354
+ </dl>
355
+ ) : (
356
+ <div style={{ color: 'var(--paper-3)', fontStyle: 'italic', fontFamily: 'Spectral, serif', fontSize: 12 }}>
357
+ no owner declared in package.json or pyproject.toml (ADR-054)
358
+ </div>
359
+ )}
360
+ </section>
361
+ )}
326
362
  </div>
327
363
  </aside>
328
364
  )
@@ -31,10 +31,23 @@ export function Rail({ project }: RailProps) {
31
31
  .catch(() => {})
32
32
  }, [project])
33
33
 
34
+ // ADR-056 — Find is wired: dispatches a custom event TopBar's search input listens for.
35
+ // ADR-056 — Layers / NeatScript / Time travel / Diff / Comments / Agents / Settings
36
+ // are deferred features; rendered with `disabled` + tooltip affordance so the user
37
+ // perceives them as unavailable, not broken.
38
+ function focusFind(): void {
39
+ const input = document.querySelector<HTMLInputElement>('.top-search input')
40
+ input?.focus()
41
+ }
42
+
43
+ function disabledTip(label: string): string {
44
+ return `${label} — coming in v0.3.x`
45
+ }
46
+
34
47
  return (
35
48
  <nav className="rail">
36
49
  <div className="rail-group">
37
- <button className="rail-btn active" aria-label="Graph view">
50
+ <button className="rail-btn active" aria-label="Graph view" title="Graph view">
38
51
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
39
52
  <circle cx="6" cy="6" r="2.5" /><circle cx="18" cy="6" r="2.5" />
40
53
  <circle cx="6" cy="18" r="2.5" /><circle cx="18" cy="18" r="2.5" />
@@ -42,13 +55,13 @@ export function Rail({ project }: RailProps) {
42
55
  </svg>
43
56
  <span className="rail-tip">Graph<span className="k">G</span></span>
44
57
  </button>
45
- <button className="rail-btn" aria-label="Layers view">
58
+ <button className="rail-btn" aria-label="Layers (coming soon)" disabled title={disabledTip('Layers')} style={{ opacity: 0.35, cursor: 'not-allowed' }}>
46
59
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
47
60
  <path d="M4 7h16M4 12h10M4 17h16" />
48
61
  </svg>
49
62
  <span className="rail-tip">Layers<span className="k">L</span></span>
50
63
  </button>
51
- <button className="rail-btn" aria-label="Find node">
64
+ <button className="rail-btn" aria-label="Find node" onClick={focusFind} title="Find — focus search">
52
65
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
53
66
  <circle cx="11" cy="11" r="6" /><path d="m20 20-4-4" />
54
67
  </svg>
@@ -57,19 +70,19 @@ export function Rail({ project }: RailProps) {
57
70
  </div>
58
71
 
59
72
  <div className="rail-group">
60
- <button className="rail-btn" aria-label="NeatScript editor">
73
+ <button className="rail-btn" aria-label="NeatScript editor (coming soon)" disabled title={disabledTip('NeatScript')} style={{ opacity: 0.35, cursor: 'not-allowed' }}>
61
74
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
62
75
  <path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" />
63
76
  </svg>
64
77
  <span className="rail-tip">NeatScript<span className="k">N</span></span>
65
78
  </button>
66
- <button className="rail-btn" aria-label="Time travel">
79
+ <button className="rail-btn" aria-label="Time travel (coming soon)" disabled title={disabledTip('Time travel')} style={{ opacity: 0.35, cursor: 'not-allowed' }}>
67
80
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
68
81
  <circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" />
69
82
  </svg>
70
83
  <span className="rail-tip">Time travel<span className="k">T</span></span>
71
84
  </button>
72
- <button className="rail-btn" aria-label="Blast radius analysis">
85
+ <button className="rail-btn" aria-label="Blast radius (coming soon)" disabled title={disabledTip('Blast radius')} style={{ opacity: 0.35, cursor: 'not-allowed' }}>
73
86
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
74
87
  <circle cx="12" cy="12" r="3" /><circle cx="12" cy="12" r="7" />
75
88
  <path d="M12 3v2M12 19v2M3 12h2M19 12h2" />
@@ -77,7 +90,7 @@ export function Rail({ project }: RailProps) {
77
90
  <span className="rail-tip">Blast radius<span className="k">B</span></span>
78
91
  {blastBadge > 0 && <span className="badge">{blastBadge}</span>}
79
92
  </button>
80
- <button className="rail-btn" aria-label="Graph diff">
93
+ <button className="rail-btn" aria-label="Graph diff (coming soon)" disabled title={disabledTip('Diff')} style={{ opacity: 0.35, cursor: 'not-allowed' }}>
81
94
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
82
95
  <path d="M8 4 4 8l4 4M16 12l4 4-4 4M14 4l-4 16" />
83
96
  </svg>
@@ -86,7 +99,7 @@ export function Rail({ project }: RailProps) {
86
99
  </div>
87
100
 
88
101
  <div className="rail-group">
89
- <button className="rail-btn" aria-label="Comments">
102
+ <button className="rail-btn" aria-label="Comments (coming soon)" disabled title={disabledTip('Comments')} style={{ opacity: 0.35, cursor: 'not-allowed' }}>
90
103
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
91
104
  <path d="M4 6c0-1 1-2 2-2h12c1 0 2 1 2 2v9c0 1-1 2-2 2h-7l-4 4v-4H6c-1 0-2-1-2-2z" />
92
105
  </svg>
@@ -99,7 +112,7 @@ export function Rail({ project }: RailProps) {
99
112
  <span className="rail-tip">Incidents</span>
100
113
  {incidentBadge > 0 && <span className="badge">{incidentBadge}</span>}
101
114
  </Link>
102
- <button className="rail-btn" aria-label="Agent control panel">
115
+ <button className="rail-btn" aria-label="Agents (coming soon)" disabled title={disabledTip('Agents')} style={{ opacity: 0.35, cursor: 'not-allowed' }}>
103
116
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
104
117
  <path d="M12 3v3M12 18v3M5 12H2M22 12h-3M6 6l2 2M16 16l2 2M6 18l2-2M16 8l2-2" />
105
118
  <circle cx="12" cy="12" r="3" />
@@ -111,7 +124,7 @@ export function Rail({ project }: RailProps) {
111
124
  <div className="rail-spacer" />
112
125
 
113
126
  <div className="rail-group" style={{ borderTop: '1px solid var(--rule)' }}>
114
- <button className="rail-btn" aria-label="Settings">
127
+ <button className="rail-btn" aria-label="Settings (coming soon)" disabled title={disabledTip('Settings')} style={{ opacity: 0.35, cursor: 'not-allowed' }}>
115
128
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
116
129
  <circle cx="12" cy="12" r="3" />
117
130
  <path d="M19 12a7 7 0 0 1-.4 2.3l2 1.5-2 3.4-2.3-1a7 7 0 0 1-4 2.3l-.4 2.5h-4l-.4-2.5a7 7 0 0 1-4-2.3l-2.3 1-2-3.4 2-1.5A7 7 0 0 1 5 12a7 7 0 0 1 .4-2.3l-2-1.5 2-3.4 2.3 1a7 7 0 0 1 4-2.3L12 1h4l.4 2.5a7 7 0 0 1 4 2.3l2.3-1 2 3.4-2 1.5" />
@@ -2,18 +2,33 @@
2
2
 
3
3
  import { useEffect, useState } from 'react'
4
4
  import type { GraphData } from './AppShell'
5
+ import {
6
+ CORE_URL_PUBLIC,
7
+ connectionBus,
8
+ sseEventBus,
9
+ type ConnectionEvent,
10
+ type SseEvent,
11
+ } from '../../lib/proxy-client'
5
12
 
6
13
  interface StatusBarProps {
7
14
  project: string
8
15
  graphData: GraphData | null
9
16
  }
10
17
 
18
+ type ConnState = 'ok' | 'slow' | 'down'
19
+ type SseState = 'connected' | 'reconnecting' | 'disconnected'
20
+
11
21
  function formatTime(d: Date): string {
12
22
  return d.toTimeString().slice(0, 8) + ' ' + d.toTimeString().slice(9, 12)
13
23
  }
14
24
 
15
25
  export function StatusBar({ project, graphData }: StatusBarProps) {
16
26
  const [now, setNow] = useState(() => formatTime(new Date()))
27
+ // ADR-058 #1 — daemon connection state visible. Tracks /health latency
28
+ // and consecutive failures.
29
+ const [connState, setConnState] = useState<ConnState>('down')
30
+ // ADR-058 #2 — SSE state visible.
31
+ const [sseState, setSseState] = useState<SseState>('disconnected')
17
32
  const [healthy, setHealthy] = useState<boolean | null>(null)
18
33
 
19
34
  useEffect(() => {
@@ -21,28 +36,112 @@ export function StatusBar({ project, graphData }: StatusBarProps) {
21
36
  return () => clearInterval(id)
22
37
  }, [])
23
38
 
24
- // ADR-057 #3re-check health when project changes so the indicator
25
- // reflects the active project's daemon state.
39
+ // ADR-058 #1heartbeat every 5s; classify latency.
26
40
  useEffect(() => {
27
- const check = () =>
28
- fetch(`/api/health?project=${encodeURIComponent(project)}`)
29
- .then((r) => r.json())
30
- .then((d: { ok: boolean }) => setHealthy(d.ok === true))
31
- .catch(() => setHealthy(false))
32
- check()
33
- const id = setInterval(check, 15_000)
41
+ let consecutiveFailures = 0
42
+ const SLOW_MS = 800
43
+ const DOWN_FAILS = 2
44
+
45
+ async function check(): Promise<void> {
46
+ const start = performance.now()
47
+ try {
48
+ const r = await fetch('/api/health', { cache: 'no-store' })
49
+ const rtt = Math.round(performance.now() - start)
50
+ const ok = r.ok
51
+ if (!ok) {
52
+ consecutiveFailures += 1
53
+ const next: ConnState = consecutiveFailures >= DOWN_FAILS ? 'down' : 'slow'
54
+ setConnState(next)
55
+ setHealthy(false)
56
+ connectionBus.emit({ state: next, rttMs: rtt, timestamp: Date.now() })
57
+ return
58
+ }
59
+ consecutiveFailures = 0
60
+ const next: ConnState = rtt > SLOW_MS ? 'slow' : 'ok'
61
+ setConnState(next)
62
+ setHealthy(true)
63
+ connectionBus.emit({ state: next, rttMs: rtt, timestamp: Date.now() })
64
+ } catch {
65
+ consecutiveFailures += 1
66
+ const next: ConnState = consecutiveFailures >= DOWN_FAILS ? 'down' : 'slow'
67
+ setConnState(next)
68
+ setHealthy(false)
69
+ connectionBus.emit({ state: next, timestamp: Date.now() })
70
+ }
71
+ }
72
+
73
+ void check()
74
+ const id = setInterval(() => void check(), 5_000)
34
75
  return () => clearInterval(id)
35
76
  }, [project])
36
77
 
78
+ // ADR-058 #2 — track SSE connection state. EventSource auto-reconnects
79
+ // per spec; we surface the state transitions.
80
+ useEffect(() => {
81
+ const sse = new EventSource('/api/events')
82
+ setSseState('reconnecting')
83
+
84
+ sse.onopen = () => {
85
+ setSseState('connected')
86
+ }
87
+ sse.onerror = () => {
88
+ // EventSource toggles readyState; readyState 0 = CONNECTING (reconnect),
89
+ // 2 = CLOSED.
90
+ setSseState(sse.readyState === EventSource.CLOSED ? 'disconnected' : 'reconnecting')
91
+ }
92
+
93
+ function record(type: string): (e: MessageEvent) => void {
94
+ return () => {
95
+ sseEventBus.emit({ type, timestamp: Date.now() })
96
+ }
97
+ }
98
+ sse.addEventListener('node-added', record('node-added'))
99
+ sse.addEventListener('edge-added', record('edge-added'))
100
+ sse.addEventListener('node-removed', record('node-removed'))
101
+ sse.addEventListener('edge-removed', record('edge-removed'))
102
+
103
+ return () => sse.close()
104
+ }, [])
105
+
37
106
  const nodeCount = graphData?.nodes.length ?? '—'
38
107
  const edgeCount = graphData?.edges.length ?? '—'
39
108
 
109
+ // ADR-058 #1 — `data-connection-state` is a stable attribute the contract
110
+ // test asserts. The colour classes follow.
111
+ const connColor: Record<ConnState, string> = {
112
+ ok: 'var(--prov-observed)',
113
+ slow: '#d3a847',
114
+ down: '#e87a7a',
115
+ }
116
+ const sseColor: Record<SseState, string> = {
117
+ connected: 'var(--prov-observed)',
118
+ reconnecting: '#d3a847',
119
+ disconnected: '#e87a7a',
120
+ }
121
+
122
+ // Suppress unused-var warnings for buses imported above so we keep the
123
+ // module-side effect (event subscriptions in DebugPanel are wired).
124
+ void connectionBus
125
+ void sseEventBus
126
+ type _typecheck = ConnectionEvent | SseEvent
127
+ void (null as unknown as _typecheck)
128
+
40
129
  return (
41
130
  <footer className="status">
42
- <div className={`st-item${healthy ? ' live' : ' live-dead'}`}>
131
+ <div
132
+ className="st-item"
133
+ data-connection-state={connState}
134
+ title={`daemon @ ${CORE_URL_PUBLIC} — ${connState}`}
135
+ >
136
+ <span className="dot" style={{ background: connColor[connState] }} />
43
137
  <span className="k">neat</span>
44
138
  <span className="v">{project}</span>
45
139
  </div>
140
+ <div className="st-item" data-sse-state={sseState} title={`live updates: ${sseState}`}>
141
+ <span className="dot" style={{ background: sseColor[sseState] }} />
142
+ <span className="k">sse</span>
143
+ <span className="v">{sseState}</span>
144
+ </div>
46
145
  <div className="st-item">
47
146
  <span className="k">nodes</span>
48
147
  <span className="v" id="st-nodes">{nodeCount}</span>
@@ -0,0 +1,67 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { toastBus, type ToastEvent } from '../../lib/proxy-client'
5
+
6
+ // ADR-058 #3 — surfaces non-2xx fetch responses as transient toasts.
7
+ // Subscribes to `toastBus` populated by trackedFetch. Auto-dismisses after
8
+ // six seconds; stacks up to four at a time.
9
+ export function Toaster() {
10
+ const [toasts, setToasts] = useState<ToastEvent[]>([])
11
+
12
+ useEffect(() => {
13
+ const unsub = toastBus.subscribe((t) => {
14
+ setToasts((prev) => [...prev, t].slice(-4))
15
+ window.setTimeout(() => {
16
+ setToasts((prev) => prev.filter((p) => p.id !== t.id))
17
+ }, 6_000)
18
+ })
19
+ return unsub
20
+ }, [])
21
+
22
+ if (toasts.length === 0) return null
23
+
24
+ return (
25
+ <div
26
+ role="status"
27
+ aria-live="polite"
28
+ style={{
29
+ position: 'fixed',
30
+ bottom: 56,
31
+ right: 16,
32
+ display: 'flex',
33
+ flexDirection: 'column',
34
+ gap: 8,
35
+ zIndex: 999,
36
+ }}
37
+ >
38
+ {toasts.map((t) => {
39
+ const color = t.level === 'error' ? '#e87a7a' : t.level === 'warn' ? '#d3a847' : 'var(--prov-observed)'
40
+ return (
41
+ <div
42
+ key={t.id}
43
+ className="toast"
44
+ onClick={() => setToasts((prev) => prev.filter((p) => p.id !== t.id))}
45
+ style={{
46
+ background: 'var(--ink-2, #14141a)',
47
+ border: `1px solid ${color}`,
48
+ borderLeft: `3px solid ${color}`,
49
+ padding: '8px 12px',
50
+ fontFamily: 'JetBrains Mono, monospace',
51
+ fontSize: 11,
52
+ color: 'var(--paper-1, #d8d3c9)',
53
+ maxWidth: 360,
54
+ cursor: 'pointer',
55
+ boxShadow: '0 8px 16px rgba(0,0,0,0.35)',
56
+ }}
57
+ >
58
+ <div style={{ display: 'flex', gap: 8, alignItems: 'baseline' }}>
59
+ <span style={{ color, fontWeight: 600 }}>{t.status ?? t.level}</span>
60
+ <span style={{ flex: 1 }}>{t.message}</span>
61
+ </div>
62
+ </div>
63
+ )
64
+ })}
65
+ </div>
66
+ )
67
+ }
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useEffect, useRef, useState } from 'react'
4
+ import { CORE_URL_PUBLIC } from '../../lib/proxy-client'
4
5
 
5
6
  interface Project {
6
7
  name: string
@@ -189,10 +190,36 @@ export function TopBar({ project, onProjectChange, onNodeSelect, onRelayout, onT
189
190
  </div>
190
191
 
191
192
  <div className="top-actions">
193
+ {/* ADR-058 #5 — daemon URL visible. */}
194
+ <span className="daemon-url" title="NEAT daemon URL">{CORE_URL_PUBLIC}</span>
192
195
  <button className="top-btn" aria-label={isLive ? 'Core connected' : 'Core offline'}>
193
196
  <span className={`dot${isLive ? ' live' : ''}`} />
194
197
  {isLive ? 'Live' : 'Offline'}
195
198
  </button>
199
+ {/* ADR-056 — History deferred; explicitly disabled with affordance. */}
200
+ <button className="top-btn" disabled title="History — coming in v0.3.x" aria-label="History (coming soon)" style={{ opacity: 0.4, cursor: 'not-allowed' }}>
201
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
202
+ <circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" />
203
+ </svg>
204
+ History
205
+ </button>
206
+ {/* ADR-056 — Share wired: copies the deep-link URL to clipboard. */}
207
+ <button
208
+ className="top-btn"
209
+ title="Copy current view URL"
210
+ aria-label="Share — copy URL"
211
+ onClick={() => {
212
+ if (typeof navigator !== 'undefined' && navigator.clipboard) {
213
+ void navigator.clipboard.writeText(window.location.href)
214
+ }
215
+ }}
216
+ >
217
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
218
+ <circle cx="6" cy="12" r="2.5" /><circle cx="18" cy="6" r="2.5" /><circle cx="18" cy="18" r="2.5" />
219
+ <path d="m8 11 8-4M8 13l8 4" />
220
+ </svg>
221
+ Share
222
+ </button>
196
223
  <button className="top-btn" title="Re-run cose layout" onClick={onRelayout}>
197
224
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
198
225
  <path d="M12 8v4l3 2" /><circle cx="12" cy="12" r="9" />
@@ -0,0 +1,150 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import Link from 'next/link'
5
+
6
+ interface Incident {
7
+ nodeId: string
8
+ timestamp: string
9
+ type: string
10
+ message: string
11
+ stacktrace?: string
12
+ }
13
+
14
+ interface IncidentsResponse {
15
+ count: number
16
+ total: number
17
+ events: Incident[]
18
+ }
19
+
20
+ function formatTs(iso: string): string {
21
+ try {
22
+ const d = new Date(iso)
23
+ return d.toLocaleDateString() + ' ' + d.toTimeString().slice(0, 8)
24
+ } catch {
25
+ return iso
26
+ }
27
+ }
28
+
29
+ export function IncidentsClient() {
30
+ const [data, setData] = useState<IncidentsResponse | null>(null)
31
+ const [loading, setLoading] = useState(true)
32
+ const [error, setError] = useState<string | null>(null)
33
+ const [openRow, setOpenRow] = useState<string | null>(null)
34
+
35
+ // ADR-057 #2 — read project from URL (deep-linkable) or localStorage,
36
+ // matching AppShell's resolution chain. The lazy initializer reads
37
+ // window.* synchronously; safe because the page mounts client-only via
38
+ // dynamic({ ssr: false }) per ADR-062 §4 (2026-05-11 amendment). The
39
+ // typeof window guard stays as belt-and-suspenders — if someone later
40
+ // removes the dynamic wrapper, this degrades to a flicker, not a crash.
41
+ const [project] = useState<string>(() => {
42
+ if (typeof window === 'undefined') return 'default'
43
+ const fromUrl = new URLSearchParams(window.location.search).get('project')
44
+ if (fromUrl) return fromUrl
45
+ try {
46
+ const stored = window.localStorage.getItem('neat:lastProject')
47
+ if (stored) return stored
48
+ } catch { /* noop */ }
49
+ return 'default'
50
+ })
51
+
52
+ // ADR-057 #3 — re-fetch when project changes.
53
+ useEffect(() => {
54
+ setLoading(true)
55
+ fetch(`/api/incidents?limit=100&project=${encodeURIComponent(project)}`)
56
+ .then((r) => r.json())
57
+ .then((d: IncidentsResponse) => {
58
+ setData(d)
59
+ setLoading(false)
60
+ })
61
+ .catch((e: Error) => {
62
+ setError(e.message)
63
+ setLoading(false)
64
+ })
65
+ }, [project])
66
+
67
+ return (
68
+ <div style={{ background: 'var(--ink-0)', minHeight: '100vh' }}>
69
+ <header className="topbar">
70
+ <div className="brand" title="NEAT">N</div>
71
+ <div className="crumbs">
72
+ <Link href="/" className="incidents-nav-link">graph view</Link>
73
+ <span className="sep">/</span>
74
+ <span className="here">incidents</span>
75
+ </div>
76
+ </header>
77
+
78
+ <div className="incidents-page" style={{ marginTop: 44 }}>
79
+ <h1>Incidents</h1>
80
+ <div className="subtitle">
81
+ {data ? `${data.total} total events — showing ${data.events.length}` : 'loading…'}
82
+ </div>
83
+
84
+ {loading && (
85
+ <div className="incidents-empty">loading…</div>
86
+ )}
87
+
88
+ {error && (
89
+ <div className="incidents-empty" style={{ color: '#e87a7a' }}>
90
+ failed to load: {error}
91
+ </div>
92
+ )}
93
+
94
+ {!loading && !error && data && data.events.length === 0 && (
95
+ <div className="incidents-empty">no incidents recorded</div>
96
+ )}
97
+
98
+ {!loading && !error && data && data.events.length > 0 && (
99
+ <table className="incidents-table">
100
+ <thead>
101
+ <tr>
102
+ <th>Node</th>
103
+ <th>Time</th>
104
+ <th>Type</th>
105
+ <th>Message</th>
106
+ </tr>
107
+ </thead>
108
+ <tbody>
109
+ {data.events.map((evt, i) => {
110
+ const rowKey = `${i}-${evt.nodeId}`
111
+ const isOpen = openRow === rowKey
112
+ return (
113
+ <>
114
+ <tr
115
+ key={rowKey}
116
+ style={{ cursor: evt.stacktrace ? 'pointer' : undefined }}
117
+ onClick={() => evt.stacktrace && setOpenRow(isOpen ? null : rowKey)}
118
+ title={evt.stacktrace ? (isOpen ? 'Collapse stacktrace' : 'Expand stacktrace') : undefined}
119
+ >
120
+ <td className="td-node">
121
+ <Link href={`/?node=${encodeURIComponent(evt.nodeId)}&project=${encodeURIComponent(project)}`} className="incidents-node-link">
122
+ {evt.nodeId}
123
+ </Link>
124
+ </td>
125
+ <td className="td-time">{formatTs(evt.timestamp)}</td>
126
+ <td className="td-type">{evt.type}</td>
127
+ <td className="td-msg">
128
+ {evt.message}
129
+ {evt.stacktrace && (
130
+ <span className="stack-toggle">{isOpen ? ' ▲' : ' ▼'}</span>
131
+ )}
132
+ </td>
133
+ </tr>
134
+ {isOpen && evt.stacktrace && (
135
+ <tr key={`${rowKey}-stack`}>
136
+ <td colSpan={4} className="td-stacktrace">
137
+ <pre className="stacktrace-pre">{evt.stacktrace}</pre>
138
+ </td>
139
+ </tr>
140
+ )}
141
+ </>
142
+ )
143
+ })}
144
+ </tbody>
145
+ </table>
146
+ )}
147
+ </div>
148
+ </div>
149
+ )
150
+ }
@@ -1,145 +1,16 @@
1
- 'use client'
2
-
3
- import { useEffect, useState } from 'react'
4
- import Link from 'next/link'
5
-
6
- interface Incident {
7
- nodeId: string
8
- timestamp: string
9
- type: string
10
- message: string
11
- stacktrace?: string
12
- }
13
-
14
- interface IncidentsResponse {
15
- count: number
16
- total: number
17
- events: Incident[]
18
- }
19
-
20
- function formatTs(iso: string): string {
21
- try {
22
- const d = new Date(iso)
23
- return d.toLocaleDateString() + ' ' + d.toTimeString().slice(0, 8)
24
- } catch {
25
- return iso
26
- }
27
- }
28
-
29
- export default function IncidentsPage() {
30
- const [data, setData] = useState<IncidentsResponse | null>(null)
31
- const [loading, setLoading] = useState(true)
32
- const [error, setError] = useState<string | null>(null)
33
- const [openRow, setOpenRow] = useState<string | null>(null)
34
-
35
- // ADR-057 #2 — page reads project from URL (deep-linkable) or localStorage,
36
- // matching AppShell's resolution chain so the incidents view stays in sync
37
- // with whichever project the operator last selected on the graph view.
38
- const [project, setProject] = useState<string>('default')
39
- useEffect(() => {
40
- if (typeof window === 'undefined') return
41
- const fromUrl = new URLSearchParams(window.location.search).get('project')
42
- let stored: string | null = null
43
- try { stored = window.localStorage.getItem('neat:lastProject') } catch { /* noop */ }
44
- setProject(fromUrl || stored || 'default')
45
- }, [])
46
-
47
- // ADR-057 #3 — re-fetch when project changes.
48
- useEffect(() => {
49
- setLoading(true)
50
- fetch(`/api/incidents?limit=100&project=${encodeURIComponent(project)}`)
51
- .then((r) => r.json())
52
- .then((d: IncidentsResponse) => {
53
- setData(d)
54
- setLoading(false)
55
- })
56
- .catch((e: Error) => {
57
- setError(e.message)
58
- setLoading(false)
59
- })
60
- }, [project])
61
-
62
- return (
63
- <div style={{ background: 'var(--ink-0)', minHeight: '100vh' }}>
64
- <header className="topbar">
65
- <div className="brand" title="NEAT">N</div>
66
- <div className="crumbs">
67
- <Link href="/" className="incidents-nav-link">graph view</Link>
68
- <span className="sep">/</span>
69
- <span className="here">incidents</span>
70
- </div>
71
- </header>
72
-
73
- <div className="incidents-page" style={{ marginTop: 44 }}>
74
- <h1>Incidents</h1>
75
- <div className="subtitle">
76
- {data ? `${data.total} total events — showing ${data.events.length}` : 'loading…'}
77
- </div>
78
-
79
- {loading && (
80
- <div className="incidents-empty">loading…</div>
81
- )}
82
-
83
- {error && (
84
- <div className="incidents-empty" style={{ color: '#e87a7a' }}>
85
- failed to load: {error}
86
- </div>
87
- )}
88
-
89
- {!loading && !error && data && data.events.length === 0 && (
90
- <div className="incidents-empty">no incidents recorded</div>
91
- )}
92
-
93
- {!loading && !error && data && data.events.length > 0 && (
94
- <table className="incidents-table">
95
- <thead>
96
- <tr>
97
- <th>Node</th>
98
- <th>Time</th>
99
- <th>Type</th>
100
- <th>Message</th>
101
- </tr>
102
- </thead>
103
- <tbody>
104
- {data.events.map((evt, i) => {
105
- const rowKey = `${i}-${evt.nodeId}`
106
- const isOpen = openRow === rowKey
107
- return (
108
- <>
109
- <tr
110
- key={rowKey}
111
- style={{ cursor: evt.stacktrace ? 'pointer' : undefined }}
112
- onClick={() => evt.stacktrace && setOpenRow(isOpen ? null : rowKey)}
113
- title={evt.stacktrace ? (isOpen ? 'Collapse stacktrace' : 'Expand stacktrace') : undefined}
114
- >
115
- <td className="td-node">
116
- <Link href={`/?node=${encodeURIComponent(evt.nodeId)}&project=${encodeURIComponent(project)}`} className="incidents-node-link">
117
- {evt.nodeId}
118
- </Link>
119
- </td>
120
- <td className="td-time">{formatTs(evt.timestamp)}</td>
121
- <td className="td-type">{evt.type}</td>
122
- <td className="td-msg">
123
- {evt.message}
124
- {evt.stacktrace && (
125
- <span className="stack-toggle">{isOpen ? ' ▲' : ' ▼'}</span>
126
- )}
127
- </td>
128
- </tr>
129
- {isOpen && evt.stacktrace && (
130
- <tr key={`${rowKey}-stack`}>
131
- <td colSpan={4} className="td-stacktrace">
132
- <pre className="stacktrace-pre">{evt.stacktrace}</pre>
133
- </td>
134
- </tr>
135
- )}
136
- </>
137
- )
138
- })}
139
- </tbody>
140
- </table>
141
- )}
142
- </div>
143
- </div>
144
- )
1
+ import dynamic from 'next/dynamic'
2
+
3
+ // ADR-062 §4 (2026-05-11 amendment) /incidents renders client-only.
4
+ // Same shape as app/page.tsx: the IncidentsClient subtree reads the URL
5
+ // and localStorage synchronously inside its useState lazy initializer,
6
+ // which would diverge from the SSR pass; removing the SSR pass keeps the
7
+ // resolution chain honest and avoids the double-fetch the deferred shape
8
+ // produced on every page load.
9
+ const IncidentsClient = dynamic(
10
+ () => import('./IncidentsClient').then((m) => m.IncidentsClient),
11
+ { ssr: false },
12
+ )
13
+
14
+ export default function IncidentsPage(): JSX.Element {
15
+ return <IncidentsClient />
145
16
  }
package/app/page.tsx CHANGED
@@ -1,4 +1,12 @@
1
- import { AppShell } from './components/AppShell'
1
+ import dynamic from 'next/dynamic'
2
+
3
+ // ADR-062 — AppShell renders client-only. The Next.js server emits the
4
+ // static HTML shell (head, fonts, CSS); the React tree builds on mount.
5
+ // Removing { ssr: false } reintroduces the hydration bug ADR-062 closed.
6
+ const AppShell = dynamic(
7
+ () => import('./components/AppShell').then((m) => m.AppShell),
8
+ { ssr: false },
9
+ )
2
10
 
3
11
  export default function Home(): JSX.Element {
4
12
  return <AppShell />
@@ -0,0 +1,118 @@
1
+ 'use client'
2
+
3
+ // Public-facing daemon URL string. Browsers don't get NEAT_API_URL directly —
4
+ // the Next.js server routes proxy at /api/* — but the operator wants to see
5
+ // which daemon is on the other end (ADR-058 #5). This is the human-readable
6
+ // label, not a fetched URL.
7
+ export const CORE_URL_PUBLIC: string =
8
+ (typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_NEAT_API_URL) ||
9
+ 'localhost:8080'
10
+
11
+ // Tiny pub-sub for transient surfaces (toasts, debug-panel call log).
12
+ type Subscriber<T> = (value: T) => void
13
+
14
+ class Channel<T> {
15
+ private subs = new Set<Subscriber<T>>()
16
+ subscribe(fn: Subscriber<T>): () => void {
17
+ this.subs.add(fn)
18
+ return () => {
19
+ this.subs.delete(fn)
20
+ }
21
+ }
22
+ emit(value: T): void {
23
+ this.subs.forEach((fn) => {
24
+ try {
25
+ fn(value)
26
+ } catch {
27
+ /* never let a subscriber kill the bus */
28
+ }
29
+ })
30
+ }
31
+ }
32
+
33
+ export interface ToastEvent {
34
+ id: number
35
+ level: 'error' | 'warn' | 'info'
36
+ message: string
37
+ status?: number
38
+ timestamp: number
39
+ }
40
+
41
+ export interface ApiCallEvent {
42
+ path: string
43
+ status: number
44
+ durationMs: number
45
+ timestamp: number
46
+ }
47
+
48
+ export interface SseEvent {
49
+ type: string
50
+ timestamp: number
51
+ }
52
+
53
+ export interface ConnectionEvent {
54
+ state: 'ok' | 'slow' | 'down'
55
+ rttMs?: number
56
+ timestamp: number
57
+ }
58
+
59
+ export const toastBus = new Channel<ToastEvent>()
60
+ export const apiCallBus = new Channel<ApiCallEvent>()
61
+ export const sseEventBus = new Channel<SseEvent>()
62
+ export const connectionBus = new Channel<ConnectionEvent>()
63
+
64
+ let toastIdCounter = 0
65
+ function nextId(): number {
66
+ toastIdCounter += 1
67
+ return toastIdCounter
68
+ }
69
+
70
+ // ADR-058 #3 — every non-2xx fetch surfaces a toast carrying the error
71
+ // envelope from ADR-040 (`{ error, status, details? }`). Wrap raw `fetch`
72
+ // instead of editing every call-site so the rule is centralized.
73
+ export async function trackedFetch(input: string, init?: RequestInit): Promise<Response> {
74
+ const start = performance.now()
75
+ let status = 0
76
+ try {
77
+ const res = await fetch(input, init)
78
+ status = res.status
79
+ if (!res.ok) {
80
+ let message = `${input} → ${res.status}`
81
+ try {
82
+ const cloned = res.clone()
83
+ const ct = cloned.headers.get('content-type') ?? ''
84
+ if (ct.includes('application/json')) {
85
+ const body = (await cloned.json()) as { error?: string; details?: string }
86
+ if (body?.error) {
87
+ message = body.error + (body.details ? ` — ${body.details}` : '')
88
+ }
89
+ }
90
+ } catch {
91
+ /* fall back to the default message */
92
+ }
93
+ toastBus.emit({
94
+ id: nextId(),
95
+ level: res.status >= 500 ? 'error' : 'warn',
96
+ message,
97
+ status: res.status,
98
+ timestamp: Date.now(),
99
+ })
100
+ }
101
+ return res
102
+ } catch (err) {
103
+ toastBus.emit({
104
+ id: nextId(),
105
+ level: 'error',
106
+ message: `${input} — ${(err as Error).message}`,
107
+ timestamp: Date.now(),
108
+ })
109
+ throw err
110
+ } finally {
111
+ apiCallBus.emit({
112
+ path: input,
113
+ status,
114
+ durationMs: Math.round(performance.now() - start),
115
+ timestamp: Date.now(),
116
+ })
117
+ }
118
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neat.is/web",
3
- "version": "0.2.10",
3
+ "version": "0.3.0",
4
4
  "description": "NEAT web shell — minimal Next.js app fronting the core API",
5
5
  "license": "BUSL-1.1",
6
6
  "homepage": "https://neat.is",
@@ -34,17 +34,22 @@
34
34
  "clean": "rm -rf .next .turbo"
35
35
  },
36
36
  "dependencies": {
37
- "@neat.is/types": "^0.2.10",
37
+ "@neat.is/types": "^0.3.0",
38
38
  "cytoscape": "^3.33.3",
39
39
  "next": "14.2.35",
40
40
  "react": "^18",
41
41
  "react-dom": "^18"
42
42
  },
43
43
  "devDependencies": {
44
+ "@testing-library/jest-dom": "^6.6.0",
45
+ "@testing-library/react": "^16.1.0",
46
+ "@testing-library/user-event": "^14.5.2",
44
47
  "@types/cytoscape": "^3.21.9",
45
48
  "@types/node": "^20",
46
49
  "@types/react": "^18",
47
50
  "@types/react-dom": "^18",
51
+ "@vitejs/plugin-react": "^4.3.4",
52
+ "jsdom": "^25.0.1",
48
53
  "postcss": "^8",
49
54
  "tailwindcss": "^3.4.1",
50
55
  "typescript": "^5",