nuxt-devtools-observatory 0.1.32 → 0.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +37 -1
  2. package/client/.env.example +2 -0
  3. package/client/dist/assets/index-BO7neKEi.css +1 -0
  4. package/client/dist/assets/index-fFBuk6M6.js +20 -0
  5. package/client/dist/index.html +2 -2
  6. package/client/src/App.vue +8 -0
  7. package/client/src/components/Flamegraph.vue +4 -4
  8. package/client/src/components/SpanInspector.vue +1 -1
  9. package/client/src/composables/composable-search.ts +3 -0
  10. package/client/src/composables/trace-render-aggregation.ts +11 -2
  11. package/client/src/composables/useVirtualizationConfig.ts +40 -0
  12. package/client/src/composables/useVirtualizationFlags.ts +129 -0
  13. package/client/src/stores/observatory.ts +20 -0
  14. package/client/src/views/ComposableTracker.vue +212 -71
  15. package/client/src/views/FetchDashboard.vue +181 -16
  16. package/client/src/views/PiniaStoreTracker.vue +343 -0
  17. package/client/src/views/ProvideInjectGraph.vue +66 -18
  18. package/client/src/views/RenderHeatmap.vue +329 -75
  19. package/client/src/views/TraceViewer.vue +190 -20
  20. package/client/src/views/TransitionTimeline.vue +112 -19
  21. package/dist/module.d.mts +15 -0
  22. package/dist/module.json +1 -1
  23. package/dist/module.mjs +28 -24
  24. package/dist/runtime/composables/pinia-store-registry.d.ts +44 -0
  25. package/dist/runtime/composables/pinia-store-registry.js +447 -0
  26. package/dist/runtime/composables/provide-inject-registry.js +13 -8
  27. package/dist/runtime/composables/render-registry.js +6 -4
  28. package/dist/runtime/instrumentation/asyncData.d.ts +1 -1
  29. package/dist/runtime/instrumentation/fetch.d.ts +7 -1
  30. package/dist/runtime/instrumentation/fetch.js +22 -1
  31. package/dist/runtime/plugin.js +39 -2
  32. package/dist/runtime/test-bridge.d.ts +18 -0
  33. package/dist/runtime/test-bridge.js +100 -0
  34. package/package.json +14 -3
  35. package/client/dist/assets/index-5Wl1XYRH.js +0 -17
  36. package/client/dist/assets/index-DT_QUiIh.css +0 -1
@@ -1,18 +1,24 @@
1
1
  <script setup lang="ts">
2
- import { ref, computed } from 'vue'
2
+ import { ref, computed, watch } from 'vue'
3
+ import { useVirtualizer } from '@tanstack/vue-virtual'
4
+ import { useVirtualizationConfig } from '@observatory-client/composables/useVirtualizationConfig'
3
5
  import { useResizablePane } from '@observatory-client/composables/useResizablePane'
4
6
  import { useObservatoryData } from '@observatory-client/stores/observatory'
5
7
  import type { FetchEntry } from '@observatory/types/snapshot'
6
8
 
7
9
  type FetchViewEntry = FetchEntry & { startOffset: number }
8
10
 
9
- const { fetch, connected } = useObservatoryData()
11
+ const { fetch, connected, features } = useObservatoryData()
10
12
  const { paneWidth: detailWidth, onHandleMouseDown } = useResizablePane(280, 'observatory:fetch:detailWidth')
11
13
 
12
14
  const filter = ref<string>('all')
13
15
  const search = ref('')
14
16
  const selectedId = ref<string | null>(null)
15
17
  const waterfallOpen = ref(true)
18
+ const tableScrollRef = ref<HTMLElement | null>(null)
19
+ const currentPage = ref(1)
20
+
21
+ const { preset: virtualizationPreset } = useVirtualizationConfig({ rowHeight: 38, overscan: 6 })
16
22
 
