nuxt-devtools-observatory 0.1.11 → 0.1.13

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,74 +1,64 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, computed } from 'vue'
3
- import { useObservatoryData, type ComposableEntry as RuntimeComposableEntry } from '../stores/observatory'
3
+ import { useObservatoryData, getObservatoryOrigin, type ComposableEntry as RuntimeComposableEntry } from '../stores/observatory'
4
4
 
5
- interface ComposableViewEntry {
6
- id: string
7
- name: string
8
- component: string
9
- instances: number
10
- status: string
11
- leak: boolean
12
- leakReason?: string
13
- refs: Array<{ key: string; type: string; val: string }>
14
- watchers: number
15
- intervals: number
16
- lifecycle: {
17
- onMounted: boolean
18
- onUnmounted: boolean
19
- watchersCleaned: boolean
20
- intervalsCleaned: boolean
5
+ const { composables: rawEntries, connected, clearComposables } = useObservatoryData()
6
+
7
+ function clearSession() {
8
+ const origin = getObservatoryOrigin()
9
+
10
+ if (!origin) {
11
+ return
21
12
  }
13
+
14
+ clearComposables()
15
+ window.top?.postMessage({ type: 'observatory:clear-composables' }, origin)
22
16
  }
23
17
 
24
- const { composables: rawEntries, connected } = useObservatoryData()
18
+ // ── Flat per-instance display ─────────────────────────────────────────────
19
+ // Each entry is shown individually so you can see exactly which component
20
+ // instance uses which state. No grouping — instanceA and instanceB of the
21
+ // same composable in different components are two separate rows.
25
22
 
26
- const entries = computed<ComposableViewEntry[]>(() => {
27
- const groups = new Map<string, RuntimeComposableEntry[]>()
23
+ function formatVal(v: unknown): string {
24
+ if (v === null) {
25
+ return 'null'
26
+ }
28
27
 
29
- for (const entry of rawEntries.value) {
30
- const key = `${entry.name}::${entry.componentFile}`
31
- const list = groups.get(key) ?? []
32
- list.push(entry)
33
- groups.set(key, list)
28
+ if (v === undefined) {
29
+ return 'undefined'
34
30
  }
35
31
 
36
- return [...groups.entries()].map(([key, group]) => {
37
- const latest = [...group].sort((a, b) => b.line - a.line)[0]
38
- const leakReasons = [...new Set(group.map((entry) => entry.leakReason).filter(Boolean))]
39
-
40
- return {
41
- id: key,
42
- name: latest.name,
43
- component: latest.componentFile,
44
- instances: group.length,
45
- status: group.some((entry) => entry.status === 'mounted') ? 'mounted' : 'unmounted',
46
- leak: group.some((entry) => entry.leak),
47
- leakReason: leakReasons.join(' · ') || undefined,
48
- refs: Object.entries(latest.refs).map(([refKey, refValue]) => ({
49
- key: refKey,
50
- type: refValue.type,
51
- val: typeof refValue.value === 'string' ? refValue.value : JSON.stringify(refValue.value),
52
- })),
53
- watchers: group.reduce((sum, entry) => sum + entry.watcherCount, 0),
54
- intervals: group.reduce((sum, entry) => sum + entry.intervalCount, 0),
55
- lifecycle: {
56
- onMounted: group.some((entry) => entry.lifecycle.hasOnMounted),
57
- onUnmounted: group.some((entry) => entry.lifecycle.hasOnUnmounted),
58
- watchersCleaned: group.every((entry) => entry.lifecycle.watchersCleaned),
59
- intervalsCleaned: group.every((entry) => entry.lifecycle.intervalsCleaned),
60
- },
32
+ if (typeof v === 'string') {
33
+ return `"${v}"`
34
+ }
35
+
36
+ if (typeof v === 'object') {
37
+ try {
38
+ const s = JSON.stringify(v)
39
+
40
+ return s.length > 80 ? s.slice(0, 80) + '…' : s
41
+ } catch {
42
+ return String(v)
61
43
  }
62
- })
63
- })
44
+ }
45
+
46
+ return String(v)
47
+ }
48
+
49
+ function basename(file: string) {
50
+ return file.split('/').pop() ?? file
51
+ }
64
52
 
65
53
  const filter = ref('all')
66
54
  const search = ref('')
67
55
  const expanded = ref<string | null>(null)
68
56
 
57
+ const entries = computed<RuntimeComposableEntry[]>(() => rawEntries.value)
58
+
69
59
  const counts = computed(() => ({
70
- mounted: entries.value.filter((entry) => entry.status === 'mounted').length,
71
- leaks: entries.value.filter((entry) => entry.leak).length,
60
+ mounted: entries.value.filter((e) => e.status === 'mounted').length,
61
+ leaks: entries.value.filter((e) => e.leak).length,
72
62
  }))
73
63
 
74
64
  const filtered = computed(() => {
@@ -77,24 +67,49 @@ const filtered = computed(() => {
77
67
  return false
78
68
  }
79
69
 
70
+ if (filter.value === 'mounted' && entry.status !== 'mounted') {
71
+ return false
72
+ }
73
+
80
74
  if (filter.value === 'unmounted' && entry.status !== 'unmounted') {
81
75
  return false
82
76
  }
83
77
 
84
78
  const q = search.value.toLowerCase()
85
79
 
86
- if (q && !entry.name.toLowerCase().includes(q) && !entry.component.toLowerCase().includes(q)) {
87
- return false
80
+ if (q) {
81
+ const matchesName = entry.name.toLowerCase().includes(q)
82
+ const matchesFile = entry.componentFile.toLowerCase().includes(q)
83
+ const matchesRef = Object.keys(entry.refs).some((k) => k.toLowerCase().includes(q))
84
+ const matchesVal = Object.values(entry.refs).some((v) => {
85
+ try {
86
+ return String(JSON.stringify(v.value)).toLowerCase().includes(q)
87
+ } catch {
88
+ return false
89
+ }
90
+ })
91
+
92
+ if (!matchesName && !matchesFile && !matchesRef && !matchesVal) {
93
+ return false
94
+ }
88
95
  }
89
96
 
90
97
  return true
91
98
  })
92
99
  })
93
100
 
94
- function lifecycleRows(entry: ComposableViewEntry) {
101
+ function lifecycleRows(entry: RuntimeComposableEntry) {
95
102
  return [
96
- { label: 'onMounted', ok: entry.lifecycle.onMounted, status: entry.lifecycle.onMounted ? 'registered' : 'not used' },
97
- { label: 'onUnmounted', ok: entry.lifecycle.onUnmounted, status: entry.lifecycle.onUnmounted ? 'registered' : 'missing' },
103
+ {
104
+ label: 'onMounted',
105
+ ok: entry.lifecycle.hasOnMounted,
106
+ status: entry.lifecycle.hasOnMounted ? 'registered' : 'not used',
107
+ },
108
+ {
109
+ label: 'onUnmounted',
110
+ ok: entry.lifecycle.hasOnUnmounted,
111
+ status: entry.lifecycle.hasOnUnmounted ? 'registered' : 'missing',
112
+ },
98
113
  {
99
114
  label: 'watchers cleaned',
100
115
  ok: entry.lifecycle.watchersCleaned,
@@ -107,6 +122,93 @@ function lifecycleRows(entry: ComposableViewEntry) {
107
122
  },
108
123
  ]
109
124
  }
125
+
126
+ function typeBadgeClass(type: string) {
127
+ if (type === 'computed') {
128
+ return 'badge-info'
129
+ }
130
+
131
+ if (type === 'reactive') {
132
+ return 'badge-purple'
133
+ }
134
+
135
+ return 'badge-gray'
136
+ }
137
+
138
+ // ── Reverse lookup ────────────────────────────────────────────────────────
139
+ // Clicking a ref key shows every mounted instance that exposes the same key.
140
+
141
+ const lookupKey = ref<string | null>(null)
142
+
143
+ const lookupResults = computed(() => {
144
+ if (!lookupKey.value) {
145
+ return []
146
+ }
147
+
148
+ const key = lookupKey.value
149
+
150
+ return entries.value.filter((e) => key in e.refs)
151
+ })
152
+
153
+ function openLookup(key: string) {
154
+ lookupKey.value = lookupKey.value === key ? null : key
155
+ }
156
+
157
+ // ── Inline editing ────────────────────────────────────────────────────────
158
+ // Only writable refs (type === 'ref') can be edited. Computed are read-only.
159
+
160
+ interface EditTarget {
161
+ id: string
162
+ key: string
163
+ rawValue: string
164
+ }
165
+
166
+ const editTarget = ref<EditTarget | null>(null)
167
+ const editError = ref('')
168
+
169
+ function openEdit(id: string, key: string, currentValue: unknown) {
170
+ editError.value = ''
171
+ editTarget.value = {
172
+ id,
173
+ key,
174
+ rawValue: JSON.stringify(currentValue, null, 2),
175
+ }
176
+ }
177
+
178
+ function applyEdit() {
179
+ if (!editTarget.value) {
180
+ return
181
+ }
182
+
183
+ let parsed: unknown
184
+
185
+ try {
186
+ parsed = JSON.parse(editTarget.value.rawValue)
187
+ editError.value = ''
188
+ } catch (err) {
189
+ editError.value = `Invalid JSON: ${(err as Error).message}`
190
+
191
+ return
192
+ }
193
+
194
+ const origin = getObservatoryOrigin()
195
+
196
+ if (!origin) {
197
+ return
198
+ }
199
+
200
+ window.top?.postMessage(
201
+ {
202
+ type: 'observatory:edit-composable',
203
+ id: editTarget.value.id,
204
+ key: editTarget.value.key,
205
+ value: parsed,
206
+ },
207
+ origin
208
+ )
209
+
210
+ editTarget.value = null
211
+ }
110
212
  </script>
111
213
 
112
214
  <template>
@@ -126,20 +228,17 @@ function lifecycleRows(entry: ComposableViewEntry) {
126
228
  </div>
127
229
  <div class="stat-card">
128
230
  <div class="stat-label">instances</div>
129
- <div class="stat-val">{{ entries.reduce((sum, entry) => sum + entry.instances, 0) }}</div>
231
+ <div class="stat-val">{{ entries.length }}</div>
130
232
  </div>
131
233
  </div>
132
234
 
133
235
  <div class="toolbar">
134
236
  <button :class="{ active: filter === 'all' }" @click="filter = 'all'">all</button>
237
+ <button :class="{ active: filter === 'mounted' }" @click="filter = 'mounted'">mounted</button>
135
238
  <button :class="{ 'danger-active': filter === 'leak' }" @click="filter = 'leak'">leaks only</button>
136
239
  <button :class="{ active: filter === 'unmounted' }" @click="filter = 'unmounted'">unmounted</button>
137
- <input
138
- v-model="search"
139
- type="search"
140
- placeholder="search composable or component…"
141
- style="max-width: 220px; margin-left: auto"
142
- />
240
+ <input v-model="search" type="search" placeholder="search name, file, or ref…" style="max-width: 220px; margin-left: auto" />
241
+ <button class="clear-btn" title="Clear session history" @click="clearSession">clear</button>
143
242
  </div>
144
243
 
145
244
  <div class="list">
@@ -151,40 +250,126 @@ function lifecycleRows(entry: ComposableViewEntry) {
151
250
  @click="expanded = expanded === entry.id ? null : entry.id"
152
251
  >
153
252
  <div class="comp-header">
154
- <span class="mono bold" style="font-size: 12px">{{ entry.name }}</span>
155
- <span class="muted text-sm" style="margin-left: 4px">{{ entry.component }}</span>
253
+ <div class="comp-identity">
254
+ <span class="comp-name mono">{{ entry.name }}</span>
255
+ <span class="comp-file muted mono">{{ basename(entry.componentFile) }}</span>
256
+ </div>
156
257
  <div class="comp-meta">
157
- <span v-if="entry.instances > 1" class="muted text-sm">{{ entry.instances }}×</span>
158
- <span v-if="entry.watchers > 0 && !entry.leak" class="badge badge-warn">
159
- {{ entry.watchers }} watcher{{ entry.watchers > 1 ? 's' : '' }}
160
- </span>
161
- <span v-if="entry.intervals > 0 && !entry.leak" class="badge badge-warn">
162
- {{ entry.intervals }} interval{{ entry.intervals > 1 ? 's' : '' }}
163
- </span>
164
- <span v-if="entry.leak" class="badge badge-err">leak detected</span>
258
+ <span v-if="entry.watcherCount > 0 && !entry.leak" class="badge badge-warn">{{ entry.watcherCount }}w</span>
259
+ <span v-if="entry.intervalCount > 0 && !entry.leak" class="badge badge-warn">{{ entry.intervalCount }}t</span>
260
+ <span v-if="entry.leak" class="badge badge-err">leak</span>
165
261
  <span v-else-if="entry.status === 'mounted'" class="badge badge-ok">mounted</span>
166
262
  <span v-else class="badge badge-gray">unmounted</span>
167
263
  </div>
168
264
  </div>
169
265
 
266
+ <!-- Inline ref preview — shows up to 3 refs without expanding -->
267
+ <div v-if="Object.keys(entry.refs).length" class="refs-preview">
268
+ <span
269
+ v-for="[k, v] in Object.entries(entry.refs).slice(0, 3)"
270
+ :key="k"
271
+ class="ref-chip"
272
+ :class="{
273
+ 'ref-chip--reactive': v.type === 'reactive',
274
+ 'ref-chip--computed': v.type === 'computed',
275
+ 'ref-chip--shared': entry.sharedKeys?.includes(k),
276
+ }"
277
+ :title="entry.sharedKeys?.includes(k) ? 'shared global state' : ''"
278
+ >
279
+ <span class="ref-chip-key">{{ k }}</span>
280
+ <span class="ref-chip-val">{{ formatVal(v.value) }}</span>
281
+ <span v-if="entry.sharedKeys?.includes(k)" class="ref-chip-shared-dot" title="global"></span>
282
+ </span>
283
+ <span v-if="Object.keys(entry.refs).length > 3" class="muted text-sm">
284
+ +{{ Object.keys(entry.refs).length - 3 }} more
285
+ </span>
286
+ </div>
287
+
170
288
  <div v-if="expanded === entry.id" class="comp-detail" @click.stop>
171
289
  <div v-if="entry.leak" class="leak-banner">{{ entry.leakReason }}</div>
172
290
 
291
+ <!-- Global state warning -->
292
+ <div v-if="entry.sharedKeys?.length" class="global-banner">
293
+ <span class="global-dot"></span>
294
+ <span>
295
+ <strong>global state</strong>
296
+ — {{ entry.sharedKeys.join(', ') }}
297
+ {{ entry.sharedKeys.length === 1 ? 'is' : 'are' }}
298
+ shared across all instances of {{ entry.name }}
299
+ </span>
300
+ </div>
301
+
173
302
  <div class="section-label">reactive state</div>
174
- <div v-for="refEntry in entry.refs" :key="refEntry.key" class="ref-row">
175
- <span class="mono text-sm" style="min-width: 90px; color: var(--text2)">{{ refEntry.key }}</span>
176
- <span class="mono text-sm muted" style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
177
- {{ refEntry.val }}
303
+ <div v-if="!Object.keys(entry.refs).length" class="muted text-sm" style="padding: 2px 0 6px">
304
+ no tracked state returned
305
+ </div>
306
+ <div v-for="[k, v] in Object.entries(entry.refs)" :key="k" class="ref-row">
307
+ <span
308
+ class="mono text-sm ref-key ref-key--clickable"
309
+ :title="`click to see all instances exposing '${k}'`"
310
+ @click.stop="openLookup(k)"
311
+ >
312
+ {{ k }}
178
313
  </span>
179
- <span class="badge badge-info text-xs">{{ refEntry.type }}</span>
314
+ <span class="mono text-sm ref-val">{{ formatVal(v.value) }}</span>
315
+ <span class="badge text-xs" :class="typeBadgeClass(v.type)">{{ v.type }}</span>
316
+ <span v-if="entry.sharedKeys?.includes(k)" class="badge badge-amber text-xs">global</span>
317
+ <button v-if="v.type === 'ref'" class="edit-btn" title="Edit value" @click.stop="openEdit(entry.id, k, v.value)">
318
+ edit
319
+ </button>
180
320
  </div>
181
321
 
182
- <div class="section-label" style="margin-top: 8px">lifecycle</div>
322
+ <template v-if="entry.history && entry.history.length">
323
+ <div class="section-label" style="margin-top: 10px">
324
+ change history
325
+ <span class="muted" style="font-weight: 400; text-transform: none; letter-spacing: 0">
326
+ ({{ entry.history.length }} events)
327
+ </span>
328
+ </div>
329
+ <div class="history-list">
330
+ <div v-for="(evt, idx) in [...entry.history].reverse().slice(0, 20)" :key="idx" class="history-row">
331
+ <span class="history-time mono muted">+{{ (evt.t / 1000).toFixed(2) }}s</span>
332
+ <span class="history-key mono">{{ evt.key }}</span>
333
+ <span class="history-val mono">{{ formatVal(evt.value) }}</span>
334
+ </div>
335
+ <div v-if="entry.history.length > 20" class="muted text-sm" style="padding: 2px 0">
336
+ … {{ entry.history.length - 20 }} earlier events
337
+ </div>
338
+ </div>
339
+ </template>
340
+
341
+ <div class="section-label" style="margin-top: 10px">lifecycle</div>
183
342
  <div v-for="row in lifecycleRows(entry)" :key="row.label" class="lc-row">
184
343
  <span class="lc-dot" :style="{ background: row.ok ? 'var(--teal)' : 'var(--red)' }"></span>
185
- <span class="muted text-sm" style="min-width: 110px">{{ row.label }}</span>
344
+ <span class="muted text-sm" style="min-width: 120px">{{ row.label }}</span>
186
345
  <span class="text-sm" :style="{ color: row.ok ? 'var(--teal)' : 'var(--red)' }">{{ row.status }}</span>
187
346
  </div>
347
+
348
+ <div class="section-label" style="margin-top: 10px">context</div>
349
+ <div class="lc-row">
350
+ <span class="muted text-sm" style="min-width: 120px">component</span>
351
+ <span class="mono text-sm">{{ basename(entry.componentFile) }}</span>
352
+ </div>
353
+ <div class="lc-row">
354
+ <span class="muted text-sm" style="min-width: 120px">uid</span>
355
+ <span class="mono text-sm muted">{{ entry.componentUid }}</span>
356
+ </div>
357
+ <div class="lc-row">
358
+ <span class="muted text-sm" style="min-width: 120px">defined in</span>
359
+ <span class="mono text-sm muted">{{ entry.file }}:{{ entry.line }}</span>
360
+ </div>
361
+ <div class="lc-row">
362
+ <span class="muted text-sm" style="min-width: 120px">route</span>
363
+ <span class="mono text-sm muted">{{ entry.route }}</span>
364
+ </div>
365
+ <div class="lc-row">
366
+ <span class="muted text-sm" style="min-width: 120px">watchers</span>
367
+ <span class="mono text-sm">{{ entry.watcherCount }}</span>
368
+ </div>
369
+ <div class="lc-row">
370
+ <span class="muted text-sm" style="min-width: 120px">intervals</span>
371
+ <span class="mono text-sm">{{ entry.intervalCount }}</span>
372
+ </div>
188
373
  </div>
189
374
  </div>
190
375
 
@@ -192,6 +377,47 @@ function lifecycleRows(entry: ComposableViewEntry) {
192
377
  {{ connected ? 'No composables recorded yet.' : 'Waiting for connection to the Nuxt app…' }}
193
378
  </div>
194
379
  </div>
380
+
381
+ <!-- ── Reverse lookup panel ──────────────────────────────────────── -->
382
+ <Transition name="slide">
383
+ <div v-if="lookupKey" class="lookup-panel">
384
+ <div class="lookup-header">
385
+ <span class="mono text-sm">{{ lookupKey }}</span>
386
+ <span class="muted text-sm">— {{ lookupResults.length }} instance{{ lookupResults.length !== 1 ? 's' : '' }}</span>
387
+ <button class="clear-btn" style="margin-left: auto" @click="lookupKey = null">✕</button>
388
+ </div>
389
+ <div v-if="!lookupResults.length" class="muted text-sm" style="padding: 6px 0">No mounted instances expose this key.</div>
390
+ <div v-for="r in lookupResults" :key="r.id" class="lookup-row">
391
+ <span class="mono text-sm">{{ r.name }}</span>
392
+ <span class="muted text-sm">{{ basename(r.componentFile) }}</span>
393
+ <span class="muted text-sm" style="margin-left: auto">{{ r.route }}</span>
394
+ </div>
395
+ </div>
396
+ </Transition>
397
+
398
+ <!-- ── Edit value dialog ─────────────────────────────────────────── -->
399
+ <Transition name="fade">
400
+ <div v-if="editTarget" class="edit-overlay" @click.self="editTarget = null">
401
+ <div class="edit-dialog">
402
+ <div class="edit-dialog-header">
403
+ edit
404
+ <span class="mono">{{ editTarget.key }}</span>
405
+ <button class="clear-btn" style="margin-left: auto" @click="editTarget = null">✕</button>
406
+ </div>
407
+ <p class="muted text-sm" style="padding: 4px 0 8px">
408
+ Value applied immediately to the live ref. Only
409
+ <span class="mono">ref</span>
410
+ values are writable.
411
+ </p>
412
+ <textarea v-model="editTarget.rawValue" class="edit-textarea" rows="6" spellcheck="false" />
413
+ <div v-if="editError" class="edit-error text-sm">{{ editError }}</div>
414
+ <div class="edit-actions">
415
+ <button @click="applyEdit">apply</button>
416
+ <button class="clear-btn" @click="editTarget = null">cancel</button>
417
+ </div>
418
+ </div>
419
+ </div>
420
+ </Transition>
195
421
  </div>
196
422
  </template>
197
423
 
@@ -220,12 +446,24 @@ function lifecycleRows(entry: ComposableViewEntry) {
220
446
  flex-wrap: wrap;
221
447
  }
222
448
 
449
+ .clear-btn {
450
+ color: var(--text3);
451
+ border-color: var(--border);
452
+ flex-shrink: 0;
453
+ }
454
+
455
+ .clear-btn:hover {
456
+ color: var(--red);
457
+ border-color: var(--red);
458
+ background: transparent;
459
+ }
460
+
223
461
  .list {
224
462
  flex: 1;
225
463
  overflow: auto;
226
464
  display: flex;
227
465
  flex-direction: column;
228
- gap: 6px;
466
+ gap: 5px;
229
467
  min-height: 0;
230
468
  }
231
469
 
@@ -254,30 +492,104 @@ function lifecycleRows(entry: ComposableViewEntry) {
254
492
  .comp-header {
255
493
  display: flex;
256
494
  align-items: center;
495
+ justify-content: space-between;
496
+ padding: 8px 12px;
257
497
  gap: 8px;
258
- padding: 9px 13px;
259
- min-height: 44px;
498
+ }
499
+
500
+ .comp-identity {
501
+ display: flex;
502
+ align-items: baseline;
503
+ gap: 6px;
504
+ min-width: 0;
505
+ flex: 1;
506
+ }
507
+
508
+ .comp-name {
509
+ font-size: 12px;
510
+ font-weight: 500;
511
+ color: var(--text);
512
+ flex-shrink: 0;
513
+ }
514
+
515
+ .comp-file {
516
+ font-size: 11px;
517
+ color: var(--text3);
518
+ overflow: hidden;
519
+ text-overflow: ellipsis;
520
+ white-space: nowrap;
260
521
  }
261
522
 
262
523
  .comp-meta {
263
524
  display: flex;
264
525
  align-items: center;
265
- gap: 6px;
266
- margin-left: auto;
526
+ gap: 5px;
527
+ flex-shrink: 0;
528
+ }
529
+
530
+ /* Inline ref preview chips */
531
+ .refs-preview {
532
+ display: flex;
533
+ flex-wrap: wrap;
534
+ gap: 4px;
535
+ padding: 0 12px 8px;
536
+ align-items: center;
537
+ }
538
+
539
+ .ref-chip {
540
+ display: inline-flex;
541
+ align-items: center;
542
+ gap: 4px;
543
+ padding: 2px 7px;
544
+ border-radius: 4px;
545
+ background: var(--bg2);
546
+ border: 0.5px solid var(--border);
547
+ font-size: 11px;
548
+ font-family: var(--mono);
549
+ max-width: 220px;
550
+ overflow: hidden;
551
+ }
552
+
553
+ .ref-chip--reactive {
554
+ border-color: color-mix(in srgb, var(--purple) 40%, var(--border));
555
+ background: color-mix(in srgb, var(--purple) 8%, var(--bg2));
267
556
  }
268
557
 
558
+ .ref-chip--computed {
559
+ border-color: color-mix(in srgb, var(--blue) 40%, var(--border));
560
+ background: color-mix(in srgb, var(--blue) 8%, var(--bg2));
561
+ }
562
+
563
+ .ref-chip-key {
564
+ color: var(--text2);
565
+ flex-shrink: 0;
566
+ }
567
+
568
+ .ref-chip-val {
569
+ color: var(--teal);
570
+ overflow: hidden;
571
+ text-overflow: ellipsis;
572
+ white-space: nowrap;
573
+ }
574
+
575
+ /* Expanded detail */
269
576
  .comp-detail {
577
+ padding: 4px 12px 12px;
270
578
  border-top: 0.5px solid var(--border);
271
- padding: 10px 13px;
579
+ display: flex;
580
+ flex-direction: column;
581
+ gap: 3px;
272
582
  }
273
583
 
274
584
  .leak-banner {
275
- background: rgb(226 75 74 / 10%);
585
+ background: color-mix(in srgb, var(--red) 12%, transparent);
586
+ border: 0.5px solid color-mix(in srgb, var(--red) 40%, var(--border));
276
587
  border-radius: var(--radius);
277
- padding: 7px 10px;
278
- font-size: 12px;
588
+ padding: 6px 10px;
589
+ font-size: 11px;
279
590
  color: var(--red);
280
- margin-bottom: 8px;
591
+ margin-bottom: 6px;
592
+ font-family: var(--mono);
281
593
  }
282
594
 
283
595
  .section-label {
@@ -286,30 +598,302 @@ function lifecycleRows(entry: ComposableViewEntry) {
286
598
  text-transform: uppercase;
287
599
  letter-spacing: 0.4px;
288
600
  color: var(--text3);
289
- margin-bottom: 5px;
601
+ margin-top: 6px;
602
+ margin-bottom: 3px;
290
603
  }
291
604
 
292
605
  .ref-row {
293
606
  display: flex;
294
607
  align-items: center;
295
608
  gap: 8px;
296
- padding: 5px 8px;
297
- background: var(--bg2);
298
- border-radius: var(--radius);
299
- margin-bottom: 3px;
609
+ padding: 3px 0;
610
+ }
611
+
612
+ .ref-key {
613
+ min-width: 90px;
614
+ color: var(--text2);
615
+ flex-shrink: 0;
616
+ }
617
+
618
+ .ref-val {
619
+ flex: 1;
620
+ overflow: hidden;
621
+ text-overflow: ellipsis;
622
+ white-space: nowrap;
623
+ color: var(--teal);
300
624
  }
301
625
 
302
626
  .lc-row {
303
627
  display: flex;
304
628
  align-items: center;
305
629
  gap: 8px;
306
- padding: 4px 0;
630
+ padding: 2px 0;
307
631
  }
308
632
 
309
633
  .lc-dot {
310
- width: 7px;
311
- height: 7px;
634
+ width: 6px;
635
+ height: 6px;
636
+ border-radius: 50%;
637
+ flex-shrink: 0;
638
+ }
639
+
640
+ .ref-chip--shared {
641
+ border-color: color-mix(in srgb, var(--amber) 50%, var(--border));
642
+ background: color-mix(in srgb, var(--amber) 10%, var(--bg2));
643
+ }
644
+
645
+ .ref-chip-shared-dot {
646
+ display: inline-block;
647
+ width: 5px;
648
+ height: 5px;
649
+ border-radius: 50%;
650
+ background: var(--amber);
651
+ flex-shrink: 0;
652
+ margin-left: 1px;
653
+ }
654
+
655
+ .global-banner {
656
+ display: flex;
657
+ align-items: flex-start;
658
+ gap: 8px;
659
+ background: color-mix(in srgb, var(--amber) 10%, transparent);
660
+ border: 0.5px solid color-mix(in srgb, var(--amber) 40%, var(--border));
661
+ border-radius: var(--radius);
662
+ padding: 7px 10px;
663
+ font-size: 11px;
664
+ color: var(--text2);
665
+ margin-bottom: 6px;
666
+ }
667
+
668
+ .global-dot {
669
+ display: inline-block;
670
+ width: 6px;
671
+ height: 6px;
312
672
  border-radius: 50%;
673
+ background: var(--amber);
313
674
  flex-shrink: 0;
675
+ margin-top: 3px;
676
+ }
677
+
678
+ .badge-amber {
679
+ background: color-mix(in srgb, var(--amber) 15%, transparent);
680
+ color: color-mix(in srgb, var(--amber) 80%, var(--text));
681
+ border: 0.5px solid color-mix(in srgb, var(--amber) 40%, var(--border));
682
+ }
683
+
684
+ .history-list {
685
+ display: flex;
686
+ flex-direction: column;
687
+ gap: 1px;
688
+ background: var(--bg2);
689
+ border-radius: var(--radius);
690
+ padding: 4px 8px;
691
+ max-height: 180px;
692
+ overflow-y: auto;
693
+ }
694
+
695
+ .history-row {
696
+ display: flex;
697
+ align-items: center;
698
+ gap: 8px;
699
+ padding: 2px 0;
700
+ font-size: 11px;
701
+ font-family: var(--mono);
702
+ border-bottom: 0.5px solid var(--border);
703
+ }
704
+
705
+ .history-row:last-child {
706
+ border-bottom: none;
707
+ }
708
+
709
+ .history-time {
710
+ min-width: 52px;
711
+ color: var(--text3);
712
+ flex-shrink: 0;
713
+ }
714
+
715
+ .history-key {
716
+ min-width: 80px;
717
+ color: var(--text2);
718
+ flex-shrink: 0;
719
+ }
720
+
721
+ .history-val {
722
+ color: var(--amber);
723
+ overflow: hidden;
724
+ text-overflow: ellipsis;
725
+ white-space: nowrap;
726
+ flex: 1;
727
+ }
728
+
729
+ /* Stat cards */
730
+ .stat-card {
731
+ background: var(--bg3);
732
+ border: 0.5px solid var(--border);
733
+ border-radius: var(--radius-lg);
734
+ padding: 10px 14px;
735
+ }
736
+
737
+ .stat-label {
738
+ font-size: 10px;
739
+ font-weight: 500;
740
+ text-transform: uppercase;
741
+ letter-spacing: 0.4px;
742
+ color: var(--text3);
743
+ margin-bottom: 4px;
744
+ }
745
+
746
+ .stat-val {
747
+ font-size: 22px;
748
+ font-weight: 500;
749
+ line-height: 1;
750
+ color: var(--text);
751
+ }
752
+
753
+ /* Clickable ref key */
754
+ .ref-key--clickable {
755
+ cursor: pointer;
756
+ text-decoration: underline dotted var(--text3);
757
+ text-underline-offset: 2px;
758
+ }
759
+
760
+ .ref-key--clickable:hover {
761
+ color: var(--purple);
762
+ text-decoration-color: var(--purple);
763
+ }
764
+
765
+ /* Edit button inline with ref row */
766
+ .edit-btn {
767
+ font-size: 10px;
768
+ padding: 1px 6px;
769
+ border-radius: var(--radius);
770
+ border: 0.5px solid var(--border);
771
+ background: transparent;
772
+ color: var(--text3);
773
+ cursor: pointer;
774
+ margin-left: auto;
775
+ flex-shrink: 0;
776
+ font-family: var(--mono);
777
+ }
778
+
779
+ .edit-btn:hover {
780
+ border-color: var(--purple);
781
+ color: var(--purple);
782
+ background: color-mix(in srgb, var(--purple) 8%, transparent);
783
+ }
784
+
785
+ /* Reverse lookup panel — appears below the list */
786
+ .lookup-panel {
787
+ flex-shrink: 0;
788
+ border: 0.5px solid var(--border);
789
+ border-radius: var(--radius-lg);
790
+ background: var(--bg3);
791
+ overflow: hidden;
792
+ }
793
+
794
+ .lookup-header {
795
+ display: flex;
796
+ align-items: center;
797
+ gap: 6px;
798
+ padding: 7px 12px;
799
+ border-bottom: 0.5px solid var(--border);
800
+ background: var(--bg2);
801
+ }
802
+
803
+ .lookup-row {
804
+ display: flex;
805
+ align-items: center;
806
+ gap: 8px;
807
+ padding: 5px 12px;
808
+ border-bottom: 0.5px solid var(--border);
809
+ }
810
+
811
+ .lookup-row:last-child {
812
+ border-bottom: none;
813
+ }
814
+
815
+ /* Edit overlay + dialog */
816
+ .edit-overlay {
817
+ position: fixed;
818
+ inset: 0;
819
+ background: rgba(0, 0, 0, 0.4);
820
+ z-index: 100;
821
+ display: flex;
822
+ align-items: center;
823
+ justify-content: center;
824
+ }
825
+
826
+ .edit-dialog {
827
+ background: var(--bg1, var(--bg2));
828
+ border: 0.5px solid var(--border);
829
+ border-radius: var(--radius-lg);
830
+ padding: 14px 16px;
831
+ width: 380px;
832
+ max-width: 92vw;
833
+ display: flex;
834
+ flex-direction: column;
835
+ gap: 6px;
836
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
837
+ }
838
+
839
+ .edit-dialog-header {
840
+ display: flex;
841
+ align-items: center;
842
+ gap: 6px;
843
+ font-size: 12px;
844
+ color: var(--text2);
845
+ margin-bottom: 2px;
846
+ }
847
+
848
+ .edit-textarea {
849
+ width: 100%;
850
+ font-family: var(--mono);
851
+ font-size: 12px;
852
+ padding: 8px 10px;
853
+ background: var(--bg2);
854
+ border: 0.5px solid var(--border);
855
+ border-radius: var(--radius);
856
+ color: var(--text);
857
+ resize: vertical;
858
+ outline: none;
859
+ }
860
+
861
+ .edit-textarea:focus {
862
+ border-color: var(--purple);
863
+ }
864
+
865
+ .edit-error {
866
+ color: var(--red);
867
+ font-family: var(--mono);
868
+ }
869
+
870
+ .edit-actions {
871
+ display: flex;
872
+ gap: 6px;
873
+ padding-top: 4px;
874
+ }
875
+
876
+ /* Slide / fade transitions for the panels */
877
+ .slide-enter-active,
878
+ .slide-leave-active {
879
+ transition:
880
+ opacity 0.15s,
881
+ transform 0.15s;
882
+ }
883
+
884
+ .slide-enter-from,
885
+ .slide-leave-to {
886
+ opacity: 0;
887
+ transform: translateY(6px);
888
+ }
889
+
890
+ .fade-enter-active,
891
+ .fade-leave-active {
892
+ transition: opacity 0.15s;
893
+ }
894
+
895
+ .fade-enter-from,
896
+ .fade-leave-to {
897
+ opacity: 0;
314
898
  }
315
899
  </style>