nuxt-devtools-observatory 0.1.31 → 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,14 +6,33 @@ import WaterfallView from '@observatory-client/components/WaterfallView.vue'
6
6
  import SpanInspector from '@observatory-client/components/SpanInspector.vue'
7
7
  import TraceFilter from '@observatory-client/components/TraceFilter.vue'
8
8
  import { useTraceFilter } from '@observatory-client/composables/useTraceFilter'
9
+ import { exportJson, importJson } from '@observatory-client/composables/useExportImport'
10
+ import {
11
+ buildRenderSummaryForTrace,
12
+ buildCrossTraceRenderSummary,
13
+ type TraceRenderStatsRow,
14
+ } from '@observatory-client/composables/trace-render-aggregation'
15
+ import type { ObservatoryExportFile } from '@observatory-client/composables/useExportImport'
9
16
  import type { TraceEntry, TraceSpan } from '@observatory/types/snapshot'
10
17
 
11
18
  const { traces, connected } = useObservatoryData()
12
19
 
20
+ const importedTraces = ref<TraceEntry[]>([])
21
+ const isImportMode = computed(() => importedTraces.value.length > 0)
22
+
13
23
  const selectedTraceId = ref<string | null>(null)
14
24
  const selectedSpan = ref<TraceSpan | undefined>(undefined)
15
25
  const viewMode = ref<'overview' | 'flamegraph' | 'waterfall'>('overview')
16
26
  const showFilters = ref(false)
27
+ const renderSummaryOpen = ref(true)
28
+ const crossTraceSummaryOpen = ref(true)
29
+ const crossTraceSortBy = ref<'avgRerendersPerTrace' | 'deltaVsBaseline' | 'totalMs' | 'componentName'>('avgRerendersPerTrace')
30
+ const crossTraceSortDir = ref<'asc' | 'desc'>('desc')
31
+ const crossTraceOnlyRegressions = ref(false)
32
+ const crossTraceOnlyComparable = ref(false)
33
+ const crossTraceSearch = ref('')
34
+ const highlightedUid = ref<string | number | undefined>(undefined)
35
+ const highlightedComponentKey = ref<string | undefined>(undefined)
17
36
 
