nuxt-devtools-observatory 0.1.9 → 0.1.11

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.
@@ -1,135 +1,132 @@
1
1
  <script setup lang="ts">
2
- import { ref, computed, defineComponent, h, type VNode } from 'vue'
2
+ import { computed, defineComponent, h, ref, watch, type VNode } from 'vue'
3
3
  import { useObservatoryData, type RenderEntry } from '../stores/observatory'
4
4
 
5
5
  interface ComponentNode {
6
6
  id: string
7
7
  label: string
8
8
  file: string
9
+ element?: string
10
+ depth: number
11
+ path: string[]
9
12
  renders: number
13
+ navigationRenders: number
10
14
  avgMs: number
11
15
  triggers: string[]
12
16
  children: ComponentNode[]
17
+ parentId?: string
18
+ parentLabel?: string
13
19
  }
14
20
 
15
- const ComponentBlock = defineComponent({
16
- name: 'ComponentBlock',
21
+ const TreeNode = defineComponent({
22
+ name: 'TreeNode',
17
23
  props: {
18
24
  node: Object as () => ComponentNode,
19
25
  mode: String,
20
26
  threshold: Number,
21
- hotOnly: Boolean,
22
27
  selected: String,
28
+ expandedIds: Object as () => Set<string>,
23
29
  },
24
- emits: ['select'],
30
+ emits: ['select', 'toggle'],
25
31
  setup(props, { emit }): () => VNode | null {
26
- function getVal(node: ComponentNode) {
32
+ function nodeValue(node: ComponentNode) {
27
33
  return props.mode === 'count' ? node.renders : node.avgMs
28
34
  }
29
35
 
30
- function getMax(): number {
31
- let max = 1
32
-
33
- function walk(nodes: ComponentNode[]) {
34
- nodes.forEach((node) => {
35
- const value = getVal(node)
36
-
37
- if (value > max) {
38
- max = value
39
- }
40
-
41
- walk(node.children)
42
- })
43
- }
44
-
45
- walk([props.node!])
46
-
47
- return Math.max(max, props.mode === 'count' ? 40 : 20)
36
+ function isHot(node: ComponentNode) {
37
+ return nodeValue(node) >= props.threshold!
48
38
  }
49
39
 
50
- function heatColor(value: number, max: number) {
51
- const ratio = Math.min(value / max, 1)
52
-
53
- if (ratio < 0.25) {
54
- return { bg: '#EAF3DE', text: '#27500A', border: '#97C459' }
40
+ function rowClass(node: ComponentNode) {
41
+ return {
42
+ selected: props.selected === node.id,
43
+ hot: isHot(node),
55
44
  }
56
-
57
- if (ratio < 0.55) {
58
- return { bg: '#FAEEDA', text: '#633806', border: '#EF9F27' }
59
- }
60
-
61
- if (ratio < 0.8) {
62
- return { bg: '#FAECE7', text: '#712B13', border: '#D85A30' }
63
- }
64
-
65
- return { bg: '#FCEBEB', text: '#791F1F', border: '#E24B4A' }
66
- }
67
-
68
- function isHot(node: ComponentNode) {
69
- return (props.mode === 'count' ? node.renders : node.avgMs) >= props.threshold!
70
45
  }
71
46
 
72
47
  return () => {
73
48
  const node = props.node!
49
+ const expanded = props.expandedIds?.has(node.id) ?? false
50
+ const canExpand = node.children.length > 0
51
+ const metric = props.mode === 'count' ? `${node.renders}` : `${node.avgMs.toFixed(1)}ms`
52
+ const metricLabel = props.mode === 'count' ? 'renders' : 'avg'
53
+ const badges = []
54
+ const normalizedElement = node.element?.toLowerCase()
74
55
 
75
- if (props.hotOnly && !isHot(node) && !node.children.some((child) => (props.mode === 'count' ? child.renders : child.avgMs) >= props.threshold!)) {
76
- return null
56
+ if (node.element && node.element !== node.label && !['div', 'span', 'p'].includes(normalizedElement ?? '')) {
57
+ badges.push(node.element)
77
58
  }
78
59
 
79
- const max = getMax()
80
- const value = getVal(node)
81
- const colors = heatColor(value, max)
82
- const isSelected = props.selected === node.id
83
- const unit = props.mode === 'count' ? 'renders' : 'ms avg'
84
- const valueLabel = props.mode === 'count' ? String(value) : `${value.toFixed(1)}ms`
85
-
86
- return h(
87
- 'div',
88
- {
89
- style: {
90
- background: colors.bg,
91
- border: isSelected ? `2px solid ${colors.border}` : `1px solid ${colors.border}`,
92
- borderRadius: '6px',
93
- padding: '6px 9px',
94
- marginBottom: '5px',
95
- cursor: 'pointer',
60
+ if (node.file !== 'unknown') {
61
+ const fileBadge = node.file.split('/').pop()?.replace(/\.vue$/i, '') ?? node.file
62
+
63
+ if (fileBadge !== node.label && !badges.includes(fileBadge)) {
64
+ badges.push(fileBadge)
65
+ }
66
+ }
67
+
68
+ return h('div', { class: 'tree-node' }, [
69
+ h(
70
+ 'div',
71
+ {
72
+ class: ['tree-row', rowClass(node)],
73
+ style: { '--tree-depth': String(node.depth) },
74
+ onClick: (event: MouseEvent) => {
75
+ event.stopPropagation()
76
+ emit('select', node)
77
+ },
96
78
  },
97
- onClick: () => emit('select', node),
98
- },
99
- [
100
- h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, [
101
- h('span', { style: { fontFamily: 'var(--mono)', fontSize: '11px', fontWeight: '500', color: colors.text } }, node.label),
79
+ [
80
+ h('span', { class: 'tree-rail', 'aria-hidden': 'true' }),
102
81
  h(
103
- 'span',
104
- { style: { fontFamily: 'var(--mono)', fontSize: '10px', color: colors.text, opacity: '0.7', marginLeft: 'auto' } },
105
- `${valueLabel} ${unit}`
82
+ 'button',
83
+ {
84
+ class: ['tree-toggle', { empty: !canExpand }],
85
+ disabled: !canExpand,
86
+ onClick: (event: MouseEvent) => {
87
+ event.stopPropagation()
88
+
89
+ if (canExpand) {
90
+ emit('toggle', node.id)
91
+ }
92
+ },
93
+ },
94
+ canExpand ? (expanded ? '⌄' : '›') : ''
106
95
  ),
107
- ]),
108
- node.children.length
109
- ? h(
110
- 'div',
111
- {
112
- style: {
113
- marginLeft: '10px',
114
- borderLeft: `1.5px solid ${colors.border}40`,
115
- paddingLeft: '8px',
116
- marginTop: '5px',
117
- },
118
- },
119
- node.children.map((child) =>
120
- h(ComponentBlock, {
121
- node: child,
122
- mode: props.mode,
123
- threshold: props.threshold,
124
- hotOnly: props.hotOnly,
125
- selected: props.selected,
126
- onSelect: (value: ComponentNode) => emit('select', value),
127
- })
128
- )
96
+ h('div', { class: 'tree-copy' }, [
97
+ h('span', { class: 'tree-name mono', title: node.label }, node.label),
98
+ badges.length
99
+ ? h(
100
+ 'div',
101
+ { class: 'tree-badges' },
102
+ badges.slice(0, 1).map((badge) => h('span', { class: 'tree-badge mono', title: badge }, badge))
103
+ )
104
+ : null,
105
+ ]),
106
+ h('div', { class: 'tree-metrics mono' }, [
107
+ node.navigationRenders ? h('span', { class: 'tree-nav-pill' }, `${node.navigationRenders} nav`) : null,
108
+ h('span', { class: 'tree-metric-pill' }, `${metric} ${metricLabel}`),
109
+ ]),
110
+ ]
111
+ ),
112
+ expanded && canExpand
113
+ ? h(
114
+ 'div',
115
+ { class: 'tree-children' },
116
+ node.children.map((child) =>
117
+ h(TreeNode, {
118
+ node: child,
119
+ mode: props.mode,
120
+ threshold: props.threshold,
121
+ selected: props.selected,
122
+ expandedIds: props.expandedIds,
123
+ onSelect: (value: ComponentNode) => emit('select', value),
124
+ onToggle: (value: string) => emit('toggle', value),
125
+ })
129
126
  )
130
- : null,
131
- ]
132
- )
127
+ )
128
+ : null,
129
+ ])
133
130
  }
134
131
  },
135
132
  })
@@ -140,8 +137,34 @@ const activeMode = ref<'count' | 'time'>('count')
140
137
  const activeThreshold = ref(5)
141
138
  const activeHotOnly = ref(false)
142
139
  const frozen = ref(false)
140
+ const search = ref('')
143
141
  const activeSelectedId = ref<string | null>(null)
144
- const frozenSnapshot = ref<ComponentNode[]>([])
142
+ const activeRootId = ref<string | null>(null)
143
+ const expandedIds = ref<Set<string>>(new Set())
144
+ const frozenSnapshot = ref<RenderEntry[]>([])
145
+ const expansionReady = ref(false)
146
+
147
+ function displayLabel(entry: RenderEntry) {
148
+ if (entry.name && entry.name !== 'unknown' && !/^Component#\d+$/.test(entry.name)) {
149
+ return entry.name
150
+ }
151
+
152
+ if (entry.element) {
153
+ return entry.element
154
+ }
155
+
156
+ const basename = entry.file.split('/').pop()?.replace(/\.vue$/i, '')
157
+
158
+ if (basename && basename !== 'unknown') {
159
+ return basename
160
+ }
161
+
162
+ if (entry.name && entry.name !== 'unknown') {
163
+ return entry.name
164
+ }
165
+
166
+ return `Component#${entry.uid}`
167
+ }
145
168
 
146
169
  function formatTrigger(trigger: RenderEntry['triggers'][number]) {
147
170
  return `${trigger.type}: ${trigger.key}`
@@ -153,12 +176,17 @@ function buildNodes(entries: RenderEntry[]) {
153
176
  for (const entry of entries) {
154
177
  byId.set(String(entry.uid), {
155
178
  id: String(entry.uid),
156
- label: entry.file.split('/').pop() ?? entry.name,
179
+ label: displayLabel(entry),
157
180
  file: entry.file,
181
+ element: entry.element,
182
+ depth: 0,
183
+ path: [],
158
184
  renders: entry.renders,
185
+ navigationRenders: Number.isFinite(entry.navigationRenders) ? entry.navigationRenders : 0,
159
186
  avgMs: entry.avgMs,
160
187
  triggers: entry.triggers.map(formatTrigger),
161
188
  children: [],
189
+ parentId: entry.parentUid !== undefined ? String(entry.parentUid) : undefined,
162
190
  })
163
191
  }
164
192
 
@@ -174,35 +202,217 @@ function buildNodes(entries: RenderEntry[]) {
174
202
  const parent = entry.parentUid !== undefined ? byId.get(String(entry.parentUid)) : undefined
175
203
 
176
204
  if (parent) {
205
+ node.parentLabel = parent.label
177
206
  parent.children.push(node)
178
207
  } else {
179
208
  roots.push(node)
180
209
  }
181
210
  }
182
211
 
212
+ function finalize(node: ComponentNode, path: string[] = [], depth = 0) {
213
+ node.depth = depth
214
+ node.path = [...path, node.label]
215
+ node.children.forEach((child) => finalize(child, node.path, depth + 1))
216
+ }
217
+
218
+ roots.forEach((root) => finalize(root))
219
+
183
220
  return roots
184
221
  }
185
222
 
186
- const liveNodes = computed(() => buildNodes(renders.value))
187
- const rootNodes = computed(() => (frozen.value ? frozenSnapshot.value : liveNodes.value))
223
+ function flatten(nodes: ComponentNode[]) {
224
+ const flat: ComponentNode[] = []
188
225
 
189
- const allComponents = computed(() => {
190
- const all: ComponentNode[] = []
226
+ function walk(node: ComponentNode) {
227
+ flat.push(node)
228
+ node.children.forEach(walk)
229
+ }
191
230
 
192
- function collect(nodes: ComponentNode[]) {
193
- nodes.forEach((node) => {
194
- all.push(node)
195
- collect(node.children)
196
- })
231
+ nodes.forEach(walk)
232
+
233
+ return flat
234
+ }
235
+
236
+ function countSubtree(node: ComponentNode): number {
237
+ return 1 + node.children.reduce((sum, child) => sum + countSubtree(child), 0)
238
+ }
239
+
240
+ function collectIds(node: ComponentNode, target = new Set<string>()) {
241
+ target.add(node.id)
242
+ node.children.forEach((child) => collectIds(child, target))
243
+ return target
244
+ }
245
+
246
+ function pathToNode(node: ComponentNode, targetId: string, trail: string[] = []): string[] | null {
247
+ const nextTrail = [...trail, node.id]
248
+
249
+ if (node.id === targetId) {
250
+ return nextTrail
197
251
  }
198
252
 
199
- collect(rootNodes.value)
253
+ for (const child of node.children) {
254
+ const childTrail = pathToNode(child, targetId, nextTrail)
255
+
256
+ if (childTrail) {
257
+ return childTrail
258
+ }
259
+ }
200
260
 
201
- return all
261
+ return null
262
+ }
263
+
264
+ function findFirstHotNode(node: ComponentNode): ComponentNode | null {
265
+ if (isHot(node)) {
266
+ return node
267
+ }
268
+
269
+ for (const child of node.children) {
270
+ const match = findFirstHotNode(child)
271
+
272
+ if (match) {
273
+ return match
274
+ }
275
+ }
276
+
277
+ return null
278
+ }
279
+
280
+ function defaultExpandedIds(root: ComponentNode | null) {
281
+ if (!root) {
282
+ return new Set<string>()
283
+ }
284
+
285
+ return new Set([root.id])
286
+ }
287
+
288
+ function searchExpandedIds(root: ComponentNode | null, term: string) {
289
+ const expanded = defaultExpandedIds(root)
290
+
291
+ if (!root || !term) {
292
+ return expanded
293
+ }
294
+
295
+ function visit(node: ComponentNode): boolean {
296
+ const childMatched = node.children.some((child) => visit(child))
297
+ const selfMatched = matchesSearch(node, term)
298
+
299
+ if (childMatched) {
300
+ expanded.add(node.id)
301
+ }
302
+
303
+ return selfMatched || childMatched
304
+ }
305
+
306
+ visit(root)
307
+
308
+ return expanded
309
+ }
310
+
311
+ function nodeValue(node: ComponentNode) {
312
+ return activeMode.value === 'count' ? node.renders : node.avgMs
313
+ }
314
+
315
+ function isHot(node: ComponentNode) {
316
+ return nodeValue(node) >= activeThreshold.value
317
+ }
318
+
319
+ function matchesSearch(node: ComponentNode, searchTerm: string): boolean {
320
+ if (!searchTerm) {
321
+ return true
322
+ }
323
+
324
+ const query = searchTerm.toLowerCase()
325
+
326
+ return (
327
+ node.label.toLowerCase().includes(query) ||
328
+ node.file.toLowerCase().includes(query) ||
329
+ node.path.some((segment) => segment.toLowerCase().includes(query)) ||
330
+ node.triggers.some((trigger) => trigger.toLowerCase().includes(query))
331
+ )
332
+ }
333
+
334
+ function treeMatches(node: ComponentNode, searchTerm: string): boolean {
335
+ if (!searchTerm) {
336
+ return true
337
+ }
338
+
339
+ return matchesSearch(node, searchTerm) || node.children.some((child) => treeMatches(child, searchTerm))
340
+ }
341
+
342
+ function subtreeHasHotNode(node: ComponentNode): boolean {
343
+ return isHot(node) || node.children.some((child) => subtreeHasHotNode(child))
344
+ }
345
+
346
+ function isVisibleRoot(node: ComponentNode, searchTerm: string): boolean {
347
+ const matchesCurrentSearch = treeMatches(node, searchTerm)
348
+ const matchesCurrentHeat = !activeHotOnly.value || subtreeHasHotNode(node)
349
+
350
+ return matchesCurrentSearch && matchesCurrentHeat
351
+ }
352
+
353
+ function pruneVisibleTree(node: ComponentNode, searchTerm: string): ComponentNode | null {
354
+ const visibleChildren = node.children
355
+ .map((child) => pruneVisibleTree(child, searchTerm))
356
+ .filter((child): child is ComponentNode => child !== null)
357
+
358
+ const matchesCurrentSearch = !searchTerm || matchesSearch(node, searchTerm) || visibleChildren.length > 0
359
+ const matchesCurrentHeat = !activeHotOnly.value || isHot(node) || visibleChildren.length > 0
360
+
361
+ if (!matchesCurrentSearch || !matchesCurrentHeat) {
362
+ return null
363
+ }
364
+
365
+ return {
366
+ ...node,
367
+ children: visibleChildren,
368
+ }
369
+ }
370
+
371
+ const displayEntries = computed(() => (frozen.value ? frozenSnapshot.value : renders.value))
372
+ const rootNodes = computed(() => buildNodes(displayEntries.value))
373
+ const rootMap = computed(() => new Map(rootNodes.value.map((node) => [node.id, node])))
374
+ const allComponents = computed(() => flatten(rootNodes.value))
375
+
376
+ const filteredRoots = computed(() => {
377
+ const term = search.value.trim()
378
+
379
+ return rootNodes.value.filter((root) => isVisibleRoot(root, term))
380
+ })
381
+
382
+ const activeRoot = computed(() => {
383
+ if (activeRootId.value) {
384
+ return filteredRoots.value.find((node) => node.id === activeRootId.value) ?? rootMap.value.get(activeRootId.value) ?? null
385
+ }
386
+
387
+ return filteredRoots.value[0] ?? rootNodes.value[0] ?? null
202
388
  })
203
389
 
390
+ const visibleActiveRoot = computed(() => {
391
+ const term = search.value.trim()
392
+
393
+ return activeRoot.value ? pruneVisibleTree(activeRoot.value, term) : null
394
+ })
395
+
396
+ const visibleTreeRoots = computed(() => {
397
+ if (!visibleActiveRoot.value) {
398
+ return []
399
+ }
400
+
401
+ return [visibleActiveRoot.value]
402
+ })
403
+
404
+ const appEntries = computed(() =>
405
+ filteredRoots.value.map((root, index) => ({
406
+ id: root.id,
407
+ label: `App ${index + 1}`,
408
+ meta: `${countSubtree(root)} nodes`,
409
+ root,
410
+ }))
411
+ )
412
+
204
413
  const activeSelected = computed(() => allComponents.value.find((node) => node.id === activeSelectedId.value) ?? null)
205
414
  const totalRenders = computed(() => allComponents.value.reduce((sum, node) => sum + node.renders, 0))
415
+ const totalNavigationRenders = computed(() => allComponents.value.reduce((sum, node) => sum + (Number.isFinite(node.navigationRenders) ? node.navigationRenders : 0), 0))
206
416
  const hotCount = computed(() => allComponents.value.filter((node) => isHot(node)).length)
207
417
  const avgTime = computed(() => {
208
418
  const components = allComponents.value.filter((node) => node.avgMs > 0)
@@ -214,8 +424,129 @@ const avgTime = computed(() => {
214
424
  return (components.reduce((sum, node) => sum + node.avgMs, 0) / components.length).toFixed(1)
215
425
  })
216
426
 
217
- function isHot(node: ComponentNode) {
218
- return (activeMode.value === 'count' ? node.renders : node.avgMs) >= activeThreshold.value
427
+ watch(
428
+ rootNodes,
429
+ (roots) => {
430
+ if (!roots.length) {
431
+ activeRootId.value = null
432
+ activeSelectedId.value = null
433
+ expandedIds.value = new Set()
434
+ expansionReady.value = false
435
+ return
436
+ }
437
+
438
+ const rootIds = new Set(roots.map((root) => root.id))
439
+
440
+ if (!activeRootId.value || !rootIds.has(activeRootId.value)) {
441
+ activeRootId.value = roots[0].id
442
+ }
443
+
444
+ if (activeSelectedId.value && !allComponents.value.some((node) => node.id === activeSelectedId.value)) {
445
+ activeSelectedId.value = null
446
+ }
447
+
448
+ const validIds = new Set(allComponents.value.map((node) => node.id))
449
+ const preserved = new Set([...expandedIds.value].filter((id) => validIds.has(id)))
450
+
451
+ if (!expansionReady.value) {
452
+ expandedIds.value = defaultExpandedIds(activeRoot.value)
453
+ expansionReady.value = true
454
+ return
455
+ }
456
+
457
+ if (!search.value.trim() && activeSelectedId.value && activeRoot.value) {
458
+ const selectedPath = pathToNode(activeRoot.value, activeSelectedId.value) ?? []
459
+
460
+ selectedPath.forEach((id) => preserved.add(id))
461
+ }
462
+
463
+ expandedIds.value = preserved
464
+ },
465
+ { immediate: true }
466
+ )
467
+
468
+ watch(search, (term) => {
469
+ if (!activeRoot.value) {
470
+ return
471
+ }
472
+
473
+ const normalized = term.trim()
474
+
475
+ if (normalized) {
476
+ expandedIds.value = searchExpandedIds(activeRoot.value, normalized)
477
+ return
478
+ }
479
+
480
+ if (activeSelectedId.value) {
481
+ const selectedPath = pathToNode(activeRoot.value, activeSelectedId.value)
482
+
483
+ expandedIds.value = selectedPath ? new Set(selectedPath) : defaultExpandedIds(activeRoot.value)
484
+ return
485
+ }
486
+
487
+ expandedIds.value = defaultExpandedIds(activeRoot.value)
488
+ })
489
+
490
+ watch([activeHotOnly, activeThreshold, activeMode, filteredRoots], () => {
491
+ if (!activeHotOnly.value) {
492
+ return
493
+ }
494
+
495
+ const topLevelRoot = filteredRoots.value[0] ?? null
496
+
497
+ if (!topLevelRoot) {
498
+ activeSelectedId.value = null
499
+ return
500
+ }
501
+
502
+ const firstHot = findFirstHotNode(topLevelRoot)
503
+
504
+ if (!firstHot) {
505
+ activeSelectedId.value = null
506
+ return
507
+ }
508
+
509
+ activeRootId.value = topLevelRoot.id
510
+
511
+ if (activeSelectedId.value !== firstHot.id) {
512
+ activeSelectedId.value = firstHot.id
513
+ }
514
+
515
+ expandedIds.value = new Set(pathToNode(topLevelRoot, firstHot.id) ?? [topLevelRoot.id])
516
+ })
517
+
518
+ function selectNode(node: ComponentNode) {
519
+ activeSelectedId.value = node.id
520
+
521
+ const topLevelRoot = rootNodes.value.find((root) => collectIds(root).has(node.id))
522
+
523
+ if (topLevelRoot) {
524
+ activeRootId.value = topLevelRoot.id
525
+ expandedIds.value = new Set(pathToNode(topLevelRoot, node.id) ?? [topLevelRoot.id])
526
+ }
527
+ }
528
+
529
+ function toggleNode(id: string) {
530
+ const next = new Set(expandedIds.value)
531
+
532
+ if (next.has(id)) {
533
+ next.delete(id)
534
+ } else {
535
+ next.add(id)
536
+ }
537
+
538
+ expandedIds.value = next
539
+ }
540
+
541
+ function selectRoot(root: ComponentNode) {
542
+ activeRootId.value = root.id
543
+ expandedIds.value = defaultExpandedIds(root)
544
+ expansionReady.value = true
545
+ }
546
+
547
+ function updateSearch(event: Event) {
548
+ const target = event.target as HTMLInputElement | null
549
+ search.value = target?.value ?? ''
219
550
  }
220
551
 
221
552
  function toggleFreeze() {
@@ -225,9 +556,17 @@ function toggleFreeze() {
225
556
  return
226
557
  }
227
558
 
228
- frozenSnapshot.value = JSON.parse(JSON.stringify(liveNodes.value)) as ComponentNode[]
559
+ frozenSnapshot.value = JSON.parse(JSON.stringify(renders.value)) as RenderEntry[]
229
560
  frozen.value = true
230
561
  }
562
+
563
+ function basename(file: string) {
564
+ return file.split('/').pop()?.replace(/\.vue$/i, '') ?? file
565
+ }
566
+
567
+ function pathLabel(node: ComponentNode) {
568
+ return node.path.join(' / ')
569
+ }
231
570
  </script>
232
571
 
233
572
  <template>
@@ -256,6 +595,7 @@ function toggleFreeze() {
256
595
  <div class="stat-card">
257
596
  <div class="stat-label">total renders</div>
258
597
  <div class="stat-val">{{ totalRenders }}</div>
598
+ <div class="stat-sub mono">{{ totalNavigationRenders }} nav</div>
259
599
  </div>
260
600
  <div class="stat-card">
261
601
  <div class="stat-label">hot</div>
@@ -267,50 +607,90 @@ function toggleFreeze() {
267
607
  </div>
268
608
  </div>
269
609
 
270
- <div class="split">
271
- <div class="page-frame">
272
- <div class="legend">
273
- <div class="swatch-row">
274
- <span class="swatch" style="background: #eaf3de"></span>
275
- <span class="swatch" style="background: #97c459"></span>
276
- <span class="swatch" style="background: #ef9f27"></span>
277
- <span class="swatch" style="background: #e24b4a"></span>
610
+ <div class="inspector">
611
+ <aside class="roots-panel">
612
+ <div class="panel-title">apps</div>
613
+ <button
614
+ v-for="entry in appEntries"
615
+ :key="entry.id"
616
+ class="root-item"
617
+ :class="{ active: activeRootId === entry.id }"
618
+ @click="selectRoot(entry.root)"
619
+ >
620
+ <div class="root-copy">
621
+ <span class="root-label mono">{{ entry.label }}</span>
622
+ <span class="root-sub muted mono">{{ entry.root.label }}</span>
278
623
  </div>
279
- <span class="muted text-sm">cool hot</span>
624
+ <span class="root-meta mono">{{ entry.meta }}</span>
625
+ </button>
626
+ <div v-if="!appEntries.length" class="detail-empty">no apps match</div>
627
+ </aside>
628
+
629
+ <section class="tree-panel">
630
+ <div class="tree-toolbar">
631
+ <input :value="search" class="search-input mono" placeholder="Find components..." @input="updateSearch" />
280
632
  </div>
281
- <ComponentBlock
282
- v-for="rootNode in rootNodes"
283
- :key="rootNode.id"
284
- :node="rootNode"
285
- :mode="activeMode"
286
- :threshold="activeThreshold"
287
- :hot-only="activeHotOnly"
288
- :selected="activeSelected?.id"
289
- @select="activeSelectedId = $event.id"
290
- />
291
- <div v-if="!rootNodes.length" class="detail-empty" style="height: 180px; margin-top: 12px">
292
- {{ connected ? 'No render activity recorded yet.' : 'Waiting for connection to the Nuxt app…' }}
633
+
634
+ <div class="tree-frame">
635
+ <div class="tree-canvas">
636
+ <TreeNode
637
+ v-for="root in visibleTreeRoots"
638
+ :key="root.id"
639
+ :node="root"
640
+ :mode="activeMode"
641
+ :threshold="activeThreshold"
642
+ :selected="activeSelected?.id"
643
+ :expanded-ids="expandedIds"
644
+ @select="selectNode"
645
+ @toggle="toggleNode"
646
+ />
647
+ </div>
648
+ <div v-if="!visibleTreeRoots.length" class="detail-empty">
649
+ {{ connected ? 'No render activity recorded yet.' : 'Waiting for connection to the Nuxt app…' }}
650
+ </div>
293
651
  </div>
294
- </div>
652
+ </section>
295
653
 
296
- <div class="sidebar">
654
+ <aside class="detail-panel">
297
655
  <template v-if="activeSelected">
298
656
  <div class="detail-header">
299
657
  <span class="mono bold" style="font-size: 12px">{{ activeSelected.label }}</span>
300
658
  <button @click="activeSelectedId = null">×</button>
301
659
  </div>
302
660
 
661
+ <div class="detail-pill-row">
662
+ <span class="detail-pill mono">{{ activeSelected.renders }} renders</span>
663
+ <span v-if="activeSelected.navigationRenders" class="detail-pill mono nav">{{ activeSelected.navigationRenders }} nav</span>
664
+ <span class="detail-pill mono">{{ activeSelected.avgMs.toFixed(1) }}ms avg</span>
665
+ <span class="detail-pill mono" :class="{ hot: isHot(activeSelected) }">{{ isHot(activeSelected) ? 'hot' : 'cool' }}</span>
666
+ </div>
667
+
668
+ <div class="section-label">identity</div>
303
669
  <div class="meta-grid">
304
- <span class="muted text-sm">renders</span>
305
- <span class="mono text-sm">{{ activeSelected.renders }}</span>
306
- <span class="muted text-sm">avg time</span>
307
- <span class="mono text-sm">{{ activeSelected.avgMs.toFixed(1) }}ms</span>
308
- <span class="muted text-sm">hot?</span>
309
- <span class="text-sm" :style="{ color: isHot(activeSelected) ? 'var(--red)' : 'var(--teal)' }">
310
- {{ isHot(activeSelected) ? 'yes' : 'no' }}
311
- </span>
670
+ <span class="muted text-sm">label</span>
671
+ <span class="mono text-sm">{{ activeSelected.label }}</span>
672
+ <span class="muted text-sm">path</span>
673
+ <span class="mono text-sm">{{ pathLabel(activeSelected) }}</span>
312
674
  <span class="muted text-sm">file</span>
313
675
  <span class="mono text-sm muted">{{ activeSelected.file }}</span>
676
+ <span class="muted text-sm">file name</span>
677
+ <span class="mono text-sm">{{ basename(activeSelected.file) }}</span>
678
+ <span class="muted text-sm">parent</span>
679
+ <span class="mono text-sm">{{ activeSelected.parentLabel ?? 'none' }}</span>
680
+ <span class="muted text-sm">children</span>
681
+ <span class="mono text-sm">{{ activeSelected.children.length }}</span>
682
+ </div>
683
+
684
+ <div class="section-label">rendering</div>
685
+ <div class="meta-grid">
686
+ <span class="muted text-sm">mode value</span>
687
+ <span class="mono text-sm">{{ activeMode === 'count' ? activeSelected.renders : `${activeSelected.avgMs.toFixed(1)}ms` }}</span>
688
+ <span class="muted text-sm">navigation renders</span>
689
+ <span class="mono text-sm">{{ activeSelected.navigationRenders }}</span>
690
+ <span class="muted text-sm">threshold</span>
691
+ <span class="mono text-sm">{{ activeThreshold }}</span>
692
+ <span class="muted text-sm">selected mode</span>
693
+ <span class="mono text-sm">{{ activeMode === 'count' ? 'render count' : 'render time' }}</span>
314
694
  </div>
315
695
 
316
696
  <div class="section-label">triggers</div>
@@ -318,7 +698,7 @@ function toggleFreeze() {
318
698
  <div v-if="!activeSelected.triggers.length" class="muted text-sm">no triggers recorded</div>
319
699
  </template>
320
700
  <div v-else class="detail-empty">click a component to inspect</div>
321
- </div>
701
+ </aside>
322
702
  </div>
323
703
  </div>
324
704
  </template>
@@ -359,54 +739,256 @@ function toggleFreeze() {
359
739
  flex-shrink: 0;
360
740
  }
361
741
 
362
- .split {
363
- display: flex;
742
+ .stat-sub {
743
+ margin-top: 4px;
744
+ font-size: 11px;
745
+ color: var(--text3);
746
+ }
747
+
748
+ .inspector {
749
+ display: grid;
750
+ grid-template-columns: minmax(220px, 280px) minmax(0, 1fr) minmax(260px, 320px);
364
751
  gap: 12px;
365
752
  flex: 1;
366
- overflow: hidden;
367
753
  min-height: 0;
368
754
  }
369
755
 
370
- .page-frame {
371
- flex: 1;
372
- overflow: auto;
756
+ .roots-panel,
757
+ .tree-panel,
758
+ .detail-panel {
373
759
  border: 0.5px solid var(--border);
374
760
  border-radius: var(--radius-lg);
375
- padding: 12px;
376
761
  background: var(--bg3);
762
+ min-height: 0;
763
+ }
764
+
765
+ .roots-panel,
766
+ .detail-panel {
767
+ display: flex;
768
+ flex-direction: column;
769
+ overflow: auto;
770
+ padding: 12px;
771
+ gap: 8px;
772
+ }
773
+
774
+ .panel-title {
775
+ font-size: 10px;
776
+ font-weight: 500;
777
+ text-transform: uppercase;
778
+ letter-spacing: 0.4px;
779
+ color: var(--text3);
377
780
  }
378
781
 
379
- .legend {
782
+ .root-item {
380
783
  display: flex;
381
784
  align-items: center;
785
+ justify-content: space-between;
382
786
  gap: 8px;
383
- margin-bottom: 10px;
787
+ width: 100%;
788
+ padding: 10px 12px;
789
+ border: 1px solid var(--border);
790
+ border-radius: var(--radius);
791
+ background: var(--bg2);
792
+ color: var(--text);
793
+ text-align: left;
384
794
  }
385
795
 
386
- .swatch-row {
796
+ .root-item.active {
797
+ border-color: var(--teal);
798
+ background: color-mix(in srgb, var(--teal) 16%, var(--bg2));
799
+ }
800
+
801
+ .root-label {
802
+ overflow: hidden;
803
+ text-overflow: ellipsis;
804
+ white-space: nowrap;
805
+ }
806
+
807
+ .root-copy {
387
808
  display: flex;
388
- gap: 2px;
809
+ flex-direction: column;
810
+ min-width: 0;
389
811
  }
390
812
 
391
- .swatch {
392
- width: 16px;
393
- height: 8px;
394
- border-radius: 2px;
813
+ .root-sub {
814
+ font-size: 11px;
395
815
  }
396
816
 
397
- .sidebar {
398
- width: 260px;
399
- flex-shrink: 0;
817
+ .root-meta {
818
+ color: var(--text3);
819
+ font-size: 11px;
820
+ }
821
+
822
+ .tree-panel {
823
+ display: flex;
824
+ flex-direction: column;
825
+ overflow: hidden;
826
+ }
827
+
828
+ .tree-toolbar {
829
+ padding: 12px;
830
+ border-bottom: 0.5px solid var(--border);
831
+ }
832
+
833
+ .search-input {
834
+ width: 100%;
835
+ padding: 10px 12px;
836
+ border: 1px solid var(--border);
837
+ border-radius: var(--radius);
838
+ background: var(--bg2);
839
+ color: var(--text);
840
+ }
841
+
842
+ .tree-frame {
843
+ flex: 1;
844
+ min-height: 0;
400
845
  overflow: auto;
401
- border: 0.5px solid var(--border);
402
- border-radius: var(--radius-lg);
403
846
  padding: 12px;
404
- background: var(--bg3);
847
+ }
848
+
849
+ :deep(.tree-canvas) {
850
+ display: inline-block;
851
+ min-width: 100%;
852
+ width: max-content;
853
+ }
854
+
855
+ :deep(.tree-node) {
856
+ margin-bottom: 4px;
857
+ }
858
+
859
+ :deep(.tree-row) {
860
+ display: grid;
861
+ grid-template-columns: 8px 18px minmax(0, 1fr) auto;
862
+ align-items: center;
863
+ gap: 6px;
864
+ min-width: 0;
865
+ width: 100%;
866
+ padding: 4px 8px;
867
+ padding-left: calc(8px + (var(--tree-depth, 0) * 16px));
868
+ border: 1px solid transparent;
869
+ border-radius: var(--radius);
870
+ cursor: pointer;
871
+ white-space: nowrap;
872
+ }
873
+
874
+ :deep(.tree-row:hover) {
875
+ background: var(--bg2);
876
+ }
877
+
878
+ :deep(.tree-row.selected) {
879
+ background: color-mix(in srgb, var(--teal) 12%, var(--bg2));
880
+ border-color: var(--teal);
881
+ }
882
+
883
+ :deep(.tree-row.hot) {
884
+ box-shadow: inset 2px 0 0 var(--red);
885
+ }
886
+
887
+ :deep(.tree-toggle) {
888
+ width: 16px;
889
+ height: 16px;
890
+ border: none;
891
+ background: transparent;
892
+ color: var(--text3);
893
+ padding: 0;
894
+ font-size: 14px;
895
+ display: inline-flex;
896
+ align-items: center;
897
+ justify-content: center;
898
+ }
899
+
900
+ :deep(.tree-toggle:disabled) {
901
+ cursor: default;
902
+ }
903
+
904
+ :deep(.tree-toggle.empty) {
905
+ opacity: 0;
906
+ }
907
+
908
+ :deep(.tree-rail) {
909
+ display: block;
910
+ width: 2px;
911
+ height: 14px;
912
+ border-radius: 999px;
913
+ background: color-mix(in srgb, var(--border) 75%, transparent);
914
+ }
915
+
916
+ :deep(.tree-copy) {
405
917
  display: flex;
406
- flex-direction: column;
918
+ align-items: center;
919
+ min-width: 0;
920
+ gap: 6px;
921
+ overflow: hidden;
922
+ }
923
+
924
+ :deep(.tree-name) {
925
+ font-size: 12px;
926
+ color: var(--text);
927
+ min-width: 0;
928
+ overflow: hidden;
929
+ text-overflow: ellipsis;
930
+ }
931
+
932
+ :deep(.tree-badges) {
933
+ display: flex;
934
+ gap: 6px;
935
+ flex-shrink: 0;
936
+ overflow: hidden;
937
+ }
938
+
939
+ :deep(.tree-badge) {
940
+ border: 1px solid var(--border);
941
+ border-radius: 999px;
942
+ padding: 2px 7px;
943
+ font-size: 10px;
944
+ color: var(--text3);
945
+ white-space: nowrap;
946
+ overflow: hidden;
947
+ text-overflow: ellipsis;
948
+ max-width: 160px;
949
+ }
950
+
951
+ :deep(.tree-metrics) {
952
+ display: flex;
953
+ align-items: center;
954
+ min-width: 92px;
955
+ justify-content: flex-end;
956
+ flex-shrink: 0;
407
957
  gap: 6px;
408
958
  }
409
959
 
960
+ :deep(.tree-metric-pill) {
961
+ display: inline-flex;
962
+ align-items: center;
963
+ justify-content: center;
964
+ min-width: 78px;
965
+ padding: 2px 8px;
966
+ border: 1px solid var(--border);
967
+ border-radius: 999px;
968
+ background: var(--bg2);
969
+ font-size: 10px;
970
+ color: var(--text3);
971
+ }
972
+
973
+ :deep(.tree-nav-pill) {
974
+ display: inline-flex;
975
+ align-items: center;
976
+ justify-content: center;
977
+ min-width: 54px;
978
+ padding: 2px 8px;
979
+ border: 1px solid color-mix(in srgb, var(--purple) 55%, var(--border));
980
+ border-radius: 999px;
981
+ background: color-mix(in srgb, var(--purple) 10%, var(--bg2));
982
+ font-size: 10px;
983
+ color: color-mix(in srgb, var(--purple) 70%, white);
984
+ }
985
+
986
+ :deep(.tree-children) {
987
+ margin-left: 7px;
988
+ padding-left: 11px;
989
+ border-left: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
990
+ }
991
+
410
992
  .detail-empty {
411
993
  display: flex;
412
994
  align-items: center;
@@ -428,6 +1010,30 @@ function toggleFreeze() {
428
1010
  gap: 4px 12px;
429
1011
  }
430
1012
 
1013
+ .detail-pill-row {
1014
+ display: flex;
1015
+ flex-wrap: wrap;
1016
+ gap: 6px;
1017
+ }
1018
+
1019
+ .detail-pill {
1020
+ border: 1px solid var(--border);
1021
+ border-radius: 999px;
1022
+ padding: 4px 8px;
1023
+ background: var(--bg2);
1024
+ font-size: 11px;
1025
+ }
1026
+
1027
+ .detail-pill.hot {
1028
+ border-color: color-mix(in srgb, var(--red) 50%, var(--border));
1029
+ color: var(--red);
1030
+ }
1031
+
1032
+ .detail-pill.nav {
1033
+ border-color: color-mix(in srgb, var(--purple) 55%, var(--border));
1034
+ color: color-mix(in srgb, var(--purple) 70%, white);
1035
+ }
1036
+
431
1037
  .section-label {
432
1038
  font-size: 10px;
433
1039
  font-weight: 500;
@@ -445,4 +1051,15 @@ function toggleFreeze() {
445
1051
  margin-bottom: 3px;
446
1052
  color: var(--text2);
447
1053
  }
1054
+
1055
+ @media (max-width: 1180px) {
1056
+ .inspector {
1057
+ grid-template-columns: minmax(200px, 240px) minmax(0, 1fr);
1058
+ }
1059
+
1060
+ .detail-panel {
1061
+ grid-column: 1 / -1;
1062
+ max-height: 220px;
1063
+ }
1064
+ }
448
1065
  </style>