17
23
  const entries = computed<FetchViewEntry[]>(() => {
18
24
  const sorted = [...fetch.value].sort((a, b) => a.startTime - b.startTime)
@@ -24,7 +30,15 @@ const entries = computed<FetchViewEntry[]>(() => {
24
30
  }))
25
31
  })
26
32
 
27
- const selected = computed(() => entries.value.find((entry) => entry.id === selectedId.value) ?? null)
33
+ const entriesById = computed(() => new Map(entries.value.map((entry) => [entry.id, entry] as const)))
34
+
35
+ const selected = computed(() => {
36
+ if (!selectedId.value) {
37
+ return null
38
+ }
39
+
40
+ return entriesById.value.get(selectedId.value) ?? null
41
+ })
28
42
 
29
43
  const counts = computed(() => ({
30
44
  ok: entries.value.filter((entry) => entry.status === 'ok' || entry.status === 'cached').length,
@@ -48,6 +62,94 @@ const filtered = computed(() => {
48
62
  })
49
63
  })
50
64
 
65
+ const fetchPageSize = computed(() => {
66
+ const raw = features.value?.fetchPageSize
67
+
68
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) {
69
+ return 20
70
+ }
71
+
72
+ return Math.max(1, Math.floor(raw))
73
+ })
74
+
75
+ const pagedFiltered = computed(() => filtered.value.slice(0, currentPage.value * fetchPageSize.value))
76
+ const hasMoreRows = computed(() => pagedFiltered.value.length < filtered.value.length)
77
+
78
+ const virtualizedRowsEnabled = computed(() => true)
79
+
80
+ const rowVirtualizerOptions = computed(() => ({
81
+ count: pagedFiltered.value.length,
82
+ getScrollElement: () => tableScrollRef.value,
83
+ estimateSize: () => virtualizationPreset.value.rowHeight,
84
+ overscan: virtualizationPreset.value.overscan,
85
+ }))
86
+
87
+ const rowVirtualizer = useVirtualizer(rowVirtualizerOptions)
88
+
89
+ const virtualItems = computed(() => {
90
+ if (!virtualizedRowsEnabled.value) {
91
+ return []
92
+ }
93
+
94
+ return rowVirtualizer.value.getVirtualItems()
95
+ })
96
+
97
+ const topVirtualPadding = computed(() => {
98
+ if (!virtualizedRowsEnabled.value || virtualItems.value.length === 0) {
99
+ return 0
100
+ }
101
+
102
+ return virtualItems.value[0].start
103
+ })
104
+
105
+ const bottomVirtualPadding = computed(() => {
106
+ if (!virtualizedRowsEnabled.value || virtualItems.value.length === 0) {
107
+ return 0
108
+ }
109
+
110
+ const total = rowVirtualizer.value.getTotalSize()
111
+ const last = virtualItems.value[virtualItems.value.length - 1]
112
+
113
+ return Math.max(0, total - last.end)
114
+ })
115
+
116
+ const visibleRows = computed(() => {
117
+ if (!virtualizedRowsEnabled.value) {
118
+ return pagedFiltered.value
119
+ }
120
+
121
+ return virtualItems.value.map((item) => pagedFiltered.value[item.index]).filter((entry): entry is FetchViewEntry => Boolean(entry))
122
+ })
123
+
124
+ watch([filter, search], () => {
125
+ currentPage.value = 1
126
+ if (tableScrollRef.value) {
127
+ tableScrollRef.value.scrollTop = 0
128
+ }
129
+ })
130
+
131
+ watch(filtered, (nextRows) => {
132
+ const maxPage = Math.max(1, Math.ceil(nextRows.length / fetchPageSize.value))
133
+
134
+ if (currentPage.value > maxPage) {
135
+ currentPage.value = maxPage
136
+ }
137
+ })
138
+
139
+ function onTableScroll() {
140
+ const element = tableScrollRef.value
141
+
142
+ if (!element || !hasMoreRows.value) {
143
+ return
144
+ }
145
+
146
+ const nearBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 32
147
+
148
+ if (nearBottom) {
149
+ currentPage.value += 1
150
+ }
151
+ }
152
+
51
153
  const metaRows = computed(() => {
52
154
  if (!selected.value) {
53
155
  return []
@@ -91,11 +193,22 @@ function barColor(status: string) {
91
193
  return { ok: 'var(--teal)', error: 'var(--red)', pending: 'var(--amber)', cached: 'var(--border)' }[status] ?? 'var(--border)'
92
194
  }
93
195
 
196
+ const maxCompletedMs = computed(() => {
197
+ let maxMs = 1
198
+
199
+ for (const entry of entries.value) {
200
+ if (entry.ms != null && entry.ms > maxMs) {
201
+ maxMs = entry.ms
202
+ }
203
+ }
204
+
205
+ return maxMs
206
+ })
207
+
94
208
  function barWidth(entry: FetchViewEntry) {
95
209
  // Only consider completed entries for the max, so pending entries don't
96
210
  // collapse all bars to a dot while waiting.
97
- const completedMs = entries.value.filter((e) => e.ms != null).map((e) => e.ms!)
98
- const maxMs = completedMs.length > 0 ? Math.max(...completedMs, 1) : 1
211
+ const maxMs = maxCompletedMs.value
99
212
 
100
213
  return entry.ms != null ? Math.max(4, Math.round((entry.ms / maxMs) * 100)) : 4
101
214
  }
@@ -103,15 +216,26 @@ function barWidth(entry: FetchViewEntry) {
103
216
  // Waterfall uses absolute time offsets from the earliest startTime.
104
217
  // maxEnd is computed only from completed entries so that a long-running
105
218
  // pending request doesn't squash all completed bars to invisible.
106
- function waterfallScale() {
107
- const completed = entries.value.filter((e) => e.ms != null)
108
- const maxEnd = completed.length > 0 ? Math.max(...completed.map((e) => e.startOffset + e.ms!), 1) : 1
219
+ const waterfallScale = computed(() => {
220
+ let maxEnd = 1
221
+
222
+ for (const entry of entries.value) {
223
+ if (entry.ms == null) {
224
+ continue
225
+ }
226
+
227
+ const end = entry.startOffset + entry.ms
228
+
229
+ if (end > maxEnd) {
230
+ maxEnd = end
231
+ }
232
+ }
109
233
 
110
234
  return maxEnd
111
- }
235
+ })
112
236
 
113
237
  function wfLeft(entry: FetchViewEntry) {
114
- const scale = waterfallScale()
238
+ const scale = waterfallScale.value
115
239
  return Math.min(98, Math.round((entry.startOffset / scale) * 100))
116
240
  }
117
241
 
@@ -122,7 +246,7 @@ function wfWidth(entry: FetchViewEntry) {
122
246
  return 2
123
247
  }
124
248
 
125
- const scale = waterfallScale()
249
+ const scale = waterfallScale.value
126
250
  const left = wfLeft(entry)
127
251
 
128
252
  // Clamp so bar + left never exceeds 100%
@@ -173,7 +297,7 @@ function formatSize(bytes: number) {
173
297
  </div>
174
298
 
175
299
  <div class="fetch-dashboard__split tracker-split">
176
- <div class="fetch-dashboard__table tracker-table-wrap">
300
+ <div ref="tableScrollRef" class="fetch-dashboard__table tracker-table-wrap" @scroll="onTableScroll">
177
301
  <table class="data-table">
178
302
  <thead>
179
303
  <tr>
@@ -188,7 +312,14 @@ function formatSize(bytes: number) {
188
312
  </thead>
189
313
  <tbody>
190
314
  <tr
191
- v-for="entry in filtered"
315
+ v-if="virtualizedRowsEnabled && topVirtualPadding > 0"
316
+ class="fetch-dashboard__virtual-spacer-row"
317
+ aria-hidden="true"
318
+ >
319
+ <td colspan="7" :style="{ height: `${topVirtualPadding}px` }"></td>
320
+ </tr>
321
+ <tr
322
+ v-for="entry in visibleRows"
192
323
  :key="entry.id"
193
324
  :class="{ selected: selected?.id === entry.id }"
194
325
  @click="selectedId = entry.id"
@@ -221,16 +352,29 @@ function formatSize(bytes: number) {
221
352
  </div>
222
353
  </td>
223
354
  </tr>
355
+ <tr
356
+ v-if="virtualizedRowsEnabled && bottomVirtualPadding > 0"
357
+ class="fetch-dashboard__virtual-spacer-row"
358
+ aria-hidden="true"
359
+ >
360
+ <td colspan="7" :style="{ height: `${bottomVirtualPadding}px` }"></td>
361
+ </tr>
224
362
  <tr v-if="!filtered.length">
225
363
  <td colspan="7" class="tracker-empty-cell">
226
364
  {{ connected ? 'No fetches recorded yet.' : 'Waiting for connection to the Nuxt app…' }}
227
365
  </td>
228
366
  </tr>
367
+ <tr v-else class="fetch-dashboard__pagination-row" aria-live="polite">
368
+ <td colspan="7" class="fetch-dashboard__pagination-cell">
369
+ <span class="mono muted text-sm">showing {{ pagedFiltered.length }} of {{ filtered.length }}</span>
370
+ <span v-if="hasMoreRows" class="mono muted text-sm">scroll to load more</span>
371
+ </td>
372
+ </tr>
229
373
  </tbody>
230
374
  </table>
231
375
  </div>
232
376
 
233
- <div v-if="selected" class="tracker-resize-handle" @mousedown="onHandleMouseDown" />
377
+ <div class="tracker-resize-handle" @mousedown="onHandleMouseDown" />
234
378
 
235
379
  <div v-if="selected" class="fetch-dashboard__detail tracker-detail-panel" :style="{ width: detailWidth + 'px' }">
236
380
  <div class="fetch-dashboard__detail-header">
@@ -253,7 +397,7 @@ function formatSize(bytes: number) {
253
397
  <div class="tracker-section-label fetch-dashboard__section-label fetch-dashboard__section-label--source">source</div>
254
398
  <div class="mono text-sm muted">{{ selected.file }}:{{ selected.line }}</div>
255
399
  </div>
256
- <div v-else class="tracker-detail-empty">select a call to inspect</div>
400
+ <div v-else class="tracker-detail-empty" :style="{ width: detailWidth + 'px' }">select a call to inspect</div>
257
401
  </div>
258
402
 
259
403
  <div class="fetch-dashboard__waterfall">
@@ -297,6 +441,25 @@ function formatSize(bytes: number) {
297
441
  min-width: 80px;
298
442
  }
299
443
 
444
+ .fetch-dashboard__virtual-spacer-row td {
445
+ padding: 0;
446
+ border-bottom: 0;
447
+ background: transparent;
448
+ }
449
+
450
+ .fetch-dashboard__pagination-row td {
451
+ background: transparent;
452
+ }
453
+
454
+ .fetch-dashboard__pagination-cell {
455
+ display: flex;
456
+ align-items: center;
457
+ justify-content: space-between;
458
+ gap: var(--tracker-space-2);
459
+ padding-top: var(--tracker-space-2);
460
+ border-bottom: 0;
461
+ }
462
+
300
463
  .fetch-dashboard__url {
301
464
  max-width: 200px;
302
465
  }
@@ -340,7 +503,7 @@ function formatSize(bytes: number) {
340
503
  padding: var(--tracker-space-2) 10px;
341
504
  overflow: auto;
342
505
  white-space: pre;
343
- max-height: 160px;
506
+ min-height: fit-content;
344
507
  }
345
508
 
346
509
  .fetch-dashboard__waterfall {
@@ -349,6 +512,8 @@ function formatSize(bytes: number) {
349
512
  border: var(--tracker-border-width) solid var(--border);
350
513
  border-radius: var(--radius-lg);
351
514
  padding: 10px var(--tracker-space-3);
515
+ max-height: 35%;
516
+ overflow-x: auto;
352
517
  }
353
518
 
354
519
  .fetch-dashboard__waterfall-header {
@@ -0,0 +1,343 @@
1
+ <script setup lang="ts">
2
+ // No store is accessed or created here, so Pinia Tracker will show 'no store available' by default.
3
+ import { computed, ref } from 'vue'
4
+ import { useObservatoryData, clearPiniaStores /* editPiniaState */ } from '@observatory-client/stores/observatory'
5
+ import type { PiniaMutationEvent, PiniaStoreEntry } from '@observatory/types/snapshot'
6
+
7
+ const { piniaStores, connected } = useObservatoryData()
8
+
9
+ const selectedStoreId = ref<string | null>(null)
10
+ const selectedEventId = ref<string | null>(null)
11
+ // Disabled until we can get it working properly. See comments in the template and applyEdit function for details.
12
+ // const editPath = ref('')
13
+ // const editValue = ref('')
14
+ const editError = ref('')
15
+
16
+ const stores = computed(() => [...piniaStores.value].sort((a, b) => a.id.localeCompare(b.id)))
17
+
18
+ const selectedStore = computed<PiniaStoreEntry | null>(() => {
19
+ if (!selectedStoreId.value) {
20
+ return stores.value[0] ?? null
21
+ }
22
+
23
+ return stores.value.find((item) => item.id === selectedStoreId.value) ?? null
24
+ })
25
+
26
+ const timeline = computed<PiniaMutationEvent[]>(() => {
27
+ return selectedStore.value ? [...selectedStore.value.timeline].slice().reverse() : []
28
+ })
29
+
30
+ const selectedEvent = computed<PiniaMutationEvent | null>(() => {
31
+ if (!selectedEventId.value) {
32
+ return timeline.value[0] ?? null
33
+ }
34
+
35
+ return timeline.value.find((event) => event.id === selectedEventId.value) ?? null
36
+ })
37
+
38
+ function selectStore(id: string) {
39
+ selectedStoreId.value = id
40
+ selectedEventId.value = null
41
+ editError.value = ''
42
+ }
43
+
44
+ function pretty(value: unknown) {
45
+ try {
46
+ return JSON.stringify(value, null, 2)
47
+ } catch {
48
+ return String(value)
49
+ }
50
+ }
51
+
52
+ function renderDuration(event: PiniaMutationEvent) {
53
+ if (typeof event.durationMs !== 'number') {
54
+ return '-'
55
+ }
56
+
57
+ return `${event.durationMs.toFixed(2)}ms`
58
+ }
59
+
60
+ /* Edit functionality is temporarily disabled due to issues with state updates not being reflected in the UI. This is likely related to how the editPiniaState function applies changes and how the store's reactivity system detects those changes. Further investigation is needed to determine the root cause and implement a proper fix.
61
+ function applyEdit() {
62
+ const store = selectedStore.value
63
+
64
+ if (!store) {
65
+ editError.value = 'Select a store first.'
66
+
67
+ return
68
+ }
69
+
70
+ if (!editPath.value.trim()) {
71
+ editError.value = 'Path is required (for example: preferences.theme).'
72
+
73
+ return
74
+ }
75
+
76
+ let nextValue: unknown = editValue.value
77
+
78
+ if (editValue.value.trim()) {
79
+ try {
80
+ nextValue = JSON.parse(editValue.value)
81
+ } catch {
82
+ nextValue = editValue.value
83
+ }
84
+ }
85
+
86
+ editError.value = ''
87
+ editPiniaState(store.id, editPath.value.trim(), nextValue)
88
+ } */
89
+ </script>
90
+
91
+ <template>
92
+ <section class="pinia-tracker">
93
+ <header class="toolbar">
94
+ <h2>Pinia State and Mutations</h2>
95
+ <div class="toolbar-actions">
96
+ <span v-if="connected" class="status connected">Connected</span>
97
+ <span v-else class="status">Waiting for app</span>
98
+ <button class="danger" @click="clearPiniaStores">Clear timeline</button>
99
+ </div>
100
+ </header>
101
+
102
+ <div v-if="stores.length === 0" class="empty">No Pinia stores detected yet. Trigger store usage in the app.</div>
103
+
104
+ <div v-else class="content-grid">
105
+ <aside class="panel stores">
106
+ <h3>Stores</h3>
107
+ <button
108
+ v-for="store in stores"
109
+ :key="store.id"
110
+ class="store-row"
111
+ :class="{ active: selectedStore?.id === store.id }"
112
+ @click="selectStore(store.id)"
113
+ >
114
+ <span>{{ store.name }}</span>
115
+ <small>{{ store.timeline.length }} events</small>
116
+ </button>
117
+ </aside>
118
+
119
+ <section class="panel timeline">
120
+ <h3>Timeline</h3>
121
+ <div class="list">
122
+ <button
123
+ v-for="event in timeline"
124
+ :key="event.id"
125
+ class="event-row"
126
+ :class="{ active: selectedEvent?.id === event.id }"
127
+ @click="selectedEventId = event.id"
128
+ >
129
+ <strong>{{ event.kind }}</strong>
130
+ <span>{{ event.name }}</span>
131
+ <small>{{ renderDuration(event) }}</small>
132
+ </button>
133
+ </div>
134
+ </section>
135
+
136
+ <section class="panel inspector">
137
+ <h3>Inspector</h3>
138
+
139
+ <div v-if="selectedStore" class="block">
140
+ <h4>Current state</h4>
141
+ <pre>{{ pretty(selectedStore.state) }}</pre>
142
+ </div>
143
+
144
+ <!-- Edit functionality is temporarily disabled due to issues with state updates not being reflected in the UI. This is likely related to how the editPiniaState function applies changes and how the store's reactivity system detects those changes. Further investigation is needed to determine the root cause and implement a proper fix.
145
+ <div v-if="selectedStore" class="block edit-box">
146
+ <h4>Edit state path</h4>
147
+ <input v-model="editPath" placeholder="preferences.theme" />
148
+ <textarea v-model="editValue" rows="4" placeholder='"dark" or {"enabled":true}' />
149
+ <div class="actions">
150
+ <button @click="applyEdit">Apply edit</button>
151
+ <small v-if="editError" class="error">{{ editError }}</small>
152
+ </div>
153
+ </div>
154
+ -->
155
+
156
+ <div v-if="selectedEvent" class="block">
157
+ <h4>Selected event</h4>
158
+ <p class="meta">
159
+ {{ selectedEvent.kind }} · {{ selectedEvent.name }} · {{ selectedEvent.status }} ·
160
+ {{ renderDuration(selectedEvent) }}
161
+ </p>
162
+ <h5>Diff</h5>
163
+ <ul class="diff-list">
164
+ <li v-for="item in selectedEvent.diff" :key="item.path">
165
+ <code>{{ item.path }}</code>
166
+ </li>
167
+ </ul>
168
+ <h5>Before</h5>
169
+ <pre>{{ pretty(selectedEvent.beforeState) }}</pre>
170
+ <h5>After</h5>
171
+ <pre>{{ pretty(selectedEvent.afterState) }}</pre>
172
+ </div>
173
+ </section>
174
+ </div>
175
+ </section>
176
+ </template>
177
+
178
+ <style scoped>
179
+ .pinia-tracker {
180
+ display: flex;
181
+ flex-direction: column;
182
+ height: 100%;
183
+ gap: 12px;
184
+ padding: 12px;
185
+ }
186
+
187
+ .toolbar {
188
+ display: flex;
189
+ justify-content: space-between;
190
+ align-items: center;
191
+ }
192
+
193
+ .toolbar h2 {
194
+ margin: 0;
195
+ font-size: 14px;
196
+ }
197
+
198
+ .toolbar-actions {
199
+ display: flex;
200
+ align-items: center;
201
+ gap: 8px;
202
+ }
203
+
204
+ .status {
205
+ color: var(--text3);
206
+ font-size: 12px;
207
+ }
208
+
209
+ .status.connected {
210
+ color: var(--green);
211
+ }
212
+
213
+ .content-grid {
214
+ display: grid;
215
+ grid-template-columns: 220px 280px 1fr;
216
+ gap: 10px;
217
+ min-height: 0;
218
+ flex: 1;
219
+ }
220
+
221
+ .panel {
222
+ border: 1px solid var(--border);
223
+ border-radius: 8px;
224
+ background: var(--bg2);
225
+ padding: 10px;
226
+ min-height: 0;
227
+ display: flex;
228
+ flex-direction: column;
229
+ gap: 8px;
230
+ }
231
+
232
+ .panel h3 {
233
+ margin: 0;
234
+ font-size: 12px;
235
+ color: var(--text2);
236
+ letter-spacing: 0.02em;
237
+ text-transform: uppercase;
238
+ }
239
+
240
+ .stores,
241
+ .timeline,
242
+ .inspector,
243
+ .list {
244
+ overflow: auto;
245
+ }
246
+
247
+ .store-row,
248
+ .event-row {
249
+ width: 100%;
250
+ border: 1px solid var(--border);
251
+ border-radius: 6px;
252
+ background: var(--bg3);
253
+ color: var(--text);
254
+ text-align: left;
255
+ padding: 8px;
256
+ display: flex;
257
+ flex-direction: column;
258
+ gap: 2px;
259
+ cursor: pointer;
260
+ }
261
+
262
+ .store-row.active,
263
+ .event-row.active {
264
+ border-color: var(--purple);
265
+ }
266
+
267
+ .store-row small,
268
+ .event-row small {
269
+ color: var(--text3);
270
+ }
271
+
272
+ .block {
273
+ border-top: 1px solid var(--border);
274
+ padding-top: 8px;
275
+ }
276
+
277
+ .block h4,
278
+ .block h5 {
279
+ margin: 0 0 6px;
280
+ font-size: 12px;
281
+ }
282
+
283
+ .meta {
284
+ margin: 0 0 6px;
285
+ color: var(--text2);
286
+ font-size: 12px;
287
+ }
288
+
289
+ pre {
290
+ margin: 0;
291
+ background: var(--bg3);
292
+ border: 1px solid var(--border);
293
+ border-radius: 6px;
294
+ padding: 8px;
295
+ white-space: pre-wrap;
296
+ word-break: break-word;
297
+ font-size: 11px;
298
+ max-height: 200px;
299
+ overflow: auto;
300
+ }
301
+
302
+ .edit-box input,
303
+ .edit-box textarea {
304
+ width: 100%;
305
+ background: var(--bg3);
306
+ border: 1px solid var(--border);
307
+ border-radius: 6px;
308
+ color: var(--text);
309
+ padding: 6px;
310
+ font-size: 12px;
311
+ }
312
+
313
+ .actions {
314
+ display: flex;
315
+ align-items: center;
316
+ gap: 8px;
317
+ }
318
+
319
+ .error {
320
+ color: var(--red);
321
+ }
322
+
323
+ .empty {
324
+ padding: 24px;
325
+ border: 1px dashed var(--border);
326
+ border-radius: 8px;
327
+ color: var(--text2);
328
+ }
329
+
330
+ .diff-list {
331
+ margin: 0;
332
+ padding-left: 16px;
333
+ }
334
+
335
+ .danger {
336
+ border: 1px solid var(--border);
337
+ border-radius: 6px;
338
+ background: var(--bg2);
339
+ color: var(--text);
340
+ padding: 6px 10px;
341
+ cursor: pointer;
342
+ }
343
+ </style>