18
37
  const {
19
38
  searchQuery,
@@ -27,7 +46,8 @@ const {
27
46
  } = useTraceFilter()
28
47
 
29
48
  const sortedTraces = computed(() => {
30
- return [...traces.value].sort((a, b) => b.startTime - a.startTime)
49
+ const source = isImportMode.value ? importedTraces.value : traces.value
50
+ return [...source].sort((a, b) => b.startTime - a.startTime)
31
51
  })
32
52
 
33
53
  const filteredTraces = computed(() => {
@@ -35,11 +55,13 @@ const filteredTraces = computed(() => {
35
55
  })
36
56
 
37
57
  const traceCountLabel = computed(() => {
58
+ const suffix = isImportMode.value ? ' (imported)' : ''
59
+
38
60
  if (!hasActiveFilters.value) {
39
- return `${sortedTraces.value.length} traces`
61
+ return `${sortedTraces.value.length} traces${suffix}`
40
62
  }
41
63
 
42
- return `Showing ${filteredTraces.value.length} of ${sortedTraces.value.length} traces`
64
+ return `Showing ${filteredTraces.value.length} of ${sortedTraces.value.length} traces${suffix}`
43
65
  })
44
66
 
45
67
  const selectedTrace = computed(() => {
@@ -50,6 +72,59 @@ const selectedTrace = computed(() => {
50
72
  return filteredTraces.value.find((t) => t.id === selectedTraceId.value)
51
73
  })
52
74
 
75
+ const renderSummary = computed<TraceRenderStatsRow[]>(() => buildRenderSummaryForTrace(selectedTrace.value))
76
+
77
+ const crossTraceRenderSummary = computed(() => {
78
+ return buildCrossTraceRenderSummary(filteredTraces.value, selectedTrace.value?.id)
79
+ })
80
+
81
+ const crossTraceRows = computed(() => {
82
+ const q = crossTraceSearch.value.trim().toLowerCase()
83
+
84
+ let rows = crossTraceRenderSummary.value.filter((row) => {
85
+ if (crossTraceOnlyRegressions.value && (row.deltaVsBaseline ?? 0) <= 0) {
86
+ return false
87
+ }
88
+
89
+ if (crossTraceOnlyComparable.value && row.selectedRerenders === undefined) {
90
+ return false
91
+ }
92
+
93
+ if (q) {
94
+ const nameMatch = row.componentName.toLowerCase().includes(q)
95
+ const fileMatch = row.file.toLowerCase().includes(q)
96
+
97
+ if (!nameMatch && !fileMatch) {
98
+ return false
99
+ }
100
+ }
101
+
102
+ return true
103
+ })
104
+
105
+ rows = [...rows].sort((a, b) => {
106
+ const key = crossTraceSortBy.value
107
+ const dir = crossTraceSortDir.value === 'asc' ? 1 : -1
108
+
109
+ if (key === 'componentName') {
110
+ return a.componentName.localeCompare(b.componentName) * dir
111
+ }
112
+
113
+ if (key === 'deltaVsBaseline') {
114
+ const av = a.deltaVsBaseline ?? Number.NEGATIVE_INFINITY
115
+ const bv = b.deltaVsBaseline ?? Number.NEGATIVE_INFINITY
116
+
117
+ return (av - bv) * dir
118
+ }
119
+
120
+ return ((a[key] as number) - (b[key] as number)) * dir
121
+ })
122
+
123
+ return rows
124
+ })
125
+
126
+ const crossTraceRegressionsCount = computed(() => crossTraceRenderSummary.value.filter((row) => (row.deltaVsBaseline ?? 0) > 0).length)
127
+
53
128
  function elapsedFromSpans(trace: TraceEntry): number | undefined {
54
129
  if (!trace.spans.length) {
55
130
  return undefined
@@ -129,16 +204,152 @@ function getSpanDisplayName(span: TraceSpan): string {
129
204
  function selectTrace(trace: TraceEntry) {
130
205
  selectedTraceId.value = trace.id
131
206
  selectedSpan.value = undefined
207
+ highlightedUid.value = undefined
208
+ highlightedComponentKey.value = undefined
132
209
  }
133
210
 
134
211
  function handleClearFilters() {
135
212
  clearFilters()
136
213
  selectedSpan.value = undefined
214
+ highlightedUid.value = undefined
215
+ highlightedComponentKey.value = undefined
137
216
  }
138
217
 
139
218
  function handleSpanTypesUpdate(value: Set<string>) {
140
219
  selectedSpanTypes.value = value
141
220
  }
221
+
222
+ function handleExport() {
223
+ exportJson(`observatory-traces-${Date.now()}.json`, {
224
+ type: 'observatory-traces',
225
+ version: '1',
226
+ exportedAt: Date.now(),
227
+ count: traces.value.length,
228
+ data: traces.value,
229
+ })
230
+ }
231
+
232
+ async function handleImport() {
233
+ let parsed: unknown
234
+
235
+ try {
236
+ parsed = await importJson()
237
+ } catch (err) {
238
+ if (err instanceof Error && err.message !== 'cancelled') {
239
+ alert(`Import failed: ${err.message}`)
240
+ }
241
+ return
242
+ }
243
+
244
+ const file = parsed as ObservatoryExportFile<TraceEntry>
245
+
246
+ if (
247
+ file?.type !== 'observatory-traces' ||
248
+ file?.version !== '1' ||
249
+ !Array.isArray(file?.data) ||
250
+ (file.data.length > 0 && (!file.data[0]?.id || !file.data[0]?.name || !Array.isArray(file.data[0]?.spans)))
251
+ ) {
252
+ alert('Invalid observatory traces file.')
253
+ return
254
+ }
255
+
256
+ importedTraces.value = file.data
257
+ selectedTraceId.value = null
258
+ selectedSpan.value = undefined
259
+ }
260
+
261
+ function handleBackToLive() {
262
+ importedTraces.value = []
263
+ selectedTraceId.value = null
264
+ selectedSpan.value = undefined
265
+ highlightedUid.value = undefined
266
+ highlightedComponentKey.value = undefined
267
+ }
268
+
269
+ function handleRenderSummaryRowClick(uid: string | number) {
270
+ // Toggle highlight: clicking the already-highlighted row deselects it.
271
+ highlightedUid.value = highlightedUid.value === uid ? undefined : uid
272
+ highlightedComponentKey.value = undefined
273
+ selectedSpan.value = undefined
274
+ }
275
+
276
+ function formatDelta(value?: number): string {
277
+ if (value === undefined) {
278
+ return 'n/a'
279
+ }
280
+
281
+ const rounded = Math.round(value * 10) / 10
282
+
283
+ if (rounded > 0) {
284
+ return `+${rounded}`
285
+ }
286
+
287
+ return `${rounded}`
288
+ }
289
+
290
+ function deltaToneClass(value?: number): string {
291
+ if (value === undefined) {
292
+ return 'trace-viewer__delta--na'
293
+ }
294
+
295
+ if (value > 0) {
296
+ return 'trace-viewer__delta--regression'
297
+ }
298
+
299
+ if (value < 0) {
300
+ return 'trace-viewer__delta--improvement'
301
+ }
302
+
303
+ return 'trace-viewer__delta--neutral'
304
+ }
305
+
306
+ function cycleCrossTraceSort(next: 'avgRerendersPerTrace' | 'deltaVsBaseline' | 'totalMs' | 'componentName') {
307
+ if (crossTraceSortBy.value === next) {
308
+ crossTraceSortDir.value = crossTraceSortDir.value === 'desc' ? 'asc' : 'desc'
309
+
310
+ return
311
+ }
312
+
313
+ crossTraceSortBy.value = next
314
+ crossTraceSortDir.value = next === 'componentName' ? 'asc' : 'desc'
315
+ }
316
+
317
+ function sortIndicator(key: 'avgRerendersPerTrace' | 'deltaVsBaseline' | 'totalMs' | 'componentName') {
318
+ if (crossTraceSortBy.value !== key) {
319
+ return ''
320
+ }
321
+
322
+ return crossTraceSortDir.value === 'desc' ? ' ↓' : ' ↑'
323
+ }
324
+
325
+ function clearCrossTraceHighlight() {
326
+ highlightedComponentKey.value = undefined
327
+ highlightedUid.value = undefined
328
+ }
329
+
330
+ function handleCrossTraceRowClick(componentKey: string) {
331
+ const row = crossTraceRows.value.find((item) => item.componentKey === componentKey)
332
+
333
+ if (!row || row.selectedUid === undefined) {
334
+ highlightedUid.value = undefined
335
+ highlightedComponentKey.value = undefined
336
+ selectedSpan.value = undefined
337
+
338
+ return
339
+ }
340
+
341
+ if (highlightedComponentKey.value === componentKey) {
342
+ highlightedComponentKey.value = undefined
343
+ highlightedUid.value = undefined
344
+ selectedSpan.value = undefined
345
+
346
+ return
347
+ }
348
+
349
+ highlightedComponentKey.value = componentKey
350
+ highlightedUid.value = row.selectedUid
351
+ selectedSpan.value = undefined
352
+ }
142
353
  </script>
143
354
 
144
355
  <template>
@@ -148,6 +359,18 @@ function handleSpanTypesUpdate(value: Set<string>) {
148
359
  <div class="trace-viewer__title">Trace Viewer</div>
149
360
  <div class="trace-viewer__header-actions">
150
361
  <div class="trace-viewer__count muted text-sm">{{ traceCountLabel }}</div>
362
+ <button v-if="isImportMode" class="trace-viewer__import-mode-btn" title="Return to live data" @click="handleBackToLive">
363
+ ← live
364
+ </button>
365
+ <button
366
+ class="trace-viewer__action-btn"
367
+ title="Export traces as JSON"
368
+ :disabled="traces.length === 0 && !isImportMode"
369
+ @click="handleExport"
370
+ >
371
+ ↓ export
372
+ </button>
373
+ <button class="trace-viewer__action-btn" title="Import traces from JSON file" @click="handleImport">↑ import</button>
151
374
  <button
152
375
  :class="{ 'trace-viewer__filter-btn--active': showFilters }"
153
376
  class="trace-viewer__filter-btn"
@@ -278,7 +501,12 @@ function handleSpanTypesUpdate(value: Set<string>) {
278
501
  <div
279
502
  v-for="span in selectedTrace.spans"
280
503
  :key="span.id"
281
- :class="{ 'trace-viewer__span-item--selected': selectedSpan?.id === span.id }"
504
+ :class="{
505
+ 'trace-viewer__span-item--selected': selectedSpan?.id === span.id,
506
+ 'trace-viewer__span-item--highlighted':
507
+ highlightedUid !== undefined &&
508
+ (span.metadata as Record<string, unknown> | undefined)?.uid === highlightedUid,
509
+ }"
282
510
  class="trace-viewer__span-item"
283
511
  @click="selectedSpan = span"
284
512
  >
@@ -289,6 +517,165 @@ function handleSpanTypesUpdate(value: Set<string>) {
289
517
  </div>
290
518
  </div>
291
519
  </div>
520
+
521
+ <!-- Render Summary panel — shown when the trace has render spans -->
522
+ <template v-if="renderSummary.length > 0">
523
+ <div class="trace-viewer__render-summary-header" @click="renderSummaryOpen = !renderSummaryOpen">
524
+ <span class="trace-viewer__render-summary-toggle">{{ renderSummaryOpen ? '▾' : '▸' }}</span>
525
+ <span class="trace-viewer__render-summary-title">Render Summary</span>
526
+ <span class="trace-viewer__render-summary-count muted">{{ renderSummary.length }} components</span>
527
+ <span
528
+ v-if="highlightedUid !== undefined"
529
+ class="trace-viewer__render-summary-clear"
530
+ @click.stop="highlightedUid = undefined"
531
+ >
532
+ ✕ clear
533
+ </span>
534
+ </div>
535
+ <div v-if="renderSummaryOpen" class="trace-viewer__render-summary-table-wrap">
536
+ <table class="data-table trace-viewer__render-summary-table">
537
+ <thead>
538
+ <tr>
539
+ <th>Component</th>
540
+ <th title="Times the component was mounted">Mounts</th>
541
+ <th title="Reactive re-renders after initial mount">Re-renders</th>
542
+ <th title="Average render duration">Avg</th>
543
+ <th title="Sum of all render durations">Total</th>
544
+ </tr>
545
+ </thead>
546
+ <tbody>
547
+ <tr
548
+ v-for="row in renderSummary"
549
+ :key="row.uid"
550
+ :class="{ 'trace-viewer__render-summary-row--active': highlightedUid === row.uid }"
551
+ class="trace-viewer__render-summary-row"
552
+ :title="row.file"
553
+ @click="handleRenderSummaryRowClick(row.uid)"
554
+ >
555
+ <td class="mono">{{ row.componentName }}</td>
556
+ <td class="mono">{{ row.mountCount }}</td>
557
+ <td class="mono" :class="{ 'trace-viewer__render-hot': row.rerenderCount > 3 }">
558
+ {{ row.rerenderCount }}
559
+ </td>
560
+ <td class="mono">{{ formatDuration(row.avgMs) }}</td>
561
+ <td class="mono">{{ formatDuration(row.totalMs) }}</td>
562
+ </tr>
563
+ </tbody>
564
+ </table>
565
+ </div>
566
+ </template>
567
+
568
+ <!-- Cross-trace comparison panel -->
569
+ <template v-if="crossTraceRenderSummary.length > 0">
570
+ <div class="trace-viewer__render-summary-header" @click="crossTraceSummaryOpen = !crossTraceSummaryOpen">
571
+ <span class="trace-viewer__render-summary-toggle">{{ crossTraceSummaryOpen ? '▾' : '▸' }}</span>
572
+ <span class="trace-viewer__render-summary-title">Cross-Trace Render Comparison</span>
573
+ <span class="trace-viewer__render-summary-count muted">
574
+ {{ crossTraceRenderSummary.length }} components · {{ filteredTraces.length }} traces
575
+ </span>
576
+ <span class="trace-viewer__render-summary-count muted">
577
+ {{ crossTraceRegressionsCount }} regressions
578
+ </span>
579
+ <span class="trace-viewer__mobile-hint muted">mobile mode: condensed columns</span>
580
+ <span
581
+ v-if="highlightedComponentKey !== undefined"
582
+ class="trace-viewer__render-summary-clear"
583
+ @click.stop="clearCrossTraceHighlight"
584
+ >
585
+ ✕ clear
586
+ </span>
587
+ </div>
588
+ <div v-if="crossTraceSummaryOpen" class="trace-viewer__render-summary-table-wrap">
589
+ <div class="trace-viewer__comparison-toolbar">
590
+ <button
591
+ :class="{ 'trace-viewer__comparison-chip--active': crossTraceOnlyRegressions }"
592
+ class="trace-viewer__comparison-chip"
593
+ @click="crossTraceOnlyRegressions = !crossTraceOnlyRegressions"
594
+ >
595
+ regressions only
596
+ </button>
597
+ <button
598
+ :class="{ 'trace-viewer__comparison-chip--active': crossTraceOnlyComparable }"
599
+ class="trace-viewer__comparison-chip"
600
+ @click="crossTraceOnlyComparable = !crossTraceOnlyComparable"
601
+ >
602
+ selected trace only
603
+ </button>
604
+ <span class="trace-viewer__comparison-spacer"></span>
605
+ <input
606
+ v-model="crossTraceSearch"
607
+ class="trace-viewer__comparison-search mono"
608
+ type="search"
609
+ placeholder="filter component..."
610
+ />
611
+ </div>
612
+ <table class="data-table trace-viewer__render-summary-table">
613
+ <thead>
614
+ <tr>
615
+ <th class="trace-viewer__sortable" @click="cycleCrossTraceSort('componentName')">
616
+ Component{{ sortIndicator('componentName') }}
617
+ </th>
618
+ <th class="trace-viewer__col-desktop" title="How many traces include this component">
619
+ Traces
620
+ </th>
621
+ <th
622
+ class="trace-viewer__sortable"
623
+ title="Average re-renders per trace"
624
+ @click="cycleCrossTraceSort('avgRerendersPerTrace')"
625
+ >
626
+ Avg Re-renders{{ sortIndicator('avgRerendersPerTrace') }}
627
+ </th>
628
+ <th class="trace-viewer__col-desktop" title="Re-renders in selected trace">Selected</th>
629
+ <th
630
+ class="trace-viewer__sortable"
631
+ title="Selected trace vs other traces baseline"
632
+ @click="cycleCrossTraceSort('deltaVsBaseline')"
633
+ >
634
+ Delta{{ sortIndicator('deltaVsBaseline') }}
635
+ </th>
636
+ <th class="trace-viewer__col-desktop" title="Average render duration across all renders">
637
+ Avg ms
638
+ </th>
639
+ <th
640
+ class="trace-viewer__sortable"
641
+ title="Sum of all render duration across filtered traces"
642
+ @click="cycleCrossTraceSort('totalMs')"
643
+ >
644
+ Total ms{{ sortIndicator('totalMs') }}
645
+ </th>
646
+ </tr>
647
+ </thead>
648
+ <tbody>
649
+ <tr
650
+ v-for="row in crossTraceRows"
651
+ :key="row.componentKey"
652
+ :class="{
653
+ 'trace-viewer__render-summary-row--active':
654
+ highlightedComponentKey === row.componentKey,
655
+ }"
656
+ class="trace-viewer__render-summary-row"
657
+ :title="row.file"
658
+ @click="handleCrossTraceRowClick(row.componentKey)"
659
+ >
660
+ <td class="mono">{{ row.componentName }}</td>
661
+ <td class="mono trace-viewer__col-desktop">{{ row.tracesSeen }}</td>
662
+ <td class="mono">{{ (Math.round(row.avgRerendersPerTrace * 10) / 10).toFixed(1) }}</td>
663
+ <td class="mono trace-viewer__col-desktop">{{ row.selectedRerenders ?? 'n/a' }}</td>
664
+ <td class="mono">
665
+ <span class="trace-viewer__delta" :class="deltaToneClass(row.deltaVsBaseline)">
666
+ {{ formatDelta(row.deltaVsBaseline) }}
667
+ </span>
668
+ </td>
669
+ <td class="mono trace-viewer__col-desktop">{{ formatDuration(row.avgMsPerRender) }}</td>
670
+ <td class="mono">{{ formatDuration(row.totalMs) }}</td>
671
+ </tr>
672
+ <tr v-if="!crossTraceRows.length">
673
+ <td colspan="7" class="tracker-empty-cell">No components match the comparison filters.</td>
674
+ </tr>
675
+ </tbody>
676
+ </table>
677
+ </div>
678
+ </template>
292
679
  </div>
293
680
 
294
681
  <!-- Flamegraph -->
@@ -365,6 +752,44 @@ function handleSpanTypesUpdate(value: Set<string>) {
365
752
  color: var(--accent);
366
753
  }
367
754
 
755
+ .trace-viewer__action-btn {
756
+ padding: 3px 8px;
757
+ background: none;
758
+ border: 1px solid var(--border);
759
+ color: var(--text-secondary);
760
+ cursor: pointer;
761
+ font-size: 11px;
762
+ border-radius: 3px;
763
+ transition: all 0.12s;
764
+ font-family: var(--mono);
765
+ }
766
+
767
+ .trace-viewer__action-btn:hover:not(:disabled) {
768
+ background: var(--bg-secondary);
769
+ color: var(--text);
770
+ }
771
+
772
+ .trace-viewer__action-btn:disabled {
773
+ opacity: 0.4;
774
+ cursor: not-allowed;
775
+ }
776
+
777
+ .trace-viewer__import-mode-btn {
778
+ padding: 3px 8px;
779
+ background: var(--accent-bg);
780
+ border: 1px solid var(--accent);
781
+ color: var(--accent);
782
+ cursor: pointer;
783
+ font-size: 11px;
784
+ border-radius: 3px;
785
+ transition: all 0.12s;
786
+ font-family: var(--mono);
787
+ }
788
+
789
+ .trace-viewer__import-mode-btn:hover {
790
+ opacity: 0.8;
791
+ }
792
+
368
793
  .trace-viewer__container {
369
794
  display: flex;
370
795
  flex: 1;
@@ -572,7 +997,176 @@ function handleSpanTypesUpdate(value: Set<string>) {
572
997
  overflow: auto;
573
998
  }
574
999
 
575
- @media (max-width: 1024px) {
1000
+ .trace-viewer__span-item--highlighted {
1001
+ background: color-mix(in srgb, var(--purple, #a855f7) 8%, transparent);
1002
+ border-left: 2px solid var(--purple, #a855f7);
1003
+ }
1004
+
1005
+ .trace-viewer__render-summary-header {
1006
+ display: flex;
1007
+ align-items: center;
1008
+ gap: 8px;
1009
+ padding: 8px 16px;
1010
+ border-top: 1px solid var(--border);
1011
+ border-bottom: 1px solid var(--border);
1012
+ background: var(--bg3, var(--bg2, var(--bg)));
1013
+ cursor: pointer;
1014
+ user-select: none;
1015
+ flex-shrink: 0;
1016
+ }
1017
+
1018
+ .trace-viewer__render-summary-header:hover {
1019
+ background: var(--bg2, var(--bg));
1020
+ }
1021
+
1022
+ .trace-viewer__render-summary-toggle {
1023
+ font-size: 10px;
1024
+ color: var(--text3, var(--text2, var(--text)));
1025
+ width: 12px;
1026
+ flex-shrink: 0;
1027
+ }
1028
+
1029
+ .trace-viewer__render-summary-title {
1030
+ font-size: 11px;
1031
+ font-weight: 600;
1032
+ color: var(--text2, var(--text));
1033
+ text-transform: uppercase;
1034
+ letter-spacing: 0.5px;
1035
+ }
1036
+
1037
+ .trace-viewer__render-summary-count {
1038
+ font-size: 11px;
1039
+ font-family: var(--mono);
1040
+ color: var(--text3, var(--text2, var(--text)));
1041
+ }
1042
+
1043
+ .trace-viewer__render-summary-clear {
1044
+ margin-left: auto;
1045
+ font-size: 11px;
1046
+ color: var(--accent);
1047
+ cursor: pointer;
1048
+ }
1049
+
1050
+ .trace-viewer__render-summary-clear:hover {
1051
+ text-decoration: underline;
1052
+ }
1053
+
1054
+ .trace-viewer__mobile-hint {
1055
+ display: none;
1056
+ margin-left: auto;
1057
+ font-size: 11px;
1058
+ }
1059
+
1060
+ .trace-viewer__render-summary-table-wrap {
1061
+ flex-shrink: 0;
1062
+ max-height: 200px;
1063
+ overflow: auto;
1064
+ border-bottom: 1px solid var(--border);
1065
+ }
1066
+
1067
+ .trace-viewer__comparison-toolbar {
1068
+ display: flex;
1069
+ align-items: center;
1070
+ gap: 8px;
1071
+ padding: 8px 12px;
1072
+ border-bottom: 1px solid var(--border);
1073
+ background: var(--bg2, var(--bg));
1074
+ }
1075
+
1076
+ .trace-viewer__comparison-chip {
1077
+ padding: 2px 8px;
1078
+ border: 1px solid var(--border);
1079
+ border-radius: 999px;
1080
+ background: transparent;
1081
+ color: var(--text2, var(--text));
1082
+ font-size: 11px;
1083
+ font-family: var(--mono);
1084
+ cursor: pointer;
1085
+ }
1086
+
1087
+ .trace-viewer__comparison-chip--active {
1088
+ border-color: color-mix(in srgb, var(--purple, #a855f7) 55%, var(--border));
1089
+ background: color-mix(in srgb, var(--purple, #a855f7) 15%, transparent);
1090
+ color: var(--purple, #a855f7);
1091
+ }
1092
+
1093
+ .trace-viewer__comparison-spacer {
1094
+ flex: 1;
1095
+ }
1096
+
1097
+ .trace-viewer__comparison-search {
1098
+ min-width: 180px;
1099
+ max-width: 240px;
1100
+ }
1101
+
1102
+ .trace-viewer__render-summary-table {
1103
+ width: 100%;
1104
+ }
1105
+
1106
+ .trace-viewer__sortable {
1107
+ cursor: pointer;
1108
+ user-select: none;
1109
+ }
1110
+
1111
+ .trace-viewer__sortable:hover {
1112
+ color: var(--text);
1113
+ }
1114
+
1115
+ .trace-viewer__render-summary-row {
1116
+ cursor: pointer;
1117
+ transition: background 0.15s;
1118
+ }
1119
+
1120
+ .trace-viewer__render-summary-row:hover {
1121
+ background: var(--bg2, var(--bg));
1122
+ }
1123
+
1124
+ .trace-viewer__render-summary-row--active {
1125
+ background: color-mix(in srgb, var(--purple, #a855f7) 8%, transparent);
1126
+ border-left: 2px solid var(--purple, #a855f7);
1127
+ }
1128
+
1129
+ .trace-viewer__render-hot {
1130
+ color: var(--orange, #f97316);
1131
+ font-weight: 600;
1132
+ }
1133
+
1134
+ .trace-viewer__delta {
1135
+ display: inline-flex;
1136
+ align-items: center;
1137
+ justify-content: center;
1138
+ min-width: 46px;
1139
+ padding: 2px 6px;
1140
+ border-radius: 999px;
1141
+ border: 1px solid transparent;
1142
+ font-weight: 600;
1143
+ }
1144
+
1145
+ .trace-viewer__delta--regression {
1146
+ color: var(--orange, #f97316);
1147
+ border-color: color-mix(in srgb, var(--orange, #f97316) 40%, var(--border));
1148
+ background: color-mix(in srgb, var(--orange, #f97316) 14%, transparent);
1149
+ }
1150
+
1151
+ .trace-viewer__delta--improvement {
1152
+ color: var(--green, #22c55e);
1153
+ border-color: color-mix(in srgb, var(--green, #22c55e) 45%, var(--border));
1154
+ background: color-mix(in srgb, var(--green, #22c55e) 14%, transparent);
1155
+ }
1156
+
1157
+ .trace-viewer__delta--neutral {
1158
+ color: var(--text2, var(--text));
1159
+ border-color: var(--border);
1160
+ background: var(--bg2, var(--bg));
1161
+ }
1162
+
1163
+ .trace-viewer__delta--na {
1164
+ color: var(--text3, var(--text2, var(--text)));
1165
+ border-color: var(--border);
1166
+ background: transparent;
1167
+ }
1168
+
1169
+ @media (width <= 1024px) {
576
1170
  .trace-viewer__container {
577
1171
  flex-direction: column;
578
1172
  gap: 0;
@@ -595,5 +1189,24 @@ function handleSpanTypesUpdate(value: Set<string>) {
595
1189
  border-left: none;
596
1190
  border-top: 1px solid var(--border);
597
1191
  }
1192
+
1193
+ .trace-viewer__comparison-toolbar {
1194
+ flex-wrap: wrap;
1195
+ }
1196
+
1197
+ .trace-viewer__comparison-search {
1198
+ min-width: 100%;
1199
+ max-width: 100%;
1200
+ }
1201
+ }
1202
+
1203
+ @media (width <= 768px) {
1204
+ .trace-viewer__col-desktop {
1205
+ display: none;
1206
+ }
1207
+
1208
+ .trace-viewer__mobile-hint {
1209
+ display: inline-flex;
1210
+ }
598
1211
  }
599
1212
  </style>