@vobs/devtools-ui 1.0.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.
package/src/panel.tsx ADDED
@@ -0,0 +1,2164 @@
1
+ import {
2
+ getDevTools,
3
+ type ComponentDebugNode,
4
+ type DevToolsAPI,
5
+ type DevToolsErrorTrace,
6
+ type DevToolsRouterContext,
7
+ type DevToolsSelection,
8
+ type EffectExecutionInfo,
9
+ type EffectDebugInfo,
10
+ type LifecycleEvent,
11
+ type NetworkRequestTrace,
12
+ type SignalDebugInfo,
13
+ type UpdateTrace
14
+ } from '@vobs/devtools'
15
+ import { HTTP_KEY, type HTTPClient, type HTTPMethod } from '@vobs/http'
16
+ import type { Router, RouterDevToolsAPI, RouteDebugNode, RouteRecord, RouterDataRequestTrace, RouteErrorTrace } from '@vobs/router'
17
+ import { effect } from '@vobs/reactivity'
18
+ import { createComponent, createElement, createFragment, createText, inject, insertBefore, insertDynamic, onDispose, setAttribute, setProperty, state, type VobsNode } from '@vobs/vobs'
19
+ import { Alert, Button, Card, Icon, Select, Tabs, Tag } from '@vobs/ui'
20
+
21
+ export interface DevToolsSnapshot {
22
+ readonly api: DevToolsAPI | null
23
+ readonly tree: readonly ComponentDebugNode[]
24
+ readonly signals: readonly SignalDebugInfo[]
25
+ readonly effects: readonly EffectDebugInfo[]
26
+ readonly updates: readonly UpdateTrace[]
27
+ readonly lifecycle: readonly LifecycleEvent[]
28
+ readonly network: readonly NetworkRequestTrace[]
29
+ readonly errors: readonly DevToolsErrorTrace[]
30
+ readonly metrics: ReturnType<DevToolsAPI['getPerformanceMetrics']> | null
31
+ readonly performanceEntries: ReturnType<DevToolsAPI['getPerformanceEntries']>
32
+ readonly memory: ReturnType<DevToolsAPI['takeMemorySnapshot']> | null
33
+ readonly router: RouterDevToolsAPI | null
34
+ readonly routerContext: DevToolsRouterContext | null
35
+ }
36
+
37
+ export interface DevToolsPanelProps {
38
+ readonly api?: DevToolsAPI | null
39
+ readonly router?: Router | null
40
+ readonly http?: HTTPClient | null
41
+ readonly query?: { value: string }
42
+ readonly toolbarPlacement?: 'content' | 'header' | 'none'
43
+ }
44
+
45
+ const EMPTY_SNAPSHOT: DevToolsSnapshot = {
46
+ api: null,
47
+ tree: [],
48
+ signals: [],
49
+ effects: [],
50
+ updates: [],
51
+ lifecycle: [],
52
+ network: [],
53
+ errors: [],
54
+ metrics: null,
55
+ performanceEntries: [],
56
+ memory: null,
57
+ router: null,
58
+ routerContext: null
59
+ }
60
+
61
+ // The panel itself performs DOM work and creates reactive effects. Listening to
62
+ // creation/mutation events here would make the inspector refresh itself forever.
63
+ const PANEL_REFRESH_EVENTS = ['signal-update', 'update', 'lifecycle', 'network-request', 'router', 'error', 'collection', 'collection-cleared'] as const
64
+
65
+ export function readDevToolsSnapshot(api: DevToolsAPI | null = getDevTools()): DevToolsSnapshot {
66
+ return readDevToolsSnapshotWithRouter(api, null)
67
+ }
68
+
69
+ function readDevToolsSnapshotWithRouter(api: DevToolsAPI | null, router: Router | null): DevToolsSnapshot {
70
+ if (!api) return { ...EMPTY_SNAPSHOT, router: router?.devtools ?? null }
71
+ return {
72
+ api,
73
+ tree: api.getComponentTree(),
74
+ signals: api.getSignals(),
75
+ effects: api.getEffects(),
76
+ updates: api.getUpdates(),
77
+ lifecycle: api.getLifecycleEvents(),
78
+ network: api.getNetworkRequests(),
79
+ errors: api.getErrors(),
80
+ metrics: api.getPerformanceMetrics(),
81
+ performanceEntries: api.getPerformanceEntries(),
82
+ memory: api.takeMemorySnapshot(),
83
+ router: router?.devtools ?? null,
84
+ routerContext: api.getRouterContext()
85
+ }
86
+ }
87
+
88
+ export function DevToolsPanel(props: DevToolsPanelProps = {}) {
89
+ const refreshCount = state(0)
90
+ const activeSection = state<DevToolsSection>('updates')
91
+ const activeAdvancedSection = state<AdvancedSection>('signals')
92
+ const activeRouterTab = state<RouterPanelTab>('context')
93
+ const activeUpdatesTab = state<UpdatesPanelTab>('updates')
94
+ const activeComponentsTab = state<ComponentsPanelTab>('tree')
95
+ const activeRouteView = state<'tree' | 'list'>('tree')
96
+ const activeTimelineFilter = state<TimelineFilter>('all')
97
+ const networkSelection = state<string | null>(null)
98
+ const networkSourceFilter = state<NetworkSourceFilter>('all')
99
+ const networkStatusFilter = state<NetworkStatusFilter>('all')
100
+ const networkDetailTab = state<NetworkDetailTab>('overview')
101
+ const networkTesterOpen = state(false)
102
+ const networkTesterRevision = state(0)
103
+ const networkTesterRun = state<RequestTesterRun>({ status: 'idle' })
104
+ const networkTesterTab = state<RequestTesterTab>('params')
105
+ let networkTesterDraft = createRequestTesterDraft()
106
+ const selection = state<DevToolsSelection | null>(null)
107
+ const query = props.query ?? state('')
108
+ const http = props.http ?? inject(HTTP_KEY) ?? null
109
+ let refreshInvalidating = false
110
+
111
+ const notifyRefresh = (): void => {
112
+ // The panel's own local signals are also visible to the runtime hooks. Do
113
+ // not recursively invalidate it while handling the update it just caused.
114
+ if (refreshInvalidating) return
115
+ refreshInvalidating = true
116
+ refreshCount.value++
117
+ queueMicrotask(() => { refreshInvalidating = false })
118
+ }
119
+
120
+ // Event callbacks only invalidate the panel. The next reactive render reads a fresh snapshot.
121
+ queueMicrotask(notifyRefresh)
122
+ const api = resolveApi(props)
123
+ if (api) {
124
+ const stops = PANEL_REFRESH_EVENTS.map(event => api.subscribe(event, notifyRefresh))
125
+ onDispose(() => { for (const stop of stops) stop() })
126
+ }
127
+ const router = props.router ?? null
128
+ if (router) {
129
+ const detachRouter = api?.attachRouter(router)
130
+ const stopNavigationStart = router.devtools.subscribe('navigation:start', notifyRefresh)
131
+ const stopNavigation = router.devtools.subscribe('navigation:end', notifyRefresh)
132
+ const stopRouteUpdate = router.devtools.subscribe('route:update', notifyRefresh)
133
+ const stopRouteError = router.devtools.subscribe('error', notifyRefresh)
134
+ onDispose(() => { stopNavigationStart(); stopNavigation(); stopRouteUpdate(); stopRouteError(); detachRouter?.() })
135
+ }
136
+
137
+ const toolbarPlacement = props.toolbarPlacement ?? 'content'
138
+ return <div class="vobs-devtools-shell">
139
+ <aside class="vobs-devtools-shell__rail" aria-label="DevTools sections">
140
+ <div class="vobs-devtools-shell__mark"><Icon name="code" /></div>
141
+ <SectionNav activeSection={activeSection} />
142
+ </aside>
143
+ <section class="vobs-devtools-shell__main">
144
+ <div class="vobs-devtools-shell__content">{toolbarPlacement === 'content' ? <DevToolsToolbar api={api} query={query} /> : null}<DevToolsContent refreshCount={refreshCount} activeSection={activeSection} activeAdvancedSection={activeAdvancedSection} activeRouterTab={activeRouterTab} activeUpdatesTab={activeUpdatesTab} activeComponentsTab={activeComponentsTab} activeRouteView={activeRouteView} router={router} api={api} http={http} selection={selection} query={query} networkSelection={networkSelection} networkSourceFilter={networkSourceFilter} networkStatusFilter={networkStatusFilter} networkDetailTab={networkDetailTab} networkTesterOpen={networkTesterOpen} networkTesterRevision={networkTesterRevision} networkTesterRun={networkTesterRun} networkTesterDraft={networkTesterDraft} networkTesterTab={networkTesterTab} /></div>
145
+ </section>
146
+ <ActivityRail refreshCount={refreshCount} router={router} api={api} selection={selection} activeSection={activeSection} activeFilter={activeTimelineFilter} />
147
+ </div>
148
+ }
149
+
150
+ export function DevToolsToolbar(props: { readonly api: DevToolsAPI | null; readonly query: { value: string }; readonly maximized?: boolean; readonly onToggleMaximize?: () => void }): VobsNode {
151
+ const refreshCount = state(0)
152
+ if (props.api) {
153
+ const stops = ['collection', 'collection-cleared'].map(event => props.api!.subscribe(event, () => { refreshCount.value++ }))
154
+ onDispose(() => { for (const stop of stops) stop() })
155
+ }
156
+ return createFragment((parent, anchor) => {
157
+ const toolbar = createElement('div')
158
+ setAttribute(toolbar, 'class', 'vobs-devtools-collection-toolbar vobs-devtools-collection-toolbar--header')
159
+ const search = createElement('input')
160
+ setAttribute(search, 'class', 'vobs-devtools-search vobs-devtools-header-search')
161
+ setAttribute(search, 'type', 'search')
162
+ setAttribute(search, 'placeholder', 'Search diagnostics')
163
+ // This must be its own reactive effect. Reading query while mounting the
164
+ // dialog header makes the header slot depend on the query and replaces the
165
+ // focused input after every keystroke.
166
+ effect(() => {
167
+ const next = props.query.value
168
+ if ((search as HTMLInputElement).value !== next) setProperty(search, 'value', next)
169
+ })
170
+ search.addEventListener('input', () => { props.query.value = (search as HTMLInputElement).value })
171
+ insertBefore(toolbar, search, null)
172
+ const actions = createElement('div')
173
+ setAttribute(actions, 'class', 'vobs-devtools-collection-toolbar__actions')
174
+ insertBefore(toolbar, actions, null)
175
+ insertBefore(parent, toolbar, anchor)
176
+
177
+ insertDynamic(actions, null, () => {
178
+ void refreshCount.value
179
+ if (!props.api) return null
180
+ const paused = props.api.getCollectionState().paused
181
+ return createFragment((actionParent, actionAnchor) => {
182
+ insertBefore(actionParent, createComponent(Button, {
183
+ variant: paused ? 'warning' : 'secondary',
184
+ iconOnly: true,
185
+ icon: createComponent(Icon, { name: paused ? 'play' : 'pause' }),
186
+ 'aria-label': paused ? 'Resume collection' : 'Pause collection',
187
+ title: paused ? 'Resume collection' : 'Pause collection',
188
+ onClick: () => props.api?.setCollectionPaused(!paused)
189
+ }), actionAnchor)
190
+ insertBefore(actionParent, createComponent(Button, {
191
+ variant: 'danger-subtle',
192
+ iconOnly: true,
193
+ icon: createComponent(Icon, { name: 'trash' }),
194
+ 'aria-label': 'Clear diagnostics',
195
+ title: 'Clear diagnostics',
196
+ onClick: () => {
197
+ props.api?.clearUpdates()
198
+ props.api?.clearNetworkRequests()
199
+ props.api?.clearErrors()
200
+ props.api?.clearLifecycleEvents()
201
+ }
202
+ }), actionAnchor)
203
+ const exportDiagnostics = (): void => {
204
+ const data = JSON.stringify(props.api?.exportDiagnostics(), null, 2)
205
+ if (typeof document === 'undefined' || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function' || typeof URL.revokeObjectURL !== 'function' || typeof Blob === 'undefined') return
206
+ const link = document.createElement('a')
207
+ link.href = URL.createObjectURL(new Blob([data], { type: 'application/json' }))
208
+ link.download = `vobs-devtools-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
209
+ link.click()
210
+ URL.revokeObjectURL(link.href)
211
+ }
212
+ insertBefore(actionParent, createComponent(Button, {
213
+ variant: 'ghost',
214
+ iconOnly: true,
215
+ icon: createComponent(Icon, { name: 'download' }),
216
+ 'aria-label': 'Export diagnostics',
217
+ title: 'Export diagnostics',
218
+ onClick: exportDiagnostics
219
+ }), actionAnchor)
220
+ const importInput = createElement('input')
221
+ setAttribute(importInput, 'type', 'file')
222
+ setAttribute(importInput, 'accept', 'application/json,.json')
223
+ setAttribute(importInput, 'aria-label', 'Import diagnostics')
224
+ setAttribute(importInput, 'hidden', '')
225
+ importInput.addEventListener('change', () => {
226
+ const file = (importInput as HTMLInputElement).files?.[0]
227
+ if (!file) return
228
+ void file.text().then(text => props.api?.importDiagnostics(JSON.parse(text))).catch(error => props.api?.reportError('global', error))
229
+ })
230
+ insertBefore(actionParent, importInput, actionAnchor)
231
+ insertBefore(actionParent, createComponent(Button, {
232
+ variant: 'ghost',
233
+ iconOnly: true,
234
+ icon: createComponent(Icon, { name: 'upload' }),
235
+ 'aria-label': 'Import diagnostics',
236
+ title: 'Import diagnostics',
237
+ onClick: () => (importInput as HTMLInputElement).click()
238
+ }), actionAnchor)
239
+ if (props.onToggleMaximize) insertBefore(actionParent, createComponent(Button, {
240
+ variant: 'ghost',
241
+ iconOnly: true,
242
+ icon: createComponent(Icon, { name: props.maximized ? 'arrow-minimize' : 'arrow-expand' }),
243
+ 'aria-label': props.maximized ? 'Restore DevTools' : 'Maximize DevTools',
244
+ title: props.maximized ? 'Restore DevTools' : 'Maximize DevTools',
245
+ onClick: props.onToggleMaximize
246
+ }), actionAnchor)
247
+ })
248
+ })
249
+ })
250
+ }
251
+
252
+ type DevToolsSection = 'router' | 'components' | 'advanced' | 'updates' | 'network' | 'errors'
253
+ type AdvancedSection = 'signals' | 'effects'
254
+ type RouterPanelTab = 'context' | 'requests' | 'errors' | 'history' | 'routes'
255
+ type UpdatesPanelTab = 'performance' | 'slow' | 'updates'
256
+ type ComponentsPanelTab = 'tree' | 'lifecycle'
257
+ type TimelineFilter = 'all' | 'update' | 'request' | 'navigation' | 'error'
258
+ type NetworkSourceFilter = 'all' | 'http' | 'router' | 'ssr'
259
+ type NetworkStatusFilter = 'all' | 'loading' | 'success' | 'error' | 'cancelled'
260
+ type NetworkDetailTab = 'overview' | 'headers' | 'payload' | 'response' | 'timing' | 'context'
261
+
262
+ interface NetworkEntry {
263
+ readonly key: string
264
+ readonly source: 'http' | 'router' | 'ssr'
265
+ readonly method: string
266
+ readonly url: string
267
+ readonly status: string
268
+ readonly duration?: number
269
+ readonly startedAt?: number
270
+ readonly endedAt?: number
271
+ readonly request?: NetworkRequestTrace
272
+ readonly routerRequest?: DevToolsRouterContext['dataRequests'][number]
273
+ }
274
+
275
+ interface RequestTesterParam {
276
+ key: string
277
+ value: string
278
+ }
279
+
280
+ interface RequestTesterDraft {
281
+ url: string
282
+ method: HTTPMethod
283
+ params: RequestTesterParam[]
284
+ headers: RequestTesterParam[]
285
+ body: string
286
+ }
287
+
288
+ type RequestTesterRun = { status: 'idle' | 'running' | 'success' | 'error'; message?: string }
289
+ type RequestTesterTab = 'params' | 'headers' | 'body'
290
+
291
+ function createRequestTesterDraft(url = ''): RequestTesterDraft {
292
+ return { url, method: 'GET', params: [{ key: '', value: '' }], headers: [{ key: '', value: '' }], body: '' }
293
+ }
294
+
295
+ const TIMELINE_FILTER_OPTIONS: readonly { readonly value: TimelineFilter; readonly label: string }[] = [
296
+ { value: 'all', label: 'All events' },
297
+ { value: 'update', label: 'Updates' },
298
+ { value: 'request', label: 'Requests' },
299
+ { value: 'navigation', label: 'Navigation' },
300
+ { value: 'error', label: 'Errors' }
301
+ ]
302
+
303
+ function resolveApi(props: DevToolsPanelProps): DevToolsAPI | null {
304
+ return props.api === undefined ? getDevTools() : props.api
305
+ }
306
+
307
+ interface DevToolsContentProps {
308
+ readonly refreshCount: { readonly value: number }
309
+ readonly activeSection: { readonly value: DevToolsSection }
310
+ readonly activeAdvancedSection: { value: AdvancedSection }
311
+ readonly activeRouterTab: { value: RouterPanelTab }
312
+ readonly activeUpdatesTab: { value: UpdatesPanelTab }
313
+ readonly activeComponentsTab: { value: ComponentsPanelTab }
314
+ readonly activeRouteView: { value: 'tree' | 'list' }
315
+ readonly router: Router | null
316
+ readonly api: DevToolsAPI | null
317
+ readonly http: HTTPClient | null
318
+ readonly selection: { value: DevToolsSelection | null }
319
+ readonly query: { value: string }
320
+ readonly networkSelection: { value: string | null }
321
+ readonly networkSourceFilter: { value: NetworkSourceFilter }
322
+ readonly networkStatusFilter: { value: NetworkStatusFilter }
323
+ readonly networkDetailTab: { value: NetworkDetailTab }
324
+ readonly networkTesterOpen: { value: boolean }
325
+ readonly networkTesterRevision: { value: number }
326
+ readonly networkTesterRun: { value: RequestTesterRun }
327
+ readonly networkTesterDraft: RequestTesterDraft
328
+ readonly networkTesterTab: { value: RequestTesterTab }
329
+ }
330
+
331
+ interface ActivityRailProps {
332
+ readonly refreshCount: { readonly value: number }
333
+ readonly router: Router | null
334
+ readonly api: DevToolsAPI | null
335
+ readonly selection: { value: DevToolsSelection | null }
336
+ readonly activeSection: { value: DevToolsSection }
337
+ readonly activeFilter: { value: TimelineFilter }
338
+ }
339
+
340
+ function DevToolsContent(props: DevToolsContentProps) {
341
+ return createFragment((parent, anchor) => {
342
+ insertDynamic(parent, anchor, () => {
343
+ void props.refreshCount.value
344
+ void props.activeSection.value
345
+ void props.activeRouterTab.value
346
+ void props.activeUpdatesTab.value
347
+ void props.activeComponentsTab.value
348
+ void props.activeRouteView.value
349
+ void props.query.value
350
+ void props.networkSelection.value
351
+ void props.networkSourceFilter.value
352
+ void props.networkStatusFilter.value
353
+ void props.networkDetailTab.value
354
+ void props.networkTesterOpen.value
355
+ void props.networkTesterRevision.value
356
+ void props.networkTesterRun.value
357
+ void props.networkTesterTab.value
358
+ const selected = props.selection.value
359
+ const snapshot = readDevToolsSnapshotWithRouter(props.api, props.router)
360
+ return renderDevToolsContent(snapshot, props.activeSection.value, props.router, props.activeRouterTab, props.activeUpdatesTab, props.activeComponentsTab, selected, focusSelection(props.activeSection, props.selection), props.query.value, props.activeAdvancedSection, props.activeRouteView, props.networkSelection, props.networkSourceFilter, props.networkStatusFilter, props.networkDetailTab, props.query, props.http, props.networkTesterOpen, props.networkTesterRevision, props.networkTesterRun, props.networkTesterDraft, props.networkTesterTab)
361
+ })
362
+ })
363
+ }
364
+
365
+ function ActivityRail(props: ActivityRailProps) {
366
+ return createFragment((parent, anchor) => {
367
+ insertDynamic(parent, anchor, () => {
368
+ void props.refreshCount.value
369
+ const filter = props.activeFilter.value
370
+ const selected = props.selection.value
371
+ const snapshot = readDevToolsSnapshotWithRouter(props.api, props.router)
372
+ const updates = [...snapshot.updates].reverse().slice(0, 8)
373
+ const timeline = collectActivityTimeline(snapshot, props.router, filter)
374
+ return renderActivityRail(updates, timeline, snapshot.api, selected, filter, next => { props.activeFilter.value = next }, focusSelection(props.activeSection, props.selection))
375
+ })
376
+ })
377
+ }
378
+
379
+ function focusSelection(
380
+ activeSection: { value: DevToolsSection },
381
+ selection: { value: DevToolsSelection | null }
382
+ ): (section: DevToolsSection, next: DevToolsSelection) => void {
383
+ return (section, next) => {
384
+ selection.value = next
385
+ activeSection.value = section
386
+ }
387
+ }
388
+
389
+ function SectionNav(props: { readonly activeSection: { value: DevToolsSection } }): VobsNode {
390
+ return createFragment((parent, anchor) => {
391
+ insertDynamic(parent, anchor, () => {
392
+ const selected = props.activeSection.value
393
+ return createFragment((navParent, navAnchor) => {
394
+ for (const section of ['updates', 'components', 'advanced', 'network', 'errors', 'router'] as const) {
395
+ const label = section === 'components' ? 'comp...' : section === 'advanced' ? 'adv...' : section
396
+ const button = createElement('button')
397
+ setAttribute(button, 'class', `vobs-devtools-shell__nav-item${selected === section ? ' is-active' : ''}`)
398
+ setAttribute(button, 'type', 'button')
399
+ const accessibleLabel = section === 'advanced' ? 'Advanced' : section
400
+ setAttribute(button, 'aria-label', accessibleLabel)
401
+ setAttribute(button, 'title', accessibleLabel)
402
+ setAttribute(button, 'aria-pressed', selected === section ? 'true' : 'false')
403
+ insertBefore(button, createComponent(Icon, { name: section === 'router' ? 'folder' : section === 'components' ? 'code' : section === 'advanced' ? 'atom' : section === 'network' ? 'globe' : section === 'errors' ? 'alert-triangle' : 'refresh' }), null)
404
+ insertBefore(button, createElementText(label), null)
405
+ button.addEventListener('click', () => { props.activeSection.value = section })
406
+ insertBefore(navParent, button, navAnchor)
407
+ }
408
+ })
409
+ })
410
+ })
411
+ }
412
+
413
+ interface ActivityTimelineItem {
414
+ readonly kind: Exclude<TimelineFilter, 'all'>
415
+ readonly timestamp: number
416
+ readonly title: string
417
+ readonly detail?: string
418
+ readonly icon: string
419
+ readonly update?: UpdateTrace
420
+ }
421
+
422
+ function collectActivityTimeline(snapshot: DevToolsSnapshot, router: Router | null, filter: TimelineFilter = 'all'): readonly ActivityTimelineItem[] {
423
+ const items: ActivityTimelineItem[] = snapshot.updates.map(update => ({
424
+ kind: 'update',
425
+ timestamp: update.timestamp,
426
+ title: snapshot.api?.getSignal(update.signalId) ? displaySignalName(snapshot.api.getSignal(update.signalId)!) : displayDebugName(update.signalName),
427
+ detail: `${formatDebugSource(snapshot.api?.getSignal(update.signalId)?.component ?? 'unknown')} · ${update.duration.toFixed(2)} ms · ${update.effects.length} effects`,
428
+ icon: 'zap',
429
+ update
430
+ }))
431
+ for (const request of snapshot.network) {
432
+ items.push({
433
+ kind: 'request',
434
+ timestamp: request.startedAt,
435
+ title: `${request.method} ${request.url}`,
436
+ detail: `${request.source ?? 'http'} · ${request.status}${request.duration === undefined ? '' : ` · ${request.duration} ms`}`,
437
+ icon: 'download'
438
+ })
439
+ }
440
+ for (const request of snapshot.routerContext?.dataRequests ?? []) {
441
+ items.push({
442
+ kind: 'request',
443
+ timestamp: request.startedAt ?? Date.now(),
444
+ title: `${request.kind} · ${request.key}`,
445
+ detail: `${request.status}${request.route ? ` · ${request.route}` : ''}`,
446
+ icon: 'folder'
447
+ })
448
+ }
449
+ for (const trace of router?.devtools.getNavigationHistory() ?? []) {
450
+ items.push({
451
+ kind: 'navigation',
452
+ timestamp: trace.endedAt,
453
+ title: `Navigation ${trace.to}`,
454
+ detail: `${trace.status} · ${trace.duration.toFixed(2)} ms`,
455
+ icon: 'arrow-right'
456
+ })
457
+ }
458
+ for (const error of snapshot.errors) {
459
+ items.push({
460
+ kind: 'error',
461
+ timestamp: error.lastOccurredAt,
462
+ title: `${error.phase} · ${error.message}`,
463
+ detail: error.route ?? error.component,
464
+ icon: 'alert-triangle'
465
+ })
466
+ }
467
+ return items
468
+ .filter(item => filter === 'all' || item.kind === filter)
469
+ .sort((left, right) => right.timestamp - left.timestamp)
470
+ .slice(0, 12)
471
+ }
472
+
473
+ function renderActivityRail(
474
+ updates: readonly UpdateTrace[],
475
+ timeline: readonly ActivityTimelineItem[],
476
+ api: DevToolsAPI | null,
477
+ selection: DevToolsSelection | null,
478
+ filter: TimelineFilter,
479
+ onFilterChange: (filter: TimelineFilter) => void,
480
+ onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void
481
+ ): VobsNode {
482
+ const root = createElement('aside')
483
+ setAttribute(root, 'class', 'vobs-devtools-shell__activity')
484
+ const header = createElement('div')
485
+ setAttribute(header, 'class', 'vobs-devtools-shell__activity-header')
486
+ const title = createElement('div')
487
+ setAttribute(title, 'class', 'vobs-devtools-shell__activity-title')
488
+ insertBefore(title, createElementText('Timeline'), null)
489
+ insertBefore(header, title, null)
490
+ insertBefore(header, createComponent(Select, {
491
+ class: 'vobs-devtools-timeline-filter',
492
+ value: filter,
493
+ 'aria-label': 'Filter timeline events',
494
+ onChange: event => {
495
+ const next = (event.target as HTMLSelectElement).value
496
+ if (isTimelineFilter(next)) onFilterChange(next)
497
+ },
498
+ children: () => createFragment((parent, anchor) => {
499
+ for (const option of TIMELINE_FILTER_OPTIONS) {
500
+ const optionNode = createElement('option')
501
+ setAttribute(optionNode, 'value', option.value)
502
+ insertBefore(optionNode, createText(option.label), null)
503
+ insertBefore(parent, optionNode, anchor)
504
+ }
505
+ })
506
+ }), null)
507
+ insertBefore(root, header, null)
508
+ const subtitle = createElement('div')
509
+ setAttribute(subtitle, 'class', 'vobs-devtools-shell__activity-subtitle')
510
+ insertBefore(subtitle, createElementText(`${timeline.length} shown · ${updates.length} updates`), null)
511
+ insertBefore(root, subtitle, null)
512
+ const list = createElement('div')
513
+ setAttribute(list, 'class', 'vobs-devtools-activity-list')
514
+ for (const entry of timeline) {
515
+ const update = entry.update
516
+ const activityItem = createElement('div')
517
+ setAttribute(activityItem, 'class', `vobs-devtools-activity-item${update && selection?.type === 'update' && selection.id === update.id ? ' is-selected' : ''}`)
518
+ if (update) activityItem.addEventListener('click', () => onFocus('updates', { type: 'update', id: update.id }))
519
+ const dot = createElement('span')
520
+ setAttribute(dot, 'class', 'vobs-devtools-activity-item__dot')
521
+ insertBefore(dot, createComponent(Icon, { name: entry.icon }), null)
522
+ const body = createElement('div')
523
+ setAttribute(body, 'class', 'vobs-devtools-activity-item__body')
524
+ const name = createElement('span')
525
+ setAttribute(name, 'class', 'vobs-devtools-activity-item__name')
526
+ const signal = update ? api?.getSignal(update.signalId) : undefined
527
+ insertBefore(name, createElementText(entry.title), null)
528
+ const detail = createElement('span')
529
+ insertBefore(detail, createElementText(entry.detail ?? (update ? `${formatDebugSource(signal?.component ?? 'unknown')} · ${update.duration.toFixed(2)} ms · ${update.effects.length} effects` : '')), null)
530
+ insertBefore(body, name, null)
531
+ insertBefore(body, detail, null)
532
+ insertBefore(activityItem, dot, null)
533
+ insertBefore(activityItem, body, null)
534
+ insertBefore(list, activityItem, null)
535
+ }
536
+ if (timeline.length === 0) {
537
+ const empty = createElement('span')
538
+ setAttribute(empty, 'class', 'vobs-devtools-muted')
539
+ insertBefore(empty, createElementText(filter === 'all' ? 'No recent events.' : `No ${timelineFilterLabel(filter).toLowerCase()} events.`), null)
540
+ insertBefore(list, empty, null)
541
+ }
542
+ insertBefore(root, list, null)
543
+ return root
544
+ }
545
+
546
+ function isTimelineFilter(value: string): value is TimelineFilter {
547
+ return TIMELINE_FILTER_OPTIONS.some(option => option.value === value)
548
+ }
549
+
550
+ function timelineFilterLabel(filter: TimelineFilter): string {
551
+ return TIMELINE_FILTER_OPTIONS.find(option => option.value === filter)?.label ?? 'All events'
552
+ }
553
+
554
+ function createElementText(value: string): VobsNode {
555
+ return createText(value)
556
+ }
557
+
558
+ function renderDevToolsContent(
559
+ snapshot: DevToolsSnapshot,
560
+ section: DevToolsSection = 'updates',
561
+ router: Router | null = null,
562
+ activeRouterTab?: { value: RouterPanelTab },
563
+ activeUpdatesTab?: { value: UpdatesPanelTab },
564
+ activeComponentsTab?: { value: ComponentsPanelTab },
565
+ selection: DevToolsSelection | null = null,
566
+ onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void = () => undefined,
567
+ query = '',
568
+ activeAdvancedSection?: { value: AdvancedSection },
569
+ activeRouteView?: { value: 'tree' | 'list' },
570
+ networkSelection?: { value: string | null },
571
+ networkSourceFilter?: { value: NetworkSourceFilter },
572
+ networkStatusFilter?: { value: NetworkStatusFilter },
573
+ networkDetailTab?: { value: NetworkDetailTab },
574
+ queryState?: { value: string },
575
+ http?: HTTPClient | null,
576
+ networkTesterOpen?: { value: boolean },
577
+ networkTesterRevision?: { value: number },
578
+ networkTesterRun?: { value: RequestTesterRun },
579
+ networkTesterDraft?: RequestTesterDraft,
580
+ networkTesterTab?: { value: RequestTesterTab }
581
+ ): VobsNode {
582
+ if (!snapshot.api) {
583
+ return createComponent(Alert, {
584
+ description: 'Enable the devtools plugin to inspect the runtime.'
585
+ })
586
+ }
587
+ return renderFocusedSection(snapshot, section, router, activeRouterTab, activeUpdatesTab, activeComponentsTab, selection, onFocus, query, activeAdvancedSection, activeRouteView, networkSelection, networkSourceFilter, networkStatusFilter, networkDetailTab, queryState, http, networkTesterOpen, networkTesterRevision, networkTesterRun, networkTesterDraft, networkTesterTab)
588
+ }
589
+
590
+ function renderFocusedSection(
591
+ snapshot: DevToolsSnapshot,
592
+ section: DevToolsSection,
593
+ router: Router | null,
594
+ activeRouterTab?: { value: RouterPanelTab },
595
+ activeUpdatesTab?: { value: UpdatesPanelTab },
596
+ activeComponentsTab?: { value: ComponentsPanelTab },
597
+ selection: DevToolsSelection | null = null,
598
+ onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void = () => undefined,
599
+ query = '',
600
+ activeAdvancedSection?: { value: AdvancedSection },
601
+ activeRouteView?: { value: 'tree' | 'list' },
602
+ networkSelection?: { value: string | null },
603
+ networkSourceFilter?: { value: NetworkSourceFilter },
604
+ networkStatusFilter?: { value: NetworkStatusFilter },
605
+ networkDetailTab?: { value: NetworkDetailTab },
606
+ queryState?: { value: string },
607
+ http?: HTTPClient | null,
608
+ networkTesterOpen?: { value: boolean },
609
+ networkTesterRevision?: { value: number },
610
+ networkTesterRun?: { value: RequestTesterRun },
611
+ networkTesterDraft?: RequestTesterDraft,
612
+ networkTesterTab?: { value: RequestTesterTab }
613
+ ): VobsNode {
614
+ if (section === 'router') return renderRouterSection(router, activeRouterTab, activeRouteView)
615
+ if (section === 'updates') return renderUpdatesSection(snapshot, activeUpdatesTab, query, selection, onFocus)
616
+ if (section === 'components') return renderComponentsSection(snapshot, activeComponentsTab, query, selection, onFocus)
617
+ if (section === 'network') return createComponent(Card, {
618
+ children: () => renderUnifiedNetworkRequests(snapshot.network, snapshot.routerContext?.dataRequests ?? [], query, selection, onFocus, networkSelection, networkSourceFilter, networkStatusFilter, networkDetailTab, snapshot.api, queryState, http, networkTesterOpen, networkTesterRevision, networkTesterRun, networkTesterDraft, snapshot.routerContext?.route, networkTesterTab)
619
+ })
620
+ if (section === 'errors') return createComponent(Card, {
621
+ description: `${snapshot.errors.length} retained unique errors`,
622
+ children: () => renderErrors(filterErrors(snapshot.errors, query), selection, onFocus)
623
+ })
624
+ if (section === 'advanced') return renderAdvancedSection(snapshot, activeAdvancedSection, query)
625
+ const list = createElement('div')
626
+ setAttribute(list, 'class', 'vobs-devtools-list')
627
+ let title = 'Recent updates'
628
+ let description = `${snapshot.updates.length} retained traces`
629
+ return createComponent(Card, { title, description, children: () => list })
630
+ }
631
+
632
+ function renderComponentsSection(
633
+ snapshot: DevToolsSnapshot,
634
+ activeTab?: { value: ComponentsPanelTab },
635
+ query = '',
636
+ selection: DevToolsSelection | null = null,
637
+ onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void = () => undefined
638
+ ): VobsNode {
639
+ const tabs = createComponent(Tabs, {
640
+ class: 'vobs-devtools-router-tabs',
641
+ variant: 'filled',
642
+ items: [
643
+ { id: 'tree', label: 'Component tree', content: () => renderComponentTree(snapshot, query, selection, onFocus) },
644
+ { id: 'lifecycle', label: 'Lifecycle timeline', content: () => createComponent(Card, {
645
+ class: 'vobs-devtools-lifecycle-card',
646
+ description: `${snapshot.lifecycle.length} retained runtime events`,
647
+ children: () => renderLifecycleTimeline(snapshot.lifecycle, (section, next) => {
648
+ onFocus(section, next)
649
+ if (section === 'components' && activeTab) activeTab.value = 'tree'
650
+ })
651
+ }) }
652
+ ],
653
+ get value() { return activeTab?.value ?? 'tree' },
654
+ onChange: id => { if (activeTab && isComponentsPanelTab(id)) activeTab.value = id }
655
+ })
656
+ const root = createElement('div')
657
+ setAttribute(root, 'class', 'vobs-devtools-components-tabs-wrap')
658
+ insertBefore(root, tabs, null)
659
+ return root
660
+ }
661
+
662
+ function renderComponentTree(
663
+ snapshot: DevToolsSnapshot,
664
+ query: string,
665
+ selection: DevToolsSelection | null,
666
+ onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void
667
+ ): VobsNode {
668
+ const componentTree = filterComponentTree(snapshot.tree, query)
669
+ const list = createElement('div')
670
+ setAttribute(list, 'class', 'vobs-devtools-list')
671
+ for (const node of componentTree) insertBefore(list, renderComponentSummary(node, snapshot, selection, onFocus), null)
672
+ if (componentTree.length === 0) appendMuted(list, query ? 'No components match the search.' : 'No components recorded.')
673
+ return createComponent(Card, {
674
+ class: 'vobs-devtools-component-tree-card',
675
+ description: `Component tree ${snapshot.memory?.ownerCount ?? 0} active owners`,
676
+ children: () => list
677
+ })
678
+ }
679
+
680
+ function isComponentsPanelTab(value: string): value is ComponentsPanelTab {
681
+ return value === 'tree' || value === 'lifecycle'
682
+ }
683
+
684
+ function renderUpdatesSection(
685
+ snapshot: DevToolsSnapshot,
686
+ activeTab?: { value: UpdatesPanelTab },
687
+ query = '',
688
+ selection: DevToolsSelection | null = null,
689
+ onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void = () => undefined
690
+ ): VobsNode {
691
+ const tabs = createComponent(Tabs, {
692
+ class: 'vobs-devtools-router-tabs',
693
+ variant: 'filled',
694
+ items: [
695
+ { id: 'performance', label: 'Performance', content: () => renderPerformanceMetrics(snapshot.metrics) },
696
+ { id: 'slow', label: 'Slow items', content: () => renderSlowItems(snapshot.performanceEntries) },
697
+ { id: 'updates', label: `Updates${snapshot.updates.length ? ` (${snapshot.updates.length})` : ''}`, content: () => renderUpdatesList(snapshot, query, selection, onFocus) }
698
+ ],
699
+ get value() { return activeTab?.value ?? 'updates' },
700
+ onChange: id => { if (activeTab && isUpdatesPanelTab(id)) activeTab.value = id }
701
+ })
702
+ const root = createElement('div')
703
+ setAttribute(root, 'class', 'vobs-devtools-updates-tabs-wrap')
704
+ insertBefore(root, tabs, null)
705
+ return root
706
+ }
707
+
708
+ function isUpdatesPanelTab(value: string): value is UpdatesPanelTab {
709
+ return value === 'performance' || value === 'slow' || value === 'updates'
710
+ }
711
+
712
+ function renderUpdatesList(
713
+ snapshot: DevToolsSnapshot,
714
+ query: string,
715
+ selection: DevToolsSelection | null,
716
+ onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void
717
+ ): VobsNode {
718
+ const list = createElement('div')
719
+ setAttribute(list, 'class', 'vobs-devtools-list')
720
+ for (const update of [...snapshot.updates].reverse().filter(update => matchesQuery(query, update.signalName, update.signalId, update.status, formatValue(update.previousValue), formatValue(update.nextValue))).slice(0, 50)) {
721
+ insertBefore(list, renderUpdateRow(update, snapshot.api, selection, onFocus), null)
722
+ }
723
+ if (snapshot.updates.length === 0) appendMuted(list, 'Interact with the playground to record updates.')
724
+ return createComponent(Card, {
725
+ class: 'vobs-devtools-updates-card',
726
+ children: () => list
727
+ })
728
+ }
729
+
730
+ function renderAdvancedSection(
731
+ snapshot: DevToolsSnapshot,
732
+ activeSection?: { value: AdvancedSection },
733
+ query = ''
734
+ ): VobsNode {
735
+ const tabs = createComponent(Tabs, {
736
+ class: 'vobs-devtools-advanced-tabs',
737
+ variant: 'filled',
738
+ items: [
739
+ { id: 'signals', label: 'Signals', content: () => renderSignalsInspector(snapshot, query) },
740
+ { id: 'effects', label: 'Effects', content: () => renderEffectsInspector(snapshot, query) }
741
+ ],
742
+ get value() { return activeSection?.value ?? 'signals' },
743
+ onChange: id => { if (activeSection) activeSection.value = id as AdvancedSection }
744
+ })
745
+ const root = createElement('div')
746
+ setAttribute(root, 'class', 'vobs-devtools-advanced')
747
+ insertBefore(root, tabs, null)
748
+ return root
749
+ }
750
+
751
+ function renderSignalsInspector(snapshot: DevToolsSnapshot, query: string): VobsNode {
752
+ const list = createElement('div')
753
+ setAttribute(list, 'class', 'vobs-devtools-list')
754
+ const signals = snapshot.signals.filter(signal => matchesQuery(query, signal.name, signal.component, formatValue(signal.value))).slice(0, 50)
755
+ for (const signal of signals) insertBefore(list, renderSignalInspector(signal, snapshot.api), null)
756
+ if (signals.length === 0) appendMuted(list, query ? 'No signals match the search.' : 'No signals recorded.')
757
+ return list
758
+ }
759
+
760
+ function renderEffectsInspector(snapshot: DevToolsSnapshot, query: string): VobsNode {
761
+ const list = createElement('div')
762
+ setAttribute(list, 'class', 'vobs-devtools-list')
763
+ const effects = snapshot.effects.filter(effect => matchesQuery(query, effect.name, effect.component, effect.id)).slice(0, 50)
764
+ for (const effect of effects) insertBefore(list, renderEffectRow(effect), null)
765
+ if (effects.length === 0) appendMuted(list, query ? 'No effects match the search.' : 'No effects recorded.')
766
+ return list
767
+ }
768
+
769
+ function renderPerformanceMetrics(metrics: DevToolsSnapshot['metrics']): VobsNode {
770
+ if (!metrics) {
771
+ return createComponent(Card, { description: 'Performance: Lightweight slow-path summary for the retained diagnostics.', children: () => {
772
+ const root = createElement('div')
773
+ appendMuted(root, 'No performance data available.')
774
+ return root
775
+ } })
776
+ }
777
+ const metricsGrid = createElement('div')
778
+ setAttribute(metricsGrid, 'class', 'vobs-devtools-performance-metrics')
779
+ appendMetric(metricsGrid, 'Slow updates', metrics.slowUpdateCount)
780
+ appendMetric(metricsGrid, 'Slow effects', metrics.slowEffectCount)
781
+ appendMetric(metricsGrid, 'Slow requests', metrics.slowRequestCount)
782
+ appendMetric(metricsGrid, 'Max update', `${metrics.maxUpdateDuration.toFixed(2)} ms`)
783
+ appendMetric(metricsGrid, 'Max effect', `${metrics.maxEffectDuration.toFixed(2)} ms`)
784
+ appendMetric(metricsGrid, 'Max request', `${metrics.maxRequestDuration.toFixed(2)} ms`)
785
+ return createComponent(Card, {
786
+ class: 'vobs-devtools-performance-card',
787
+ description: 'Lightweight slow-path summary for the retained diagnostics.',
788
+ children: () => metricsGrid
789
+ })
790
+ }
791
+
792
+ function renderSlowItems(entries: DevToolsSnapshot['performanceEntries']): VobsNode {
793
+ const slowEntries = entries.filter(entry => entry.duration >= 16).slice(0, 8)
794
+ const slowList = createElement('div')
795
+ setAttribute(slowList, 'class', 'vobs-devtools-performance-slow-list')
796
+ for (const entry of slowEntries) {
797
+ const item = createElement('div')
798
+ setAttribute(item, 'class', 'vobs-devtools-performance-slow-item')
799
+ appendText(item, entry.kind, 'vobs-devtools-muted')
800
+ appendText(item, formatPerformanceLabel(entry), 'vobs-devtools-list__name')
801
+ appendText(item, `${entry.duration.toFixed(2)} ms`, 'vobs-devtools-code')
802
+ insertBefore(slowList, item, null)
803
+ }
804
+ if (slowEntries.length === 0) appendMuted(slowList, 'No slow items recorded.')
805
+ return createComponent(Card, {
806
+ class: 'vobs-devtools-performance-slow-card',
807
+ description: slowEntries.length ? `${slowEntries.length} retained item${slowEntries.length === 1 ? '' : 's'} at or above 16 ms.` : undefined,
808
+ children: () => slowList
809
+ })
810
+ }
811
+
812
+ function formatPerformanceLabel(entry: DevToolsSnapshot['performanceEntries'][number]): string {
813
+ if (entry.kind === 'request') return entry.label
814
+ const formatted = formatDebugLocation(entry.label)
815
+ const openParen = formatted.indexOf('(')
816
+ const closeParen = openParen >= 0 ? formatted.indexOf(')', openParen) : -1
817
+ if (openParen < 0 || closeParen < 0) return stripDebugLocation(entry.label)
818
+ const componentName = formatted.slice(0, openParen).trim()
819
+ const source = formatted.slice(openParen + 1, closeParen)
820
+ const fileName = source.split('/').pop() ?? source
821
+ const suffix = formatted.slice(closeParen + 1).trim()
822
+ return [componentName, fileName, suffix].filter(Boolean).join(' ')
823
+ }
824
+
825
+ function appendMetric(parent: Element, label: string, value: number | string): void {
826
+ const row = createElement('div')
827
+ setAttribute(row, 'class', 'vobs-devtools-performance-item')
828
+ appendText(row, label, 'vobs-devtools-muted')
829
+ appendText(row, String(value), 'vobs-devtools-list__name')
830
+ insertBefore(parent, row, null)
831
+ }
832
+
833
+ function renderRouterSection(
834
+ router: Router | null,
835
+ activeRouterTab?: { value: RouterPanelTab },
836
+ activeRouteView?: { value: 'tree' | 'list' }
837
+ ): VobsNode {
838
+ if (!router) return createComponent(Alert, { tone: 'warning', title: 'Router unavailable', description: 'Pass a Router instance to inspect route activity.' })
839
+ const devtools = router.devtools
840
+ const root = createElement('div')
841
+ setAttribute(root, 'class', 'vobs-devtools-router')
842
+ const current = devtools.getCurrentRoute()
843
+ const state = devtools.getNavigationState()
844
+ const metrics = devtools.getPerformanceMetrics()
845
+ const activeRequests = devtools.getDataRequests().filter(request => request.route === current.fullPath || request.key.startsWith(`${current.fullPath}#`))
846
+ const activeErrors = devtools.getErrors().filter(error => error.route === current.fullPath)
847
+ const toolbar = createElement('div')
848
+ setAttribute(toolbar, 'class', 'vobs-devtools-panel__toolbar')
849
+ insertBefore(toolbar, createComponent(Button, { variant: 'secondary', icon: createComponent(Icon, { name: 'refresh' }), children: () => 'Revalidate current route', onClick: () => { void devtools.revalidate(current.fullPath) } }), null)
850
+ const tabs = createComponent(Tabs, {
851
+ class: 'vobs-devtools-router-tabs',
852
+ variant: 'filled',
853
+ items: [
854
+ { id: 'context', label: 'Context', content: () => createRouterContextPanel(current, state) },
855
+ { id: 'requests', label: `Requests${activeRequests.length ? ` (${activeRequests.length})` : ''}`, content: () => createComponent(Card, { title: `Data requests for ${current.path}`, description: 'Loader, action and fetcher traces for the active route.', children: () => renderDataRequests(activeRequests) }) },
856
+ { id: 'errors', label: `Errors${activeErrors.length ? ` (${activeErrors.length})` : ''}`, content: () => createComponent(Card, { title: `Errors for ${current.path}`, description: `${activeErrors.length} errors captured for the active route.`, children: () => renderRouterErrors(activeErrors) }) },
857
+ { id: 'history', label: 'History', content: () => createComponent(Card, { title: 'Navigation history', description: `${metrics.navigationCount} recorded navigations · ${metrics.averageNavigationDuration.toFixed(2)} ms average`, children: () => renderNavigationHistory(devtools.getNavigationHistory(), router) }) },
858
+ { id: 'routes', label: 'Route map', content: () => createComponent(Card, {
859
+ title: 'Route map',
860
+ description: 'All registered routes and their source locations.',
861
+ children: () => renderRouteMap(devtools.getRouteTree(), router, activeRouteView)
862
+ }) }
863
+ ],
864
+ get value() { return activeRouterTab?.value ?? 'context' },
865
+ onChange: id => { if (activeRouterTab) activeRouterTab.value = id as RouterPanelTab }
866
+ })
867
+ const tabsWrap = createElement('div')
868
+ setAttribute(tabsWrap, 'class', 'vobs-devtools-router-tabs-wrap')
869
+ insertBefore(tabsWrap, tabs, null)
870
+ insertBefore(tabsWrap, toolbar, null)
871
+ insertBefore(root, tabsWrap, null)
872
+ return root
873
+ }
874
+
875
+ function createRouterContextPanel(current: ReturnType<RouterDevToolsAPI['getCurrentRoute']>, state: ReturnType<RouterDevToolsAPI['getNavigationState']>): VobsNode {
876
+ return createFragment((parent, anchor) => {
877
+ insertBefore(parent, createComponent(Card, { title: 'Active route', children: () => renderRouterLocation(current, state) }), anchor)
878
+ insertBefore(parent, createComponent(Card, { title: 'Matched route structure', children: () => renderMatchedRoutes(current.matched) }), anchor)
879
+ })
880
+ }
881
+
882
+ function renderDataRequests(requests: readonly RouterDataRequestTrace[]): VobsNode {
883
+ const root = createElement('div')
884
+ setAttribute(root, 'class', 'vobs-devtools-list')
885
+ for (const request of [...requests].reverse().slice(0, 30)) {
886
+ const row = createElement('details')
887
+ setAttribute(row, 'class', 'vobs-devtools-list__row')
888
+ const summary = createElement('summary')
889
+ appendText(summary, `${request.kind} · ${request.key}`, 'vobs-devtools-list__name')
890
+ insertBefore(summary, createComponent(Tag, { tone: request.status === 'success' ? 'success' : request.status === 'error' ? 'danger' : 'warning', children: () => request.status }), null)
891
+ appendText(summary, request.duration === undefined ? 'running' : `${request.duration.toFixed(2)} ms`)
892
+ insertBefore(row, summary, null)
893
+ if (request.error) appendText(row, request.error)
894
+ if (request.status === 'success' && request.result !== undefined) insertBefore(row, renderInspectableValue('Result', request.result), null)
895
+ insertBefore(root, row, null)
896
+ }
897
+ if (requests.length === 0) appendMuted(root, 'No data requests recorded.')
898
+ return root
899
+ }
900
+
901
+ function renderRouterErrors(errors: readonly RouteErrorTrace[]): VobsNode {
902
+ const root = createElement('div')
903
+ setAttribute(root, 'class', 'vobs-devtools-list')
904
+ for (const error of [...errors].reverse().slice(0, 30)) {
905
+ const row = createElement('details')
906
+ setAttribute(row, 'class', 'vobs-devtools-list__row vobs-devtools-error-row')
907
+ const summary = createElement('summary')
908
+ appendText(summary, `${error.phase} · ${error.route}`, 'vobs-devtools-list__name')
909
+ appendText(summary, error.message)
910
+ insertBefore(row, summary, null)
911
+ if (error.stack) appendText(row, error.stack, 'vobs-devtools-code')
912
+ insertBefore(root, row, null)
913
+ }
914
+ if (errors.length === 0) appendMuted(root, 'No route errors recorded.')
915
+ return root
916
+ }
917
+
918
+ function renderRouterLocation(route: ReturnType<RouterDevToolsAPI['getCurrentRoute']>, state: ReturnType<RouterDevToolsAPI['getNavigationState']>): VobsNode {
919
+ const root = createElement('div')
920
+ setAttribute(root, 'class', 'vobs-devtools-list')
921
+ const statusRow = createElement('div')
922
+ setAttribute(statusRow, 'class', 'vobs-devtools-list__row')
923
+ appendText(statusRow, route.fullPath, 'vobs-devtools-list__name')
924
+ insertBefore(statusRow, createComponent(Tag, { tone: state.status === 'error' ? 'danger' : state.status === 'loading' ? 'warning' : 'success', children: () => state.status }), null)
925
+ if (state.status === 'loading') appendText(statusRow, `to ${state.to}`)
926
+ if (state.error) appendText(statusRow, state.error)
927
+ insertBefore(root, statusRow, null)
928
+ insertBefore(root, renderInspectableValue('Params', route.params), null)
929
+ insertBefore(root, renderInspectableValue('Query', route.query), null)
930
+ insertBefore(root, renderInspectableValue('Meta', route.meta), null)
931
+ appendText(root, `outlet ${route.matched.map(record => record.path ?? '(layout)').join(' > ')}`)
932
+ return root
933
+ }
934
+
935
+ function renderMatchedRoutes(records: readonly RouteRecord[]): VobsNode {
936
+ const root = createElement('div')
937
+ setAttribute(root, 'class', 'vobs-devtools-route-tree')
938
+ records.forEach((record, index) => {
939
+ const row = createElement('div')
940
+ setAttribute(row, 'class', 'vobs-devtools-route-node')
941
+ setAttribute(row, 'style', `--route-depth: ${index}`)
942
+ appendText(row, record.path ?? '(layout)', 'vobs-devtools-list__name')
943
+ appendText(row, routeComponentName(record))
944
+ if (record.source) appendText(row, record.source, 'vobs-devtools-code')
945
+ if (record.loader) insertBefore(row, createComponent(Tag, { tone: 'neutral-strong', children: () => 'loader' }), null)
946
+ if (record.action) insertBefore(row, createComponent(Tag, { tone: 'neutral-strong', children: () => 'action' }), null)
947
+ insertBefore(root, row, null)
948
+ })
949
+ if (records.length === 0) appendMuted(root, 'No matched route records.')
950
+ return root
951
+ }
952
+
953
+ function renderRouteMap(
954
+ nodes: readonly RouteDebugNode[],
955
+ router: Router,
956
+ activeRouteView?: { value: 'tree' | 'list' }
957
+ ): VobsNode {
958
+ const tree = createElement('div')
959
+ setAttribute(tree, 'class', 'vobs-devtools-route-tree')
960
+ const controls = createElement('div')
961
+ setAttribute(controls, 'class', 'vobs-devtools-route-view-toggle')
962
+ for (const mode of ['tree', 'list'] as const) {
963
+ const button = createElement('button')
964
+ setAttribute(button, 'class', `vobs-devtools-control${(activeRouteView?.value ?? 'tree') === mode ? ' is-active' : ''}`)
965
+ setAttribute(button, 'type', 'button')
966
+ setAttribute(button, 'aria-pressed', (activeRouteView?.value ?? 'tree') === mode ? 'true' : 'false')
967
+ insertBefore(button, createText(mode === 'tree' ? 'Tree' : 'List'), null)
968
+ button.addEventListener('click', () => { if (activeRouteView) activeRouteView.value = mode })
969
+ insertBefore(controls, button, null)
970
+ }
971
+ insertBefore(tree, controls, null)
972
+ const currentPath = router.currentRoute.value.path
973
+ if ((activeRouteView?.value ?? 'tree') === 'list') {
974
+ for (const node of flattenRouteNodes(nodes)) appendRouteConfigNode(tree, node, 0, router, currentPath)
975
+ } else {
976
+ for (const node of nodes) appendRouteConfigNode(tree, node, 0, router, currentPath)
977
+ }
978
+ return tree
979
+ }
980
+
981
+ function flattenRouteNodes(nodes: readonly RouteDebugNode[]): readonly RouteDebugNode[] {
982
+ const result: RouteDebugNode[] = []
983
+ const visit = (node: RouteDebugNode): void => {
984
+ result.push({ ...node, children: [] })
985
+ for (const child of node.children) visit(child)
986
+ }
987
+ for (const node of nodes) visit(node)
988
+ return result
989
+ }
990
+
991
+ function appendRouteConfigNode(parent: Element, node: RouteDebugNode, depth: number, router: Router, currentPath: string): void {
992
+ const concrete = node.children.length === 0 && !node.path.includes(':') && !node.path.includes('*')
993
+ const row = createElement(concrete ? 'button' : 'div')
994
+ setAttribute(row, 'class', `vobs-devtools-route-node${concrete && node.path === currentPath ? ' is-active' : ''}`)
995
+ setAttribute(row, 'style', `--route-depth: ${depth}`)
996
+ appendText(row, node.path, 'vobs-devtools-list__name')
997
+ appendText(row, node.component)
998
+ if (node.source) appendText(row, node.source, 'vobs-devtools-code')
999
+ if (concrete) {
1000
+ setAttribute(row, 'type', 'button')
1001
+ setAttribute(row, 'title', `Navigate to ${node.path}`)
1002
+ row.addEventListener('click', () => { void router.push(node.path) })
1003
+ }
1004
+ insertBefore(parent, row, null)
1005
+ for (const child of node.children) appendRouteConfigNode(parent, child, depth + 1, router, currentPath)
1006
+ }
1007
+
1008
+ function routeComponentName(record: RouteRecord): string {
1009
+ const definition = record.component
1010
+ if (!definition) return 'Route'
1011
+ if (typeof definition === 'function') return definition.name || 'Anonymous'
1012
+ return 'lazy(...)'
1013
+ }
1014
+
1015
+ function renderNavigationHistory(history: readonly import('@vobs/router').NavigationTrace[], router: Router): VobsNode {
1016
+ const root = createElement('div')
1017
+ setAttribute(root, 'class', 'vobs-devtools-list')
1018
+ for (const trace of [...history].reverse().slice(0, 30)) {
1019
+ const row = createElement('button')
1020
+ setAttribute(row, 'class', 'vobs-devtools-list__row')
1021
+ setAttribute(row, 'type', 'button')
1022
+ setAttribute(row, 'title', `Replay ${trace.to}`)
1023
+ appendText(row, `${trace.from} → ${trace.to}`, 'vobs-devtools-list__name')
1024
+ insertBefore(row, createComponent(Tag, { tone: trace.status === 'success' ? 'success' : trace.status === 'error' ? 'danger' : 'warning', children: () => trace.status }), null)
1025
+ appendText(row, `${trace.duration.toFixed(2)} ms · ${trace.source}`)
1026
+ row.addEventListener('click', () => { void router.push(trace.to) })
1027
+ insertBefore(root, row, null)
1028
+ }
1029
+ if (history.length === 0) appendMuted(root, 'No navigation history recorded.')
1030
+ return root
1031
+ }
1032
+
1033
+ function appendMuted(parent: Element, value: string): void {
1034
+ const node = createElement('span')
1035
+ setAttribute(node, 'class', 'vobs-devtools-muted')
1036
+ insertBefore(node, createText(value), null)
1037
+ insertBefore(parent, node, null)
1038
+ }
1039
+
1040
+ function renderSignalInspector(
1041
+ signal: SignalDebugInfo,
1042
+ api: DevToolsAPI | null
1043
+ ): VobsNode {
1044
+ const row = createElement('details')
1045
+ setAttribute(row, 'class', 'vobs-devtools-list__row vobs-devtools-signal-row')
1046
+ const summary = createElement('summary')
1047
+ appendText(summary, displaySignalName(signal), 'vobs-devtools-list__name')
1048
+ appendText(summary, previewValue(signal.value), 'vobs-devtools-code')
1049
+ insertBefore(summary, createComponent(Tag, { tone: 'neutral-strong', children: () => `${signal.subscribers} subscribers` }), null)
1050
+ insertBefore(row, summary, null)
1051
+ if (!api) return row
1052
+ const dependencies = api.getDependencies(signal.id)
1053
+ const dependents = api.getDependents(signal.id)
1054
+ insertBefore(row, renderInspectableValue('Current value', signal.value), null)
1055
+ appendText(row, `${dependencies.length} dependencies · ${dependents.length} dependents`, 'vobs-devtools-muted')
1056
+ if (api.canMutate() && signal.kind !== 'memo') insertBefore(row, renderSignalMutationControl(signal, api), null)
1057
+ return row
1058
+ }
1059
+
1060
+ function renderSignalMutationControl(signal: SignalDebugInfo, api: DevToolsAPI): VobsNode {
1061
+ const root = createElement('div')
1062
+ setAttribute(root, 'class', 'vobs-devtools-mutation-control')
1063
+ appendText(root, 'Debug-only value edit (may trigger effects and requests).', 'vobs-devtools-muted')
1064
+ const input = createElement('input')
1065
+ setAttribute(input, 'class', 'vobs-devtools-search')
1066
+ setAttribute(input, 'type', 'text')
1067
+ setAttribute(input, 'aria-label', `Edit ${signal.name}`)
1068
+ setProperty(input, 'value', formatValue(signal.value))
1069
+ const button = createElement('button')
1070
+ setAttribute(button, 'class', 'vobs-devtools-control')
1071
+ setAttribute(button, 'type', 'button')
1072
+ insertBefore(button, createText('Apply debug value'), null)
1073
+ button.addEventListener('click', event => {
1074
+ event.stopPropagation()
1075
+ const raw = (input as HTMLInputElement).value
1076
+ let value: unknown = raw
1077
+ try { value = JSON.parse(raw) } catch { /* Treat non-JSON input as a string. */ }
1078
+ api.setSignalValue(signal.id, value)
1079
+ })
1080
+ insertBefore(root, input, null)
1081
+ insertBefore(root, button, null)
1082
+ return root
1083
+ }
1084
+
1085
+ function renderEffectRow(
1086
+ effect: EffectDebugInfo
1087
+ ): VobsNode {
1088
+ const row = createElement('details')
1089
+ setAttribute(row, 'class', 'vobs-devtools-list__row')
1090
+ const summary = createElement('summary')
1091
+ const component = formatDebugLocation(effect.component)
1092
+ appendText(summary, effect.name || `${debugComponentName(component)} effect`, 'vobs-devtools-list__name')
1093
+ appendText(summary, formatDebugSource(component))
1094
+ insertBefore(summary, createComponent(Tag, { tone: effect.status === 'success' || effect.status === 'idle' ? 'success' : effect.status === 'error' ? 'danger' : 'warning', children: () => effect.status }), null)
1095
+ appendText(summary, `${effect.executionCount} runs`)
1096
+ insertBefore(row, summary, null)
1097
+ appendText(row, `${effect.dependencies.length} dependencies`, 'vobs-devtools-muted')
1098
+ if (effect.lastExecutionTime > 0) appendText(row, `last execution ${effect.lastDuration?.toFixed(2) ?? '0.00'} ms · ${effect.lastRunStatus ?? 'unknown'} · ${effect.lastDomUpdates ?? 0} DOM updates`, 'vobs-devtools-muted')
1099
+ if (effect.lastUpdateId) appendText(row, `last update ${effect.lastUpdateId}`, 'vobs-devtools-code')
1100
+ if (effect.lastError) appendText(row, `${effect.lastError.name}: ${effect.lastError.message}`, 'vobs-devtools-error')
1101
+ return row
1102
+ }
1103
+
1104
+ function renderUpdateRow(
1105
+ update: UpdateTrace,
1106
+ api: DevToolsAPI | null,
1107
+ selection: DevToolsSelection | null = null,
1108
+ onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void = () => undefined
1109
+ ): VobsNode {
1110
+ const row = createElement('details')
1111
+ const selected = selection?.type === 'update' && selection.id === update.id
1112
+ setAttribute(row, 'class', `vobs-devtools-update-row${selected ? ' is-selected' : ''}`)
1113
+ if (selected) setProperty(row, 'open', true)
1114
+ const summary = createElement('summary')
1115
+ // The update row owns its own selection. Signal and Effect details stay local
1116
+ // to this trace and do not navigate to the Advanced inspectors.
1117
+ summary.addEventListener('click', () => onFocus('updates', { type: 'update', id: update.id }))
1118
+ const signal = api?.getSignal(update.signalId)
1119
+ appendText(summary, signal ? displaySignalName(signal) : displayDebugName(update.signalName), 'vobs-devtools-list__name')
1120
+ appendText(summary, formatDebugSource(signal?.component ?? 'unknown'), 'vobs-devtools-update-row__source')
1121
+ appendText(summary, `${update.duration.toFixed(2)} ms`)
1122
+ insertBefore(summary, createComponent(Tag, { tone: update.duration >= 16 ? 'warning' : 'neutral-strong', children: () => `${update.effects.length} effects` }), null)
1123
+ insertBefore(row, summary, null)
1124
+ insertBefore(row, renderValuePair('Value changed', update.previousValue, update.nextValue), null)
1125
+ appendText(row, `status ${update.status} · ${update.affectedSignals.length} signals · ${update.affectedEffects.length} effects`, 'vobs-devtools-muted')
1126
+ if (update.error) appendText(row, `${update.error.name}: ${update.error.message}`, 'vobs-devtools-error')
1127
+ if (update.effects.length === 0) appendText(row, 'No effects executed.', 'vobs-devtools-muted')
1128
+ for (const effect of update.effects) {
1129
+ insertBefore(row, renderEffectExecution(effect), null)
1130
+ if (effect.error) appendText(row, `${effect.error.name}: ${effect.error.message}`, 'vobs-devtools-error')
1131
+ }
1132
+ for (const domUpdate of update.domUpdates) {
1133
+ const operation = domUpdate.key ? `${domUpdate.operation}.${domUpdate.key}` : domUpdate.operation
1134
+ const mutation = createElement('div')
1135
+ setAttribute(mutation, 'class', 'vobs-devtools-dom-update')
1136
+ appendText(mutation, operation, 'vobs-devtools-list__name')
1137
+ appendText(mutation, domUpdate.target, 'vobs-devtools-muted')
1138
+ if (domUpdate.previousValue !== undefined || domUpdate.nextValue !== undefined) {
1139
+ insertBefore(mutation, renderValuePair('', domUpdate.previousValue, domUpdate.nextValue), null)
1140
+ }
1141
+ insertBefore(row, mutation, null)
1142
+ }
1143
+ return row
1144
+ }
1145
+
1146
+ function renderEffectExecution(
1147
+ effect: EffectExecutionInfo
1148
+ ): VobsNode {
1149
+ const root = createElement('div')
1150
+ setAttribute(root, 'class', 'vobs-devtools-effect-execution')
1151
+ appendText(root, effect.effectId, 'vobs-devtools-code')
1152
+ appendText(root, `${formatDebugSource(effect.component)} · ${effect.duration.toFixed(2)} ms · ${effect.domUpdates} DOM updates · ${effect.status ?? 'success'}`)
1153
+ return root
1154
+ }
1155
+
1156
+ function appendUpdateLinks(
1157
+ parent: Element,
1158
+ ids: readonly string[],
1159
+ onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void
1160
+ ): void {
1161
+ if (ids.length === 0) {
1162
+ appendText(parent, ' none', 'vobs-devtools-muted')
1163
+ return
1164
+ }
1165
+ for (const id of ids) {
1166
+ const button = createElement('button')
1167
+ setAttribute(button, 'class', 'vobs-devtools-link')
1168
+ setAttribute(button, 'type', 'button')
1169
+ button.addEventListener('click', event => {
1170
+ event.stopPropagation()
1171
+ onFocus('updates', { type: 'update', id })
1172
+ })
1173
+ insertBefore(button, createText(id), null)
1174
+ insertBefore(parent, button, null)
1175
+ }
1176
+ }
1177
+
1178
+ function renderLifecycleTimeline(
1179
+ events: readonly LifecycleEvent[],
1180
+ onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void
1181
+ ): VobsNode {
1182
+ const root = createElement('div')
1183
+ setAttribute(root, 'class', 'vobs-devtools-lifecycle-list')
1184
+ for (const event of [...events].reverse().slice(0, 40)) {
1185
+ const selection = lifecycleSelection(event)
1186
+ const row = createElement(selection?.type === 'component' ? 'button' : 'div')
1187
+ setAttribute(row, 'class', 'vobs-devtools-lifecycle-row')
1188
+ if (selection?.type === 'component') setAttribute(row, 'type', 'button')
1189
+ appendText(row, event.type, 'vobs-devtools-list__name')
1190
+ appendText(row, event.name ?? event.targetId)
1191
+ appendText(row, event.status ?? '', 'vobs-devtools-muted')
1192
+ if (selection?.type === 'component') row.addEventListener('click', () => onFocus('components', selection))
1193
+ insertBefore(root, row, null)
1194
+ }
1195
+ if (events.length === 0) appendMuted(root, 'No lifecycle events recorded.')
1196
+ return root
1197
+ }
1198
+
1199
+ function renderUnifiedNetworkRequests(
1200
+ requests: readonly NetworkRequestTrace[],
1201
+ routerRequests: readonly DevToolsRouterContext['dataRequests'][number][],
1202
+ query: string,
1203
+ selection: DevToolsSelection | null,
1204
+ onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void,
1205
+ networkSelection?: { value: string | null },
1206
+ sourceFilter?: { value: NetworkSourceFilter },
1207
+ statusFilter?: { value: NetworkStatusFilter },
1208
+ detailTab?: { value: NetworkDetailTab },
1209
+ api?: DevToolsAPI | null,
1210
+ queryState?: { value: string },
1211
+ http?: HTTPClient | null,
1212
+ testerOpen?: { value: boolean },
1213
+ testerRevision?: { value: number },
1214
+ testerRun?: { value: RequestTesterRun },
1215
+ testerDraft?: RequestTesterDraft,
1216
+ currentRoute?: string,
1217
+ testerTab?: { value: RequestTesterTab }
1218
+ ): VobsNode {
1219
+ const root = createElement('div')
1220
+ setAttribute(root, 'class', 'vobs-devtools-network-inspector')
1221
+ const activeSource = sourceFilter?.value ?? 'all'
1222
+ const activeStatus = statusFilter?.value ?? 'all'
1223
+ const entries: NetworkEntry[] = [
1224
+ ...requests.map<NetworkEntry>(request => ({
1225
+ key: `http:${request.id}`,
1226
+ source: request.source === 'ssr' ? 'ssr' as const : 'http' as const,
1227
+ method: request.method,
1228
+ url: request.url,
1229
+ status: request.status,
1230
+ duration: request.duration,
1231
+ startedAt: request.startedAt,
1232
+ endedAt: request.endedAt,
1233
+ request
1234
+ })),
1235
+ ...routerRequests.map<NetworkEntry>(request => ({
1236
+ key: `router:${request.id}`,
1237
+ source: 'router' as const,
1238
+ method: request.kind,
1239
+ url: request.key,
1240
+ status: request.status,
1241
+ duration: request.duration,
1242
+ startedAt: request.startedAt,
1243
+ endedAt: request.endedAt,
1244
+ routerRequest: request
1245
+ }))
1246
+ ].filter(entry => (activeSource === 'all' || entry.source === activeSource) && (activeStatus === 'all' || entry.status === activeStatus) && matchesQuery(query, entry.method, entry.url, entry.status, entry.source, entry.request?.responseStatus, entry.request?.route, entry.routerRequest?.route))
1247
+ .sort((left, right) => (right.startedAt ?? 0) - (left.startedAt ?? 0))
1248
+ const selectedKey = (selection?.type === 'request' ? `http:${selection.id}` : networkSelection?.value) ?? entries[0]?.key
1249
+ const selected = entries.find(entry => entry.key === selectedKey) ?? entries[0]
1250
+
1251
+ const controls = createElement('div')
1252
+ setAttribute(controls, 'class', 'vobs-devtools-network-toolbar')
1253
+ appendText(controls, 'Search', 'vobs-devtools-network-toolbar__label')
1254
+ const search = createElement('input')
1255
+ setAttribute(search, 'class', 'vobs-devtools-search vobs-devtools-network-toolbar__search')
1256
+ setAttribute(search, 'type', 'search')
1257
+ setAttribute(search, 'placeholder', 'Search requests')
1258
+ setProperty(search, 'value', query)
1259
+ // Commit on Enter/blur so the inspector does not replace the focused input
1260
+ // after every keystroke while the reactive panel refreshes.
1261
+ search.addEventListener('change', () => { if (queryState) queryState.value = (search as HTMLInputElement).value })
1262
+ insertBefore(controls, search, null)
1263
+ insertBefore(controls, createNetworkFilterSelect('Source', activeSource, [
1264
+ ['all', 'All sources'], ['http', 'HTTP'], ['router', 'Router'], ['ssr', 'SSR']
1265
+ ], value => { if (sourceFilter) sourceFilter.value = value as NetworkSourceFilter }), null)
1266
+ insertBefore(controls, createNetworkFilterSelect('Status', activeStatus, [
1267
+ ['all', 'All statuses'], ['loading', 'Loading'], ['success', 'Success'], ['error', 'Error'], ['cancelled', 'Cancelled']
1268
+ ], value => { if (statusFilter) statusFilter.value = value as NetworkStatusFilter }), null)
1269
+ if (api) insertBefore(controls, createComponent(Button, {
1270
+ variant: 'ghost',
1271
+ children: () => 'Clear',
1272
+ onClick: () => api.clearNetworkRequests()
1273
+ }), null)
1274
+ if (testerOpen && testerDraft) insertBefore(controls, createComponent(Button, {
1275
+ variant: 'ghost',
1276
+ iconOnly: true,
1277
+ icon: createComponent(Icon, { name: 'settings' }),
1278
+ 'aria-label': 'Open Request Tester',
1279
+ title: 'Open Request Tester',
1280
+ onClick: () => {
1281
+ if (!testerDraft.url) testerDraft.url = selected?.url ?? currentRoute ?? ''
1282
+ testerOpen.value = true
1283
+ testerRevision && (testerRevision.value++)
1284
+ }
1285
+ }), null)
1286
+ insertBefore(root, controls, null)
1287
+
1288
+ const panes = createElement('div')
1289
+ setAttribute(panes, 'class', 'vobs-devtools-network-panes')
1290
+ const listPane = createElement('section')
1291
+ setAttribute(listPane, 'class', 'vobs-devtools-network-list-pane')
1292
+ setAttribute(listPane, 'aria-label', 'Network requests')
1293
+ const requestList = createElement('div')
1294
+ setAttribute(requestList, 'class', 'vobs-devtools-network-request-list')
1295
+ setAttribute(requestList, 'role', 'list')
1296
+ for (const entry of entries) {
1297
+ const row = createElement('button')
1298
+ const isSelected = selectedKey === entry.key
1299
+ setAttribute(row, 'class', `vobs-devtools-network-request${isSelected ? ' is-selected' : ''}`)
1300
+ setAttribute(row, 'type', 'button')
1301
+ setAttribute(row, 'role', 'listitem')
1302
+ setAttribute(row, 'aria-pressed', isSelected ? 'true' : 'false')
1303
+ row.addEventListener('click', () => {
1304
+ networkSelection && (networkSelection.value = entry.key)
1305
+ if (entry.request) onFocus('network', { type: 'request', id: entry.request.id })
1306
+ if (detailTab) detailTab.value = 'overview'
1307
+ })
1308
+ const pathLine = createElement('span')
1309
+ setAttribute(pathLine, 'class', 'vobs-devtools-network-request__path-line')
1310
+ appendText(pathLine, entry.url, 'vobs-devtools-network-request__url')
1311
+ if (entry.request?.test) insertBefore(pathLine, createComponent(Tag, { tone: 'warning', children: () => 'TEST' }), null)
1312
+ insertBefore(pathLine, createComponent(Tag, { tone: entry.status === 'success' ? 'success' : entry.status === 'error' || entry.status === 'cancelled' ? 'danger' : 'warning', children: () => entry.request?.responseStatus ? `${entry.status} ${entry.request.responseStatus}` : entry.status }), null)
1313
+ insertBefore(row, pathLine, null)
1314
+ const meta = createElement('span')
1315
+ setAttribute(meta, 'class', 'vobs-devtools-network-request__meta')
1316
+ appendText(meta, entry.duration === undefined ? 'Running' : `${entry.duration.toFixed(0)} ms`)
1317
+ appendText(meta, `ID ${entry.request?.id ?? entry.routerRequest?.id}`, 'vobs-devtools-code')
1318
+ insertBefore(row, meta, null)
1319
+ insertBefore(requestList, row, null)
1320
+ }
1321
+ insertBefore(listPane, requestList, null)
1322
+ if (entries.length === 0) appendMuted(listPane, query || activeSource !== 'all' || activeStatus !== 'all' ? 'No requests match the current filters.' : 'No requests recorded.')
1323
+ insertBefore(panes, listPane, null)
1324
+ const detailPane = createElement('section')
1325
+ setAttribute(detailPane, 'class', 'vobs-devtools-network-detail-pane')
1326
+ setAttribute(detailPane, 'aria-label', 'Selected network request')
1327
+ if (selected) insertBefore(detailPane, renderNetworkDetail(selected, detailTab?.value ?? 'overview', tab => { if (detailTab) detailTab.value = tab }), null)
1328
+ else appendMuted(detailPane, 'Select a request to inspect its details.')
1329
+ insertBefore(panes, detailPane, null)
1330
+ insertBefore(root, panes, null)
1331
+ if (testerOpen?.value && testerDraft) insertBefore(root, renderRequestTester(testerDraft, run => {
1332
+ if (testerRun) testerRun.value = run
1333
+ testerRevision && (testerRevision.value++)
1334
+ }, testerRun?.value ?? { status: 'idle' }, testerTab?.value ?? 'params', selected, http, () => { testerOpen.value = false }, tab => { if (testerTab) testerTab.value = tab; testerRevision && (testerRevision.value++) }), null)
1335
+ return root
1336
+ }
1337
+
1338
+ function createNetworkFilterSelect(label: string, value: string, options: readonly (readonly [string, string])[], onChange: (value: string) => void): VobsNode {
1339
+ const select = createElement('select')
1340
+ setAttribute(select, 'class', 'vobs-devtools-network-toolbar__select')
1341
+ setAttribute(select, 'aria-label', label)
1342
+ setProperty(select, 'value', value)
1343
+ for (const [optionValue, optionLabel] of options) {
1344
+ const option = createElement('option')
1345
+ setAttribute(option, 'value', optionValue)
1346
+ insertBefore(option, createText(optionLabel), null)
1347
+ insertBefore(select, option, null)
1348
+ }
1349
+ setProperty(select, 'value', value)
1350
+ select.addEventListener('change', event => onChange((event.target as HTMLSelectElement).value))
1351
+ return select
1352
+ }
1353
+
1354
+ function renderRequestTester(
1355
+ draft: RequestTesterDraft,
1356
+ onRunChange: (run: RequestTesterRun) => void,
1357
+ run: RequestTesterRun,
1358
+ activeTab: RequestTesterTab,
1359
+ selected: NetworkEntry | undefined,
1360
+ http: HTTPClient | null | undefined,
1361
+ onClose: () => void,
1362
+ onTabChange: (tab: RequestTesterTab) => void
1363
+ ): VobsNode {
1364
+ const drawer = createElement('aside')
1365
+ setAttribute(drawer, 'class', 'vobs-devtools-request-tester')
1366
+ setAttribute(drawer, 'aria-label', 'Request Tester')
1367
+ const heading = createElement('div')
1368
+ setAttribute(heading, 'class', 'vobs-devtools-request-tester__heading')
1369
+ appendText(heading, 'Request Tester', 'vobs-devtools-request-tester__title')
1370
+ const headingActions = createElement('div')
1371
+ setAttribute(headingActions, 'class', 'vobs-devtools-request-tester__heading-actions')
1372
+ insertBefore(headingActions, createComponent(Button, {
1373
+ variant: 'ghost',
1374
+ disabled: !selected,
1375
+ children: () => 'Use selected request',
1376
+ onClick: () => {
1377
+ if (!selected) return
1378
+ applyNetworkEntryToTesterDraft(selected, draft)
1379
+ onRunChange({ status: 'idle' })
1380
+ }
1381
+ }), null)
1382
+ insertBefore(headingActions, createComponent(Button, {
1383
+ variant: 'ghost',
1384
+ iconOnly: true,
1385
+ icon: createComponent(Icon, { name: 'x' }),
1386
+ 'aria-label': 'Close Request Tester',
1387
+ title: 'Close Request Tester',
1388
+ onClick: onClose
1389
+ }), null)
1390
+ insertBefore(heading, headingActions, null)
1391
+ insertBefore(drawer, heading, null)
1392
+
1393
+ const requestLine = createElement('div')
1394
+ setAttribute(requestLine, 'class', 'vobs-devtools-request-tester__request-line')
1395
+ insertBefore(requestLine, createTesterInput('url', draft.url, value => { draft.url = value }), null)
1396
+ insertBefore(requestLine, createTesterMethodSelect(draft.method, value => { draft.method = value; onRunChange({ status: run.status, message: run.message }) }), null)
1397
+ insertBefore(requestLine, createComponent(Button, {
1398
+ variant: 'brand', iconOnly: true, icon: createComponent(Icon, { name: 'play' }),
1399
+ loading: run.status === 'running', disabled: run.status === 'running',
1400
+ 'aria-label': run.status === 'running' ? 'Running request' : 'Start request',
1401
+ title: run.status === 'running' ? 'Running request' : 'Start request',
1402
+ onClick: () => { void executeRequestTester(http, draft, onRunChange) }
1403
+ }), null)
1404
+ insertBefore(drawer, requestLine, null)
1405
+ const tabs = createElement('div')
1406
+ setAttribute(tabs, 'class', 'vobs-devtools-request-tester__tabs')
1407
+ const tabLabels: readonly [RequestTesterTab, string][] = [['params', 'Params'], ['headers', 'Headers'], ['body', 'Body']]
1408
+ for (const [tab, label] of tabLabels) {
1409
+ const button = createElement('button')
1410
+ setAttribute(button, 'class', `vobs-devtools-request-tester__tab${activeTab === tab ? ' is-active' : ''}`)
1411
+ setAttribute(button, 'type', 'button')
1412
+ setAttribute(button, 'aria-selected', activeTab === tab ? 'true' : 'false')
1413
+ appendText(button, label)
1414
+ button.addEventListener('click', () => onTabChange(tab))
1415
+ insertBefore(tabs, button, null)
1416
+ }
1417
+ insertBefore(drawer, tabs, null)
1418
+ if (activeTab === 'params') insertBefore(drawer, renderTesterParamGroup('Params', draft.params, 'parameter', onRunChange), null)
1419
+ if (activeTab === 'headers') insertBefore(drawer, renderTesterParamGroup('Headers', draft.headers, 'header', onRunChange), null)
1420
+ if (activeTab === 'body') {
1421
+ const bodyLabel = createElement('label')
1422
+ setAttribute(bodyLabel, 'class', 'vobs-devtools-request-tester__field')
1423
+ appendText(bodyLabel, 'Body')
1424
+ const body = createElement('textarea')
1425
+ setAttribute(body, 'class', 'vobs-devtools-request-tester__textarea')
1426
+ setAttribute(body, 'rows', '8')
1427
+ setAttribute(body, 'placeholder', '{ "key": "value" }')
1428
+ setProperty(body, 'value', draft.body)
1429
+ body.addEventListener('change', () => { draft.body = (body as HTMLTextAreaElement).value })
1430
+ insertBefore(bodyLabel, body, null)
1431
+ insertBefore(drawer, bodyLabel, null)
1432
+ }
1433
+
1434
+ const footer = createElement('div')
1435
+ setAttribute(footer, 'class', 'vobs-devtools-request-tester__footer')
1436
+ if (run.message) appendText(footer, run.message, `vobs-devtools-request-tester__status${run.status === 'error' ? ' is-error' : run.status === 'success' ? ' is-success' : ''}`)
1437
+ insertBefore(drawer, footer, null)
1438
+ return drawer
1439
+ }
1440
+
1441
+ function applyNetworkEntryToTesterDraft(entry: NetworkEntry, draft: RequestTesterDraft): void {
1442
+ const request = entry.request
1443
+ const rawUrl = request?.url ?? entry.url
1444
+ let url = rawUrl
1445
+ let params: RequestTesterParam[] = []
1446
+ try {
1447
+ const parsed = new URL(rawUrl, typeof window !== 'undefined' ? window.location.href : 'http://localhost/')
1448
+ url = `${parsed.origin === 'http://localhost' && rawUrl.startsWith('/') ? '' : parsed.origin}${parsed.pathname}${parsed.hash}`
1449
+ params = [...parsed.searchParams.entries()].map(([key, value]) => ({ key, value }))
1450
+ } catch {
1451
+ // Keep non-URL request keys intact, such as router loader identifiers.
1452
+ }
1453
+ draft.url = url
1454
+ draft.method = isHTTPMethod(request?.method ?? entry.method) ? (request?.method ?? entry.method) as HTTPMethod : 'GET'
1455
+ draft.params = params.length > 0 ? params : [{ key: '', value: '' }]
1456
+ draft.headers = request ? Object.entries(request.headers).map(([key, value]) => ({ key, value })) : [{ key: '', value: '' }]
1457
+ const body = request?.requestBody
1458
+ draft.body = body === undefined || body === null || body === '' ? '' : typeof body === 'string' ? body : JSON.stringify(body, null, 2)
1459
+ }
1460
+
1461
+ function isHTTPMethod(value: string): value is HTTPMethod {
1462
+ return ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT'].includes(value)
1463
+ }
1464
+
1465
+ function createTesterInput(name: string, value: string, onChange: (value: string) => void): VobsNode {
1466
+ const input = createElement('input')
1467
+ setAttribute(input, 'class', 'vobs-devtools-request-tester__input')
1468
+ setAttribute(input, 'name', name)
1469
+ setAttribute(input, 'type', 'text')
1470
+ setProperty(input, 'value', value)
1471
+ input.addEventListener('change', () => onChange((input as HTMLInputElement).value))
1472
+ return input
1473
+ }
1474
+
1475
+ function createTesterMethodSelect(value: HTTPMethod, onChange: (value: HTTPMethod) => void): VobsNode {
1476
+ const select = createElement('select')
1477
+ setAttribute(select, 'class', 'vobs-devtools-request-tester__select')
1478
+ for (const method of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as HTTPMethod[]) {
1479
+ const option = createElement('option')
1480
+ setAttribute(option, 'value', method)
1481
+ if (method === value) setAttribute(option, 'selected', '')
1482
+ insertBefore(option, createText(method), null)
1483
+ insertBefore(select, option, null)
1484
+ }
1485
+ select.addEventListener('change', () => onChange((select as HTMLSelectElement).value as HTTPMethod))
1486
+ return select
1487
+ }
1488
+
1489
+ function renderTesterParamGroup(label: string, params: RequestTesterParam[], singular: string, onChange: (run: RequestTesterRun) => void): VobsNode {
1490
+ const group = createElement('section')
1491
+ setAttribute(group, 'class', 'vobs-devtools-request-tester__group')
1492
+ const title = createElement('div')
1493
+ setAttribute(title, 'class', 'vobs-devtools-request-tester__group-title')
1494
+ appendText(title, label)
1495
+ const add = createElement('button')
1496
+ setAttribute(add, 'class', 'vobs-devtools-request-tester__add')
1497
+ setAttribute(add, 'type', 'button')
1498
+ appendText(add, '+')
1499
+ setAttribute(add, 'aria-label', `Add ${label}`)
1500
+ setAttribute(add, 'title', `Add ${label}`)
1501
+ add.addEventListener('click', () => { params.push({ key: '', value: '' }); onChange({ status: 'idle' }) })
1502
+ insertBefore(title, add, null)
1503
+ insertBefore(group, title, null)
1504
+ for (let index = 0; index < params.length; index++) {
1505
+ const param = params[index]
1506
+ const row = createElement('div')
1507
+ setAttribute(row, 'class', 'vobs-devtools-request-tester__param')
1508
+ insertBefore(row, createTesterInput(`${singular}-key-${index}`, param.key, value => { param.key = value }), null)
1509
+ insertBefore(row, createTesterInput(`${singular}-value-${index}`, param.value, value => { param.value = value }), null)
1510
+ const remove = createElement('button')
1511
+ setAttribute(remove, 'class', 'vobs-devtools-request-tester__remove')
1512
+ setAttribute(remove, 'type', 'button')
1513
+ setAttribute(remove, 'aria-label', `Remove ${label} row ${index + 1}`)
1514
+ setAttribute(remove, 'title', `Remove ${label} row ${index + 1}`)
1515
+ insertBefore(remove, createComponent(Icon, { name: 'trash' }), null)
1516
+ remove.addEventListener('click', () => { params.splice(index, 1); if (params.length === 0) params.push({ key: '', value: '' }); onChange({ status: 'idle' }) })
1517
+ insertBefore(row, remove, null)
1518
+ insertBefore(group, row, null)
1519
+ }
1520
+ return group
1521
+ }
1522
+
1523
+ async function executeRequestTester(http: HTTPClient | null | undefined, draft: RequestTesterDraft, onRunChange: (run: RequestTesterRun) => void): Promise<void> {
1524
+ if (!http) {
1525
+ onRunChange({ status: 'error', message: 'HTTP client is unavailable.' })
1526
+ return
1527
+ }
1528
+ const url = draft.url.trim()
1529
+ if (!url) {
1530
+ onRunChange({ status: 'error', message: 'Enter a request URL.' })
1531
+ return
1532
+ }
1533
+ const params: Record<string, string> = {}
1534
+ for (const param of draft.params) if (param.key.trim()) params[param.key.trim()] = param.value
1535
+ const headers: Record<string, string> = {}
1536
+ for (const header of draft.headers) if (header.key.trim()) headers[header.key.trim()] = header.value
1537
+ let body: unknown
1538
+ if (!['GET', 'HEAD', 'DELETE'].includes(draft.method) && draft.body.trim()) {
1539
+ try { body = JSON.parse(draft.body) } catch {
1540
+ onRunChange({ status: 'error', message: 'Body must be valid JSON.' })
1541
+ return
1542
+ }
1543
+ }
1544
+ onRunChange({ status: 'running', message: undefined })
1545
+ try {
1546
+ await http.request({
1547
+ url,
1548
+ method: draft.method,
1549
+ params: Object.keys(params).length > 0 ? params : undefined,
1550
+ headers: Object.keys(headers).length > 0 ? headers : undefined,
1551
+ body,
1552
+ debugContext: { route: url, test: true }
1553
+ })
1554
+ onRunChange({ status: 'success', message: 'Request completed.' })
1555
+ } catch (error) {
1556
+ onRunChange({ status: 'error', message: error instanceof Error ? error.message : String(error) })
1557
+ }
1558
+ }
1559
+
1560
+ function renderNetworkDetail(entry: NetworkEntry, activeTab: NetworkDetailTab, onTabChange: (tab: NetworkDetailTab) => void): VobsNode {
1561
+ const root = createElement('section')
1562
+ setAttribute(root, 'class', 'vobs-devtools-network-detail')
1563
+ const request = entry.request
1564
+ const routerRequest = entry.routerRequest
1565
+ const heading = createElement('div')
1566
+ setAttribute(heading, 'class', 'vobs-devtools-network-detail__heading')
1567
+ appendText(heading, `${entry.method} ${entry.url}`, 'vobs-devtools-network-detail__title')
1568
+ if (request?.test) insertBefore(heading, createComponent(Tag, { tone: 'warning', children: () => 'TEST' }), null)
1569
+ insertBefore(heading, createComponent(Tag, { tone: entry.status === 'success' ? 'success' : entry.status === 'error' || entry.status === 'cancelled' ? 'danger' : 'warning', children: () => request?.responseStatus ? `${entry.status} ${request.responseStatus}` : entry.status }), null)
1570
+ if (entry.duration !== undefined) appendText(heading, `${entry.duration.toFixed(0)} ms`, 'vobs-devtools-network-detail__duration')
1571
+ insertBefore(root, heading, null)
1572
+ const tabs = createElement('div')
1573
+ setAttribute(tabs, 'class', 'vobs-devtools-network-detail__tabs')
1574
+ const tabLabels: readonly [NetworkDetailTab, string][] = [['overview', 'Overview'], ['headers', 'Headers'], ['payload', 'Payload'], ['response', 'Response'], ['timing', 'Timing'], ['context', 'Context']]
1575
+ for (const [tab, label] of tabLabels) {
1576
+ const button = createElement('button')
1577
+ setAttribute(button, 'type', 'button')
1578
+ setAttribute(button, 'class', `vobs-devtools-network-detail__tab${activeTab === tab ? ' is-active' : ''}`)
1579
+ setAttribute(button, 'aria-selected', activeTab === tab ? 'true' : 'false')
1580
+ appendText(button, label)
1581
+ button.addEventListener('click', () => onTabChange(tab))
1582
+ insertBefore(tabs, button, null)
1583
+ }
1584
+ insertBefore(root, tabs, null)
1585
+ const content = createElement('div')
1586
+ setAttribute(content, 'class', 'vobs-devtools-network-detail__content')
1587
+ if (activeTab === 'overview') {
1588
+ appendNetworkField(content, 'Source', entry.source.toUpperCase())
1589
+ appendNetworkField(content, 'Method / Type', entry.method)
1590
+ appendNetworkField(content, 'Request key', entry.url, 'code')
1591
+ appendNetworkField(content, 'Status', entry.status)
1592
+ if (request?.error || routerRequest?.error) appendText(content, request ? `${request.error?.name}: ${request.error?.message}` : routerRequest!.error!, 'vobs-devtools-error')
1593
+ } else if (activeTab === 'headers') {
1594
+ if (request && Object.keys(request.headers).length > 0) insertBefore(content, renderValueTree('Headers', request.headers, false), null)
1595
+ else appendMuted(content, 'No captured headers for this request.')
1596
+ } else if (activeTab === 'payload') {
1597
+ const payload = request?.requestBody
1598
+ if (payload !== undefined) insertBefore(content, renderValueTree('Request body', payload, false), null)
1599
+ else appendMuted(content, 'No request payload captured.')
1600
+ } else if (activeTab === 'response') {
1601
+ const response = request?.responseBody ?? routerRequest?.result
1602
+ if (response !== undefined) insertBefore(content, renderValueTree('Response', response, false), null)
1603
+ else appendMuted(content, routerRequest?.error ?? 'No response body captured.')
1604
+ } else if (activeTab === 'timing') {
1605
+ if (entry.startedAt !== undefined) appendNetworkField(content, 'Started at', formatNetworkTimestamp(entry.startedAt))
1606
+ if (entry.endedAt !== undefined) appendNetworkField(content, 'Ended at', formatNetworkTimestamp(entry.endedAt))
1607
+ appendNetworkField(content, 'Duration', entry.duration === undefined ? 'Running' : `${entry.duration.toFixed(2)} ms`)
1608
+ if (request) appendNetworkField(content, 'Attempts', `${request.attempt + 1} (${request.retries} retries)`)
1609
+ } else {
1610
+ appendNetworkField(content, 'Environment', request?.environment ?? routerRequest?.environment ?? 'client')
1611
+ if (request?.route ?? routerRequest?.route) appendNetworkField(content, 'Route', request?.route ?? routerRequest?.route ?? '', 'code')
1612
+ if (request?.navigationId ?? routerRequest?.navigationId) appendNetworkField(content, 'Navigation ID', String(request?.navigationId ?? routerRequest?.navigationId), 'code')
1613
+ if (request?.dataRequestId !== undefined) appendNetworkField(content, 'Data request ID', String(request.dataRequestId), 'code')
1614
+ if (routerRequest?.trigger) appendNetworkField(content, 'Trigger', routerRequest.trigger)
1615
+ appendNetworkField(content, 'Request ID', String(request?.id ?? routerRequest?.id), 'code')
1616
+ }
1617
+ insertBefore(root, content, null)
1618
+ return root
1619
+ }
1620
+
1621
+ function appendNetworkField(parent: Element, label: string, value: string, valueKind?: 'code' | 'muted'): void {
1622
+ const field = createElement('div')
1623
+ setAttribute(field, 'class', 'vobs-devtools-network-detail__field')
1624
+ appendText(field, label, 'vobs-devtools-network-detail__label')
1625
+ appendText(field, value, `vobs-devtools-network-detail__value${valueKind ? ` vobs-devtools-${valueKind}` : ''}`)
1626
+ insertBefore(parent, field, null)
1627
+ }
1628
+
1629
+ function formatNetworkTimestamp(value: number): string {
1630
+ const epoch = value > 100_000_000_000 ? value : (typeof performance !== 'undefined' ? performance.timeOrigin + value : Date.now())
1631
+ return new Date(epoch).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
1632
+ }
1633
+
1634
+ function matchesQuery(query: string, ...values: readonly unknown[]): boolean {
1635
+ const needle = query.trim().toLowerCase()
1636
+ if (!needle) return true
1637
+ return values.some(value => String(value ?? '').toLowerCase().includes(needle))
1638
+ }
1639
+
1640
+ function filterErrors(errors: readonly DevToolsErrorTrace[], query: string): readonly DevToolsErrorTrace[] {
1641
+ return errors.filter(error => matchesQuery(query, error.phase, error.phases, error.origin, error.code, error.name, error.message, error.component, error.route, error.id, error.effectId, error.requestId))
1642
+ }
1643
+
1644
+ function filterComponentTree(nodes: readonly ComponentDebugNode[], query: string): readonly ComponentDebugNode[] {
1645
+ if (!query.trim()) return nodes
1646
+ return nodes.flatMap(node => {
1647
+ const children = filterComponentTree(node.children, query)
1648
+ if (!matchesQuery(query, node.name, node.id) && children.length === 0) return []
1649
+ return [{ ...node, children }]
1650
+ })
1651
+ }
1652
+
1653
+ function renderErrors(
1654
+ errors: readonly DevToolsErrorTrace[],
1655
+ selection: DevToolsSelection | null,
1656
+ onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void
1657
+ ): VobsNode {
1658
+ const root = createElement('div')
1659
+ setAttribute(root, 'class', 'vobs-devtools-error-list')
1660
+ for (const error of [...errors].reverse()) {
1661
+ const row = createElement('details')
1662
+ const selected = selection?.type === 'error' && selection.id === error.id
1663
+ setAttribute(row, 'class', `vobs-devtools-list__row vobs-devtools-error-item${selected ? ' is-selected' : ''}`)
1664
+ if (selected) setProperty(row, 'open', true)
1665
+ row.addEventListener('click', event => {
1666
+ event.stopPropagation()
1667
+ onFocus('errors', { type: 'error', id: error.id })
1668
+ })
1669
+ const summary = createElement('summary')
1670
+ appendText(summary, formatErrorSummary(error), 'vobs-devtools-list__name')
1671
+ appendText(summary, error.message, 'vobs-devtools-error-item__message')
1672
+ insertBefore(summary, createComponent(Tag, { tone: error.origin === 'framework' ? 'danger' : error.origin === 'usage' ? 'warning' : 'neutral-strong', children: () => error.origin }), null)
1673
+ insertBefore(summary, createComponent(Tag, { tone: 'danger', children: () => `${error.count}×` }), null)
1674
+ insertBefore(row, summary, null)
1675
+ const details = createElement('div')
1676
+ setAttribute(details, 'class', 'vobs-devtools-error-item__details')
1677
+ appendErrorField(details, 'Phase', error.phases.length > 1 ? error.phases.join(' / ') : error.phase)
1678
+ appendErrorField(details, 'Occurred', `first ${new Date(error.firstOccurredAt).toLocaleString()} · last ${new Date(error.lastOccurredAt).toLocaleString()}`, 'muted')
1679
+ if (error.code) appendErrorField(details, 'Code', error.code, 'code')
1680
+ if (error.component) appendErrorField(details, 'Component', displayErrorComponent(error.component))
1681
+ if (error.ownerId) appendErrorField(details, 'Owner', error.ownerId, 'code')
1682
+ appendErrorField(details, 'Status', `${error.handled ? 'handled' : 'propagated'} · ${error.recovery}`, 'muted')
1683
+ if (error.hint) appendErrorField(details, 'Hint', error.hint, 'muted', true)
1684
+ if (error.cause) appendErrorField(details, 'Cause', error.cause, 'muted', true)
1685
+ if (error.fix) appendErrorField(details, 'Fix', error.fix, 'muted', true)
1686
+ if (error.source) appendErrorField(details, 'Source', formatDebugLocation(error.source), 'code', true)
1687
+ if (error.updateId) appendErrorLinkField(details, 'Update', error.updateId, 'updates', { type: 'update', id: error.updateId }, onFocus)
1688
+ if (error.effectId) appendErrorField(details, 'Effect', error.effectId, 'code')
1689
+ if (error.requestId !== undefined) appendErrorLinkField(details, 'Request', String(error.requestId), 'network', { type: 'request', id: error.requestId }, onFocus)
1690
+ if (error.navigationId !== undefined) appendErrorField(details, 'Navigation', String(error.navigationId), 'code')
1691
+ if (error.route) appendErrorField(details, 'Route', error.route, 'code')
1692
+ if (error.hydration) {
1693
+ appendErrorField(details, 'Expected', error.hydration.expected, 'code', true)
1694
+ appendErrorField(details, 'Actual', error.hydration.actual, 'code', true)
1695
+ appendErrorField(details, 'DOM path', error.hydration.path, 'code')
1696
+ }
1697
+ if (error.stack) appendErrorStack(details, error.stack, error.name, error.message)
1698
+ insertBefore(row, details, null)
1699
+ insertBefore(root, row, null)
1700
+ }
1701
+ if (errors.length === 0) appendMuted(root, 'No errors recorded.')
1702
+ return root
1703
+ }
1704
+
1705
+ function appendContextLink(
1706
+ parent: Element,
1707
+ label: string,
1708
+ section: DevToolsSection,
1709
+ selection: DevToolsSelection,
1710
+ onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void
1711
+ ): void {
1712
+ const button = createElement('button')
1713
+ setAttribute(button, 'class', 'vobs-devtools-link')
1714
+ setAttribute(button, 'type', 'button')
1715
+ button.addEventListener('click', event => {
1716
+ event.stopPropagation()
1717
+ onFocus(section, selection)
1718
+ })
1719
+ insertBefore(button, createText(label), null)
1720
+ insertBefore(parent, button, null)
1721
+ }
1722
+
1723
+ function appendErrorField(parent: Element, label: string, value: string, valueKind?: 'code' | 'muted', wide = false): void {
1724
+ const field = createElement('div')
1725
+ setAttribute(field, 'class', `vobs-devtools-error-item__field${wide ? ' vobs-devtools-error-item__field--wide' : ''}`)
1726
+ appendText(field, label, 'vobs-devtools-error-item__label')
1727
+ appendText(field, value, `vobs-devtools-error-item__value${valueKind ? ` vobs-devtools-${valueKind}` : ''}`)
1728
+ insertBefore(parent, field, null)
1729
+ }
1730
+
1731
+ function appendErrorLinkField(
1732
+ parent: Element,
1733
+ label: string,
1734
+ value: string,
1735
+ section: DevToolsSection,
1736
+ selection: DevToolsSelection,
1737
+ onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void
1738
+ ): void {
1739
+ const field = createElement('div')
1740
+ setAttribute(field, 'class', 'vobs-devtools-error-item__field')
1741
+ appendText(field, label, 'vobs-devtools-error-item__label')
1742
+ const valueNode = createElement('span')
1743
+ setAttribute(valueNode, 'class', 'vobs-devtools-error-item__value')
1744
+ appendContextLink(valueNode, value, section, selection, onFocus)
1745
+ insertBefore(field, valueNode, null)
1746
+ insertBefore(parent, field, null)
1747
+ }
1748
+
1749
+ function appendErrorStack(parent: Element, value: string, name: string, message: string): void {
1750
+ const stack = createElement('div')
1751
+ setAttribute(stack, 'class', 'vobs-devtools-error-item__stack')
1752
+ appendText(stack, 'Stack trace', 'vobs-devtools-error-item__label')
1753
+ const code = createElement('pre')
1754
+ setAttribute(code, 'class', 'vobs-devtools-error-item__stack-code vobs-devtools-code')
1755
+ insertBefore(code, createText(formatDebugStack(value, name, message)), null)
1756
+ insertBefore(stack, code, null)
1757
+ insertBefore(parent, stack, null)
1758
+ }
1759
+
1760
+ function formatErrorSummary(error: DevToolsErrorTrace): string {
1761
+ const phase = error.phases.length > 1 ? error.phases.join(' / ') : error.phase
1762
+ return error.name === 'Error' ? phase : `${phase} · ${error.name}`
1763
+ }
1764
+
1765
+ function lifecycleSelection(event: LifecycleEvent): DevToolsSelection | null {
1766
+ if (event.type.startsWith('owner-')) return { type: 'component', id: event.targetId }
1767
+ if (event.type.startsWith('signal-') || event.type.startsWith('memo-')) return { type: 'signal', id: event.targetId }
1768
+ if (event.type.startsWith('effect-')) return { type: 'effect', id: event.targetId }
1769
+ return null
1770
+ }
1771
+
1772
+ function displaySignalName(signal: SignalDebugInfo): string {
1773
+ if (!signal.name.startsWith('signal-')) return stripDebugLocation(signal.name)
1774
+ const component = formatDebugLocation(signal.component)
1775
+ return component === 'unknown' ? 'runtime state' : `${debugComponentName(component)} state`
1776
+ }
1777
+
1778
+ function displayDebugName(name: string): string {
1779
+ return name.startsWith('signal-') ? 'runtime state' : stripDebugLocation(name)
1780
+ }
1781
+
1782
+ function debugComponentName(value: string): string {
1783
+ const separator = value.indexOf(' (')
1784
+ return separator > 0 ? value.slice(0, separator) : value
1785
+ }
1786
+
1787
+ function displayErrorComponent(value: string): string {
1788
+ return debugComponentName(formatDebugLocation(value))
1789
+ }
1790
+
1791
+ function appendText(parent: Element, value: string, className?: string): void {
1792
+ const node = createElement('span')
1793
+ if (className) setAttribute(node, 'class', className)
1794
+ insertBefore(node, createText(value), null)
1795
+ insertBefore(parent, node, null)
1796
+ }
1797
+
1798
+ function formatDebugLocation(value: string): string {
1799
+ const normalized = value.replaceAll('\\', '/')
1800
+ const lower = normalized.toLowerCase()
1801
+ const sourceIndex = lower.startsWith('src/') ? 0 : lower.indexOf('/src/') + 1
1802
+ if (sourceIndex <= 0 && !lower.startsWith('src/')) return normalized
1803
+ const sourcePath = normalized.slice(sourceIndex)
1804
+ const openParen = normalized.lastIndexOf('(', sourceIndex)
1805
+ return openParen >= 0
1806
+ ? `${normalized.slice(0, openParen + 1)}${sourcePath}`
1807
+ : sourcePath
1808
+ }
1809
+
1810
+ function formatDebugStack(value: string, name: string, message: string): string {
1811
+ const lines = value
1812
+ .split(/\r?\n/)
1813
+ .map(formatDebugStackLine)
1814
+ const first = lines[0]?.trim()
1815
+ const header = `${name}: ${message}`
1816
+ if (lines.length > 1 && (first === header || first?.startsWith(`${header} `))) return lines.slice(1).join('\n')
1817
+ return lines.join('\n')
1818
+ }
1819
+
1820
+ function formatDebugStackLine(value: string): string {
1821
+ const normalized = value.replaceAll('\\', '/')
1822
+ const lower = normalized.toLowerCase()
1823
+ const sourceIndex = lower.startsWith('src/') ? 0 : lower.indexOf('/src/') + 1
1824
+ if (sourceIndex < 0) return normalized
1825
+ if (sourceIndex === 0) return normalized
1826
+
1827
+ const prefixBeforeLocation = normalized.slice(0, sourceIndex)
1828
+ const openParen = prefixBeforeLocation.lastIndexOf('(')
1829
+ const openBracket = prefixBeforeLocation.lastIndexOf('[')
1830
+ const opening = Math.max(openParen, openBracket)
1831
+ if (opening >= 0) return `${normalized.slice(0, opening + 1)}${normalized.slice(sourceIndex)}`
1832
+
1833
+ const at = prefixBeforeLocation.lastIndexOf('at ')
1834
+ return at >= 0
1835
+ ? `${normalized.slice(0, at + 3)}${normalized.slice(sourceIndex)}`
1836
+ : normalized.slice(sourceIndex)
1837
+ }
1838
+
1839
+ function formatDebugSource(value: string): string {
1840
+ const location = formatDebugLocation(value)
1841
+ const openParen = location.indexOf('(')
1842
+ if (openParen < 0) return location
1843
+ const closeParen = location.indexOf(')', openParen)
1844
+ return closeParen < 0 ? location : location.slice(openParen + 1, closeParen)
1845
+ }
1846
+
1847
+ function stripDebugLocation(value: string): string {
1848
+ const normalized = value.replaceAll('\\', '/')
1849
+ const lower = normalized.toLowerCase()
1850
+ const sourceIndex = lower.startsWith('src/') ? 0 : lower.indexOf('/src/') + 1
1851
+ if (sourceIndex <= 0 && !lower.startsWith('src/')) return normalized
1852
+ const openParen = normalized.lastIndexOf('(', sourceIndex)
1853
+ if (openParen < 0) return normalized
1854
+ const closeParen = normalized.indexOf(')', sourceIndex)
1855
+ if (closeParen < 0) return normalized
1856
+ return `${normalized.slice(0, openParen)}${normalized.slice(closeParen + 1)}`.trim()
1857
+ }
1858
+
1859
+ function renderComponentSummary(
1860
+ node: ComponentDebugNode,
1861
+ snapshot: DevToolsSnapshot,
1862
+ selection: DevToolsSelection | null,
1863
+ onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void
1864
+ ): VobsNode {
1865
+ const row = createElement('details')
1866
+ const selected = selection?.type === 'component' && selection.id === node.id
1867
+ setAttribute(row, 'class', `vobs-devtools-list__row vobs-devtools-component-row${selected ? ' is-selected' : ''}`)
1868
+ if (selected) setProperty(row, 'open', true)
1869
+ row.addEventListener('click', event => {
1870
+ event.stopPropagation()
1871
+ onFocus('components', { type: 'component', id: node.id })
1872
+ })
1873
+
1874
+ const summary = createElement('summary')
1875
+ appendText(summary, formatDebugLocation(node.name), 'vobs-devtools-list__name')
1876
+ insertBefore(summary, createComponent(Tag, { tone: node.mounted ? 'success' : 'neutral-strong', children: () => `${node.signals.length} signals` }), null)
1877
+ insertBefore(summary, createComponent(Tag, { tone: 'neutral-strong', children: () => `${node.effects.length} effects` }), null)
1878
+ appendText(summary, `${node.recentUpdates.length} updates · ${node.domUpdates} DOM`, 'vobs-devtools-muted')
1879
+ insertBefore(row, summary, null)
1880
+
1881
+ appendText(row, `Component ID: ${node.id}`, 'vobs-devtools-code')
1882
+ appendText(row, 'Recent updates:', 'vobs-devtools-code')
1883
+ appendUpdateLinks(row, node.recentUpdates, onFocus)
1884
+ if (node.domUpdates > 0) {
1885
+ appendText(row, 'DOM results:', 'vobs-devtools-code')
1886
+ for (const updateId of node.recentUpdates) {
1887
+ const update = snapshot.updates.find(item => item.id === updateId)
1888
+ for (const domUpdate of update?.domUpdates ?? []) {
1889
+ const operation = domUpdate.key ? `${domUpdate.operation}.${domUpdate.key}` : domUpdate.operation
1890
+ appendText(row, `${operation} ${domUpdate.target}`, 'vobs-devtools-muted')
1891
+ }
1892
+ }
1893
+ }
1894
+ if (node.children.length > 0) {
1895
+ appendText(row, 'Children:', 'vobs-devtools-code')
1896
+ for (const child of node.children) insertBefore(row, renderComponentSummary(child, snapshot, selection, onFocus), null)
1897
+ }
1898
+ return row
1899
+ }
1900
+
1901
+ function formatValue(value: unknown): string {
1902
+ if (typeof value === 'string') return value
1903
+ try {
1904
+ return JSON.stringify(value) ?? String(value)
1905
+ } catch {
1906
+ return String(value)
1907
+ }
1908
+ }
1909
+
1910
+ function renderValuePair(label: string, previousValue: unknown, nextValue: unknown): VobsNode {
1911
+ const root = createElement('div')
1912
+ setAttribute(root, 'class', `vobs-devtools-value-pair${label ? '' : ' vobs-devtools-value-pair--compact'}`)
1913
+ const distinctMode = state(Boolean(label))
1914
+ root.addEventListener('click', event => event.stopPropagation())
1915
+ root.addEventListener('pointerdown', event => event.stopPropagation())
1916
+ if (label) {
1917
+ const title = createElement('div')
1918
+ setAttribute(title, 'class', 'vobs-devtools-value-pair__title')
1919
+ appendText(title, label)
1920
+ const toggle = createElement('button')
1921
+ setAttribute(toggle, 'class', 'vobs-devtools-distinct-toggle is-active')
1922
+ setAttribute(toggle, 'type', 'button')
1923
+ setAttribute(toggle, 'aria-pressed', 'true')
1924
+ setAttribute(toggle, 'title', 'Show only values that changed')
1925
+ insertBefore(toggle, createText('distinct'), null)
1926
+ toggle.addEventListener('click', event => {
1927
+ event.stopPropagation()
1928
+ const enabled = !distinctMode.value
1929
+ distinctMode.value = enabled
1930
+ setAttribute(toggle, 'class', `vobs-devtools-distinct-toggle${enabled ? ' is-active' : ''}`)
1931
+ setAttribute(toggle, 'aria-pressed', enabled ? 'true' : 'false')
1932
+ setAttribute(toggle, 'title', enabled ? 'Show only values that changed' : 'Show complete values')
1933
+ })
1934
+ insertBefore(title, toggle, null)
1935
+ insertBefore(root, title, null)
1936
+ }
1937
+ insertDynamic(root, null, () => {
1938
+ const distinct = distinctMode.value
1939
+ const difference = distinct ? createDistinctValuePair(previousValue, nextValue) : null
1940
+ const before = difference?.before ?? { value: previousValue, hasDifference: true }
1941
+ const after = difference?.after ?? { value: nextValue, hasDifference: true }
1942
+ return createFragment((parent, anchor) => {
1943
+ insertBefore(parent, renderValueTree('Before', before.value, Boolean(label), distinct && !before.hasDifference ? 'No differing fields' : undefined), anchor)
1944
+ insertBefore(parent, renderValueTree('After', after.value, Boolean(label), distinct && !after.hasDifference ? 'No differing fields' : undefined), anchor)
1945
+ })
1946
+ })
1947
+ return root
1948
+ }
1949
+
1950
+ function renderValueTree(label: string, value: unknown, expanded: boolean, emptyMessage?: string): VobsNode {
1951
+ const root = createElement('section')
1952
+ setAttribute(root, 'class', 'vobs-devtools-value-inspector')
1953
+ // Value-tree interactions must not select and rerender their enclosing update row.
1954
+ // Otherwise native <details> toggles are immediately replaced by a fresh closed tree.
1955
+ root.addEventListener('click', event => event.stopPropagation())
1956
+ root.addEventListener('pointerdown', event => event.stopPropagation())
1957
+ if (hasExpandableEntries(value)) {
1958
+ const controls = createElement('div')
1959
+ setAttribute(controls, 'class', 'vobs-devtools-value-inspector__controls')
1960
+ insertBefore(controls, createComponent(Button, {
1961
+ variant: 'ghost',
1962
+ iconOnly: true,
1963
+ icon: createComponent(Icon, { name: 'plus' }),
1964
+ 'aria-label': `Expand all ${label} values`,
1965
+ title: `Expand all ${label} values`,
1966
+ onClick: event => {
1967
+ event.stopPropagation()
1968
+ for (const node of root.querySelectorAll('.vobs-devtools-value-tree')) setAttribute(node, 'data-expanded', 'true')
1969
+ }
1970
+ }), null)
1971
+ insertBefore(controls, createComponent(Button, {
1972
+ variant: 'ghost',
1973
+ iconOnly: true,
1974
+ icon: createComponent(Icon, { name: 'minus' }),
1975
+ 'aria-label': `Collapse all ${label} values`,
1976
+ title: `Collapse all ${label} values`,
1977
+ onClick: event => {
1978
+ event.stopPropagation()
1979
+ for (const node of root.querySelectorAll('.vobs-devtools-value-tree')) setAttribute(node, 'data-expanded', 'false')
1980
+ }
1981
+ }), null)
1982
+ insertBefore(root, controls, null)
1983
+ }
1984
+ const tree = createElement('div')
1985
+ setAttribute(tree, 'class', 'vobs-devtools-value-tree-root')
1986
+ setAttribute(tree, 'role', 'tree')
1987
+ setAttribute(tree, 'aria-label', `${label} value`)
1988
+ if (emptyMessage) appendText(tree, emptyMessage, 'vobs-devtools-value-tree__empty')
1989
+ else insertBefore(tree, renderInspectableValue(label, value, expanded), null)
1990
+ insertBefore(root, tree, null)
1991
+ return root
1992
+ }
1993
+
1994
+ interface DistinctValue {
1995
+ readonly value: unknown
1996
+ readonly hasDifference: boolean
1997
+ }
1998
+
1999
+ interface DistinctValuePair {
2000
+ readonly before: DistinctValue
2001
+ readonly after: DistinctValue
2002
+ }
2003
+
2004
+ const MISSING_VALUE = Symbol('missing diagnostic value')
2005
+
2006
+ function createDistinctValuePair(before: unknown, after: unknown): DistinctValuePair {
2007
+ return projectDistinctValues(before, after)
2008
+ }
2009
+
2010
+ function projectDistinctValues(before: unknown, after: unknown): DistinctValuePair {
2011
+ if (areValuesEqual(before, after)) {
2012
+ return {
2013
+ before: { value: before, hasDifference: false },
2014
+ after: { value: after, hasDifference: false }
2015
+ }
2016
+ }
2017
+
2018
+ if (!isExpandableValue(before) || !isExpandableValue(after)) {
2019
+ return {
2020
+ before: { value: before, hasDifference: true },
2021
+ after: { value: after, hasDifference: true }
2022
+ }
2023
+ }
2024
+
2025
+ if (Array.isArray(before) && Array.isArray(after)) {
2026
+ const beforeProjection: unknown[] = new Array(before.length)
2027
+ const afterProjection: unknown[] = new Array(after.length)
2028
+ for (let index = 0; index < Math.max(before.length, after.length); index++) {
2029
+ const child = projectDistinctValues(
2030
+ index in before ? before[index] : MISSING_VALUE,
2031
+ index in after ? after[index] : MISSING_VALUE
2032
+ )
2033
+ if (child.before.value !== MISSING_VALUE) beforeProjection[index] = child.before.value
2034
+ if (child.after.value !== MISSING_VALUE) afterProjection[index] = child.after.value
2035
+ }
2036
+ return {
2037
+ before: { value: beforeProjection, hasDifference: valueEntries(beforeProjection).length > 0 },
2038
+ after: { value: afterProjection, hasDifference: valueEntries(afterProjection).length > 0 }
2039
+ }
2040
+ }
2041
+
2042
+ if (!Array.isArray(before) && !Array.isArray(after)) {
2043
+ const beforeProjection: Record<string, unknown> = {}
2044
+ const afterProjection: Record<string, unknown> = {}
2045
+ const beforeObject = before as Record<string, unknown>
2046
+ const afterObject = after as Record<string, unknown>
2047
+ const keys = new Set([...Object.keys(beforeObject), ...Object.keys(afterObject)])
2048
+ for (const key of keys) {
2049
+ const child = projectDistinctValues(
2050
+ Object.prototype.hasOwnProperty.call(beforeObject, key) ? beforeObject[key] : MISSING_VALUE,
2051
+ Object.prototype.hasOwnProperty.call(afterObject, key) ? afterObject[key] : MISSING_VALUE
2052
+ )
2053
+ if (child.before.value !== MISSING_VALUE) beforeProjection[key] = child.before.value
2054
+ if (child.after.value !== MISSING_VALUE) afterProjection[key] = child.after.value
2055
+ }
2056
+ return {
2057
+ before: { value: beforeProjection, hasDifference: Object.keys(beforeProjection).length > 0 },
2058
+ after: { value: afterProjection, hasDifference: Object.keys(afterProjection).length > 0 }
2059
+ }
2060
+ }
2061
+
2062
+ return {
2063
+ before: { value: before, hasDifference: true },
2064
+ after: { value: after, hasDifference: true }
2065
+ }
2066
+ }
2067
+
2068
+ function areValuesEqual(before: unknown, after: unknown, seen = new WeakMap<object, WeakSet<object>>()): boolean {
2069
+ if (Object.is(before, after)) return true
2070
+ if (!isExpandableValue(before) || !isExpandableValue(after)) return false
2071
+ if (Array.isArray(before) !== Array.isArray(after)) return false
2072
+ if (Object.prototype.toString.call(before) !== Object.prototype.toString.call(after)) return false
2073
+
2074
+ const previousPairs = seen.get(before)
2075
+ if (previousPairs?.has(after)) return true
2076
+ if (previousPairs) previousPairs.add(after)
2077
+ else seen.set(before, new WeakSet([after]))
2078
+
2079
+ const beforeKeys = Object.keys(before)
2080
+ const afterKeys = Object.keys(after)
2081
+ if (beforeKeys.length !== afterKeys.length) return false
2082
+ const beforeObject = before as Record<string, unknown>
2083
+ const afterObject = after as Record<string, unknown>
2084
+ return beforeKeys.every(key => Object.prototype.hasOwnProperty.call(afterObject, key) && areValuesEqual(beforeObject[key], afterObject[key], seen))
2085
+ }
2086
+
2087
+ function renderInspectableValue(
2088
+ label: string,
2089
+ value: unknown,
2090
+ expanded = false,
2091
+ ancestors: readonly object[] = []
2092
+ ): VobsNode {
2093
+ if (!hasExpandableEntries(value) || ancestors.includes(value as object)) {
2094
+ const row = createElement('div')
2095
+ setAttribute(row, 'class', 'vobs-devtools-value-tree__leaf')
2096
+ setAttribute(row, 'role', 'treeitem')
2097
+ appendText(row, label, 'vobs-devtools-value-field__label')
2098
+ appendText(row, ancestors.includes(value as object) ? '[Circular]' : previewValue(value), 'vobs-devtools-code')
2099
+ return row
2100
+ }
2101
+
2102
+ const root = createElement('div')
2103
+ setAttribute(root, 'class', 'vobs-devtools-value-tree')
2104
+ setAttribute(root, 'role', 'treeitem')
2105
+ setAttribute(root, 'data-expanded', expanded ? 'true' : 'false')
2106
+ const summary = createElement('button')
2107
+ setAttribute(summary, 'class', 'vobs-devtools-value-tree__summary')
2108
+ setAttribute(summary, 'type', 'button')
2109
+ setAttribute(summary, 'aria-expanded', expanded ? 'true' : 'false')
2110
+ setAttribute(summary, 'title', `Expand ${label}`)
2111
+ const toggleExpanded = (): void => {
2112
+ const expanded = root.getAttribute('data-expanded') !== 'true'
2113
+ setAttribute(root, 'data-expanded', expanded ? 'true' : 'false')
2114
+ setAttribute(summary, 'aria-expanded', expanded ? 'true' : 'false')
2115
+ setAttribute(summary, 'title', `${expanded ? 'Collapse' : 'Expand'} ${label}`)
2116
+ }
2117
+ summary.addEventListener('pointerdown', event => {
2118
+ event.preventDefault()
2119
+ event.stopPropagation()
2120
+ toggleExpanded()
2121
+ })
2122
+ summary.addEventListener('keydown', event => {
2123
+ const keyboardEvent = event as KeyboardEvent
2124
+ if (keyboardEvent.key !== 'Enter' && keyboardEvent.key !== ' ') return
2125
+ event.preventDefault()
2126
+ event.stopPropagation()
2127
+ toggleExpanded()
2128
+ })
2129
+ appendText(summary, label, 'vobs-devtools-value-field__label')
2130
+ appendText(summary, previewValue(value), 'vobs-devtools-code')
2131
+ insertBefore(root, summary, null)
2132
+
2133
+ const children = createElement('div')
2134
+ setAttribute(children, 'class', 'vobs-devtools-value-tree__children')
2135
+ setAttribute(children, 'role', 'group')
2136
+ for (const [key, child] of valueEntries(value)) {
2137
+ insertBefore(children, renderInspectableValue(key, child, false, [...ancestors, value as object]), null)
2138
+ }
2139
+ insertBefore(root, children, null)
2140
+ return root
2141
+ }
2142
+
2143
+ function isExpandableValue(value: unknown): value is Record<string, unknown> | readonly unknown[] {
2144
+ return typeof value === 'object' && value !== null
2145
+ }
2146
+
2147
+ function hasExpandableEntries(value: unknown): value is Record<string, unknown> | readonly unknown[] {
2148
+ return isExpandableValue(value) && valueEntries(value).length > 0
2149
+ }
2150
+
2151
+ function valueEntries(value: Record<string, unknown> | readonly unknown[]): Array<[string, unknown]> {
2152
+ if (Array.isArray(value)) return value.slice(0, 100).map((item, index) => [`[${index}]`, item])
2153
+ return Object.entries(value).slice(0, 100)
2154
+ }
2155
+
2156
+ function previewValue(value: unknown): string {
2157
+ if (value === undefined) return 'undefined'
2158
+ if (value === null) return 'null'
2159
+ if (typeof value === 'string') return value
2160
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value)
2161
+ if (Array.isArray(value)) return `Array (${value.length} items)`
2162
+ if (typeof value === 'object') return `Object (${Object.keys(value).length} fields)`
2163
+ return String(value)
2164
+ }