@ozsarman/clarityjs 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,765 @@
1
+ /**
2
+ * Clarity.js — In-Page Developer Tools
3
+ *
4
+ * An overlay panel that visualises the live signal graph, component tree,
5
+ * and render statistics. Zero-dependency; self-contained in a Shadow DOM
6
+ * so it never clashes with the host page's CSS.
7
+ *
8
+ * Usage:
9
+ *
10
+ * import { initDevtools } from '@ozsarman/clarityjs/devtools';
11
+ * initDevtools(); // auto-attaches to <body>
12
+ * initDevtools({ open: true }); // start with panel open
13
+ *
14
+ * Environment guard: the module is a no-op in production builds so you
15
+ * can leave the import in source and gate on NODE_ENV/import.meta.env.
16
+ *
17
+ * if (import.meta.env.DEV) initDevtools();
18
+ *
19
+ * Keyboard shortcut: Ctrl+Shift+D / ⌘⇧D — toggle panel visibility
20
+ *
21
+ * Author: Claude (Anthropic) + Özdemir Sarman
22
+ */
23
+
24
+ import { signal, effect, batch } from './runtime.js';
25
+
26
+ // ─── DevTools Protocol (postMessage bridge) ───────────────────────────────────
27
+ //
28
+ // The in-page devtools communicates with the browser extension panel via
29
+ // window.postMessage so there's no need for a background service worker.
30
+ //
31
+ // Message shapes:
32
+ // Page → Extension: { source: 'clarity-devtools', type, payload }
33
+ // Extension → Page: { source: 'clarity-devtools-ext', type, payload }
34
+ //
35
+ // Types (page → ext):
36
+ // 'init' — devtools panel opened; send full snapshot
37
+ // 'component:add' — new component mounted
38
+ // 'component:remove' — component unmounted
39
+ // 'signal:update' — signal value changed
40
+ // 'render:tick' — component re-rendered
41
+ //
42
+ // Types (ext → page):
43
+ // 'inspect' — highlight a component in the page
44
+ // 'get:snapshot' — request full state snapshot
45
+
46
+ const _ORIGIN = '*'; // set to your app origin for production
47
+ const _SOURCE_PAGE = 'clarity-devtools';
48
+ const _SOURCE_EXT = 'clarity-devtools-ext';
49
+
50
+ function _postToExtension(type, payload) {
51
+ try {
52
+ window.postMessage({ source: _SOURCE_PAGE, type, payload }, _ORIGIN);
53
+ } catch {}
54
+ }
55
+
56
+ function _listenFromExtension() {
57
+ if (typeof window === 'undefined') return () => {};
58
+ function handler(evt) {
59
+ if (evt.data?.source !== _SOURCE_EXT) return;
60
+ const { type, payload } = evt.data;
61
+ if (type === 'get:snapshot') _sendFullSnapshot();
62
+ if (type === 'inspect') _highlightComponent(payload?.id);
63
+ if (type === 'set:signal') _setSignalValue(payload?.id, payload?.value);
64
+ }
65
+ window.addEventListener('message', handler);
66
+ return () => window.removeEventListener('message', handler);
67
+ }
68
+
69
+ function _sendFullSnapshot() {
70
+ _postToExtension('snapshot', {
71
+ components: [..._components.values()].map(c => ({
72
+ id: c.id,
73
+ name: c.name,
74
+ renderCount: c.renderCount,
75
+ signals: [...c.signals].map(id => {
76
+ const s = _signals.get(id);
77
+ return s ? { id, label: s.label, value: _safeStringify(s.sig) } : null;
78
+ }).filter(Boolean),
79
+ })),
80
+ signals: [..._signals.values()].map(s => ({
81
+ id: s.id,
82
+ label: s.label,
83
+ value: _safeStringify(s.sig),
84
+ })),
85
+ });
86
+ }
87
+
88
+ function _highlightComponent(id) {
89
+ const c = _components.get(id);
90
+ if (!c?.element) return;
91
+ const el = c.element;
92
+ const prev = el.style.outline;
93
+ el.style.outline = '2px solid #7c3aed';
94
+ setTimeout(() => { el.style.outline = prev; }, 2000);
95
+ }
96
+
97
+ function _setSignalValue(signalId, rawValue) {
98
+ const s = _signals.get(signalId);
99
+ if (!s?.sig?.set) return;
100
+ try {
101
+ const parsed = JSON.parse(rawValue);
102
+ s.sig.set(parsed);
103
+ } catch {
104
+ s.sig.set(rawValue);
105
+ }
106
+ }
107
+
108
+ function _safeStringify(sig) {
109
+ try { return JSON.stringify(sig?.peek?.() ?? sig?.get?.()); } catch { return 'null'; }
110
+ }
111
+
112
+ // ─── Internal registry ────────────────────────────────────────────────────────
113
+ // These are populated by runtime.js patches below.
114
+
115
+ /** Map<id, { name, signals, renderCount, renderTimes, element }> */
116
+ const _components = new Map();
117
+ /** Map<id, { label, sig, updateCount }> */
118
+ const _signals = new Map();
119
+
120
+ /** Rolling performance timeline: [{ componentId, name, duration, ts }] */
121
+ const _perfTimeline = [];
122
+ const _MAX_TIMELINE = 200;
123
+
124
+ let _nextId = 1;
125
+
126
+ // ─── Public instrumentation API (called by runtime stubs) ─────────────────────
127
+
128
+ export function _devRegisterComponent(name, element) {
129
+ const id = _nextId++;
130
+ const entry = { id, name, signals: new Set(), renderCount: 0, renderTimes: [], element };
131
+ _components.set(id, entry);
132
+ element.__devtools_id = id;
133
+ _notifyPanel();
134
+ _postToExtension('component:add', { id, name });
135
+ return id;
136
+ }
137
+
138
+ export function _devUnregisterComponent(id) {
139
+ _components.delete(id);
140
+ _notifyPanel();
141
+ _postToExtension('component:remove', { id });
142
+ }
143
+
144
+ export function _devRegisterSignal(label, sig) {
145
+ const id = _nextId++;
146
+ const entry = { id, label, sig, updateCount: 0 };
147
+ _signals.set(id, entry);
148
+ sig.__devtools_id = id;
149
+ // Watch for value changes and broadcast; skip initial subscription fire
150
+ if (typeof effect === 'function') {
151
+ let _first = true;
152
+ try {
153
+ effect(() => {
154
+ const val = sig.get?.();
155
+ if (_first) { _first = false; return; }
156
+ entry.updateCount++;
157
+ _postToExtension('signal:update', { id, label, value: JSON.stringify(val) });
158
+ _notifyPanel();
159
+ });
160
+ } catch {}
161
+ }
162
+ _notifyPanel();
163
+ return id;
164
+ }
165
+
166
+ /**
167
+ * Called by runtime when a signal is updated externally (without effect tracking).
168
+ * Optional — enriches update frequency counts shown in the Perf tab.
169
+ */
170
+ export function _devRecordSignalUpdate(signalId) {
171
+ const s = _signals.get(signalId);
172
+ if (s) {
173
+ s.updateCount++;
174
+ _notifyPanel();
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Record a component render.
180
+ *
181
+ * @param {number} componentId
182
+ * @param {number} [durationMs] - render duration from performance.now() delta (optional)
183
+ */
184
+ export function _devRecordRender(componentId, durationMs) {
185
+ const c = _components.get(componentId);
186
+ if (c) {
187
+ c.renderCount++;
188
+ if (typeof durationMs === 'number' && isFinite(durationMs)) {
189
+ c.renderTimes.push(durationMs);
190
+ // Keep only the last 50 measurements per component
191
+ if (c.renderTimes.length > 50) c.renderTimes.shift();
192
+
193
+ // Add to rolling timeline
194
+ _perfTimeline.push({ componentId, name: c.name, duration: durationMs, ts: Date.now() });
195
+ if (_perfTimeline.length > _MAX_TIMELINE) _perfTimeline.shift();
196
+ }
197
+ _notifyPanel();
198
+ _postToExtension('render:tick', { id: componentId, count: c.renderCount, durationMs });
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Convenience helpers for wrapping a render call with performance timing.
204
+ * Usage in runtime.js:
205
+ * const t0 = _devRenderStart();
206
+ * // ...render...
207
+ * _devRenderEnd(componentId, t0);
208
+ */
209
+ export function _devRenderStart() {
210
+ return typeof performance !== 'undefined' ? performance.now() : Date.now();
211
+ }
212
+
213
+ export function _devRenderEnd(componentId, t0) {
214
+ const duration = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - t0;
215
+ _devRecordRender(componentId, duration);
216
+ }
217
+
218
+ /**
219
+ * Clear all performance timing data (does not reset render counts).
220
+ */
221
+ export function _devClearPerfData() {
222
+ _perfTimeline.length = 0;
223
+ for (const c of _components.values()) c.renderTimes = [];
224
+ for (const s of _signals.values()) s.updateCount = 0;
225
+ _notifyPanel();
226
+ }
227
+
228
+ // ─── Notification ─────────────────────────────────────────────────────────────
229
+
230
+ let _panel = null; // reference to the live DevPanel instance
231
+
232
+ function _notifyPanel() {
233
+ _panel?.refresh();
234
+ }
235
+
236
+ // ─── initDevtools ─────────────────────────────────────────────────────────────
237
+
238
+ /**
239
+ * Mount the devtools overlay into the current page.
240
+ *
241
+ * @param {object} [options]
242
+ * @param {boolean} [options.open=false] - Whether the panel starts visible
243
+ * @param {string} [options.position='br'] - Corner: 'tl'|'tr'|'bl'|'br'
244
+ * @param {HTMLElement} [options.container] - Mount target (default: document.body)
245
+ * @returns {{ dispose: () => void }}
246
+ */
247
+ export function initDevtools({ open = false, position = 'br', container } = {}) {
248
+ if (typeof document === 'undefined') {
249
+ // SSR / Node.js — no-op
250
+ return { dispose: () => {} };
251
+ }
252
+
253
+ if (_panel) {
254
+ console.warn('[Clarity DevTools] Already initialised. Call dispose() first to re-init.');
255
+ return { dispose: () => _panel?.dispose() };
256
+ }
257
+
258
+ const root = container ?? document.body;
259
+ _panel = new DevPanel({ open, position });
260
+ root.appendChild(_panel.el);
261
+ _panel.refresh();
262
+
263
+ // Keyboard shortcut: Ctrl+Shift+D / Cmd+Shift+D
264
+ function _onKey(e) {
265
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'D') {
266
+ e.preventDefault();
267
+ _panel?.toggle();
268
+ }
269
+ }
270
+ window.addEventListener('keydown', _onKey);
271
+
272
+ // Start listening for extension messages
273
+ const _stopExtListener = _listenFromExtension();
274
+
275
+ // Announce to extension that devtools are available
276
+ _postToExtension('init', { version: '0.2.0' });
277
+ _sendFullSnapshot();
278
+
279
+ function dispose() {
280
+ window.removeEventListener('keydown', _onKey);
281
+ _stopExtListener();
282
+ _panel?.dispose();
283
+ _panel = null;
284
+ }
285
+
286
+ return { dispose };
287
+ }
288
+
289
+ // ─── DevPanel class ───────────────────────────────────────────────────────────
290
+
291
+ class DevPanel {
292
+ constructor({ open, position }) {
293
+ this._open = signal(open);
294
+ this._tab = signal('components'); // 'components' | 'signals' | 'perf'
295
+ this._disposed = false;
296
+
297
+ // Host element (outside Shadow DOM so it can be positioned)
298
+ this.el = document.createElement('div');
299
+ this.el.setAttribute('data-clarity-devtools', '');
300
+ Object.assign(this.el.style, _positionStyle(position));
301
+
302
+ // Shadow root for style isolation
303
+ const shadow = this.el.attachShadow({ mode: 'open' });
304
+
305
+ // Style injection
306
+ const style = document.createElement('style');
307
+ style.textContent = _CSS;
308
+ shadow.appendChild(style);
309
+
310
+ // Panel wrapper
311
+ this._wrapper = document.createElement('div');
312
+ this._wrapper.className = 'panel';
313
+ shadow.appendChild(this._wrapper);
314
+
315
+ // Render initial structure
316
+ this._buildShell();
317
+
318
+ // Reactive open/close
319
+ this._stopEffect = effect(() => {
320
+ this._wrapper.dataset.open = String(this._open.get());
321
+ });
322
+ }
323
+
324
+ // ── Public ────────────────────────────────────────────────────────────────
325
+
326
+ toggle() {
327
+ this._open.update(v => !v);
328
+ }
329
+
330
+ refresh() {
331
+ if (this._disposed) return;
332
+ this._renderBody();
333
+ }
334
+
335
+ dispose() {
336
+ this._disposed = true;
337
+ this._stopEffect?.();
338
+ this.el.remove();
339
+ }
340
+
341
+ // ── Private ───────────────────────────────────────────────────────────────
342
+
343
+ _buildShell() {
344
+ // Toggle button (always visible)
345
+ const btn = document.createElement('button');
346
+ btn.className = 'toggle-btn';
347
+ btn.title = 'Clarity DevTools (Ctrl+Shift+D)';
348
+ btn.textContent = '⚡';
349
+ btn.addEventListener('click', () => this.toggle());
350
+ this._wrapper.appendChild(btn);
351
+
352
+ // Panel body (hidden when closed)
353
+ this._body = document.createElement('div');
354
+ this._body.className = 'body';
355
+ this._wrapper.appendChild(this._body);
356
+
357
+ // Header
358
+ const header = document.createElement('div');
359
+ header.className = 'header';
360
+ header.innerHTML = `
361
+ <span class="title">Clarity DevTools</span>
362
+ <span class="tabs">
363
+ <button data-tab="components">Components</button>
364
+ <button data-tab="signals">Signals</button>
365
+ <button data-tab="perf">Perf</button>
366
+ </span>
367
+ <button class="close-btn" title="Close">✕</button>
368
+ `;
369
+ header.querySelector('.close-btn').addEventListener('click', () => this.toggle());
370
+ header.querySelectorAll('[data-tab]').forEach(b => {
371
+ b.addEventListener('click', () => {
372
+ this._tab.set(b.dataset.tab);
373
+ this._renderBody();
374
+ });
375
+ });
376
+ this._body.appendChild(header);
377
+
378
+ this._content = document.createElement('div');
379
+ this._content.className = 'content';
380
+ this._body.appendChild(this._content);
381
+ }
382
+
383
+ _renderBody() {
384
+ if (!this._content) return;
385
+ const tab = this._tab.get();
386
+ this._content.innerHTML = '';
387
+
388
+ // Highlight active tab button
389
+ this._body.querySelectorAll('[data-tab]').forEach(b => {
390
+ b.classList.toggle('active', b.dataset.tab === tab);
391
+ });
392
+
393
+ if (tab === 'components') this._renderComponents();
394
+ else if (tab === 'signals') this._renderSignals();
395
+ else if (tab === 'perf') this._renderPerf();
396
+ }
397
+
398
+ _renderComponents() {
399
+ if (_components.size === 0) {
400
+ this._content.innerHTML = '<p class="empty">No components registered.<br>Import <code>clarity-js/devtools</code> and call <code>initDevtools()</code>.</p>';
401
+ return;
402
+ }
403
+
404
+ const ul = document.createElement('ul');
405
+ ul.className = 'list';
406
+
407
+ for (const [, c] of _components) {
408
+ const li = document.createElement('li');
409
+ li.className = 'item';
410
+ li.innerHTML = `
411
+ <span class="item-icon">🧩</span>
412
+ <span class="item-label">${_esc(c.name)}</span>
413
+ <span class="item-meta">${c.signals.size} signals · ${c.renderCount} renders</span>
414
+ `;
415
+ ul.appendChild(li);
416
+ }
417
+
418
+ this._content.appendChild(ul);
419
+ }
420
+
421
+ _renderSignals() {
422
+ if (_signals.size === 0) {
423
+ this._content.innerHTML = '<p class="empty">No signals tracked yet.</p>';
424
+ return;
425
+ }
426
+
427
+ const ul = document.createElement('ul');
428
+ ul.className = 'list';
429
+
430
+ for (const [, s] of _signals) {
431
+ let displayVal;
432
+ try {
433
+ const raw = s.sig.peek();
434
+ displayVal = _stringify(raw);
435
+ } catch {
436
+ displayVal = '<error>';
437
+ }
438
+
439
+ const li = document.createElement('li');
440
+ li.className = 'item';
441
+ li.innerHTML = `
442
+ <span class="item-icon">⚡</span>
443
+ <span class="item-label">${_esc(s.label)}</span>
444
+ <span class="item-value">${_esc(displayVal)}</span>
445
+ `;
446
+ ul.appendChild(li);
447
+ }
448
+
449
+ this._content.appendChild(ul);
450
+ }
451
+
452
+ _renderPerf() {
453
+ const totalRenders = [..._components.values()].reduce((s, c) => s + c.renderCount, 0);
454
+ const totalSigUpdates = [..._signals.values()].reduce((s, sig) => s + sig.updateCount, 0);
455
+ const hasTiming = [..._components.values()].some(c => c.renderTimes.length > 0);
456
+
457
+ const div = document.createElement('div');
458
+
459
+ // ── Summary stats ────────────────────────────────────────────────────────
460
+ div.innerHTML = `
461
+ <div class="perf-stat">
462
+ <span class="stat-label">Total renders</span>
463
+ <span class="stat-value">${totalRenders}</span>
464
+ </div>
465
+ <div class="perf-stat">
466
+ <span class="stat-label">Signal updates</span>
467
+ <span class="stat-value">${totalSigUpdates}</span>
468
+ </div>
469
+ <div class="perf-stat">
470
+ <span class="stat-label">Components tracked</span>
471
+ <span class="stat-value">${_components.size}</span>
472
+ </div>
473
+ `;
474
+
475
+ // ── Clear button ─────────────────────────────────────────────────────────
476
+ const clearBtn = document.createElement('button');
477
+ clearBtn.className = 'perf-clear-btn';
478
+ clearBtn.textContent = '🗑 Clear perf data';
479
+ clearBtn.addEventListener('click', () => {
480
+ _devClearPerfData();
481
+ this._renderBody();
482
+ });
483
+ div.appendChild(clearBtn);
484
+
485
+ // ── Render timing section ─────────────────────────────────────────────────
486
+ const topByRenders = [..._components.values()]
487
+ .sort((a, b) => b.renderCount - a.renderCount)
488
+ .slice(0, 10);
489
+
490
+ if (topByRenders.length > 0) {
491
+ const label1 = document.createElement('p');
492
+ label1.className = 'section-label';
493
+ label1.textContent = hasTiming ? 'Render counts + avg time (ms)' : 'Most-rendered components';
494
+ div.appendChild(label1);
495
+
496
+ const ul = document.createElement('ul');
497
+ ul.className = 'list';
498
+ const maxCount = topByRenders[0].renderCount || 1;
499
+
500
+ for (const c of topByRenders) {
501
+ const pct = Math.round((c.renderCount / maxCount) * 100);
502
+ const avgMs = c.renderTimes.length > 0
503
+ ? (c.renderTimes.reduce((a, b) => a + b, 0) / c.renderTimes.length).toFixed(2)
504
+ : null;
505
+ const maxMs = c.renderTimes.length > 0
506
+ ? Math.max(...c.renderTimes).toFixed(2)
507
+ : null;
508
+
509
+ // Colour-code avg time: green < 4ms, yellow < 16ms, red >= 16ms
510
+ let timeColour = '#a6e3a1';
511
+ if (avgMs !== null) {
512
+ if (avgMs >= 16) timeColour = '#f38ba8';
513
+ else if (avgMs >= 4) timeColour = '#f9e2af';
514
+ }
515
+
516
+ const li = document.createElement('li');
517
+ li.className = 'item';
518
+ li.innerHTML = `
519
+ <span class="item-icon">🔁</span>
520
+ <span class="item-label">${_esc(c.name)}</span>
521
+ <span class="bar-wrap"><span class="bar" style="width:${pct}%"></span></span>
522
+ <span class="item-meta">${c.renderCount}×</span>
523
+ ${avgMs !== null
524
+ ? `<span class="item-ms" style="color:${timeColour}" title="max: ${maxMs}ms">⏱${avgMs}ms</span>`
525
+ : ''}
526
+ `;
527
+ ul.appendChild(li);
528
+ }
529
+ div.appendChild(ul);
530
+ }
531
+
532
+ // ── Signal update frequency ───────────────────────────────────────────────
533
+ const activeSignals = [..._signals.values()]
534
+ .filter(s => s.updateCount > 0)
535
+ .sort((a, b) => b.updateCount - a.updateCount)
536
+ .slice(0, 8);
537
+
538
+ if (activeSignals.length > 0) {
539
+ const label2 = document.createElement('p');
540
+ label2.className = 'section-label';
541
+ label2.textContent = 'Most-updated signals';
542
+ div.appendChild(label2);
543
+
544
+ const ul2 = document.createElement('ul');
545
+ ul2.className = 'list';
546
+ const maxSig = activeSignals[0].updateCount || 1;
547
+
548
+ for (const s of activeSignals) {
549
+ const pct = Math.round((s.updateCount / maxSig) * 100);
550
+ const li = document.createElement('li');
551
+ li.className = 'item';
552
+ li.innerHTML = `
553
+ <span class="item-icon">⚡</span>
554
+ <span class="item-label">${_esc(s.label)}</span>
555
+ <span class="bar-wrap"><span class="bar bar--signal" style="width:${pct}%"></span></span>
556
+ <span class="item-meta">${s.updateCount}×</span>
557
+ `;
558
+ ul2.appendChild(li);
559
+ }
560
+ div.appendChild(ul2);
561
+ }
562
+
563
+ // ── Rolling timeline sparkline ────────────────────────────────────────────
564
+ if (_perfTimeline.length > 1) {
565
+ const label3 = document.createElement('p');
566
+ label3.className = 'section-label';
567
+ label3.textContent = `Render timeline (last ${_perfTimeline.length})`;
568
+ div.appendChild(label3);
569
+
570
+ // Mini SVG sparkline — last 60 ticks
571
+ const recent = _perfTimeline.slice(-60);
572
+ const maxDur = Math.max(...recent.map(t => t.duration), 1);
573
+ const W = 328, H = 40, pad = 2;
574
+ const pts = recent.map((t, i) => {
575
+ const x = pad + (i / (recent.length - 1)) * (W - pad * 2);
576
+ const y = H - pad - ((t.duration / maxDur) * (H - pad * 2));
577
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
578
+ }).join(' ');
579
+
580
+ const sparkDiv = document.createElement('div');
581
+ sparkDiv.className = 'sparkline-wrap';
582
+ sparkDiv.innerHTML = `
583
+ <svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:${H}px">
584
+ <polyline points="${_esc(pts)}" fill="none" stroke="#7c3aed" stroke-width="1.5" stroke-linejoin="round"/>
585
+ <line x1="${pad}" y1="${H - pad}" x2="${W - pad}" y2="${H - pad}" stroke="#313244" stroke-width="1"/>
586
+ </svg>
587
+ <div class="spark-labels">
588
+ <span>0ms</span>
589
+ <span>${maxDur.toFixed(1)}ms peak</span>
590
+ </div>
591
+ `;
592
+ div.appendChild(sparkDiv);
593
+ } else if (_components.size > 0 && !hasTiming) {
594
+ const hint = document.createElement('p');
595
+ hint.className = 'perf-hint';
596
+ hint.innerHTML = `Timing data appears once <code>_devRenderStart/End</code><br>is wired in the runtime.`;
597
+ div.appendChild(hint);
598
+ }
599
+
600
+ this._content.appendChild(div);
601
+ }
602
+ }
603
+
604
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
605
+
606
+ function _positionStyle(pos) {
607
+ const base = { position: 'fixed', zIndex: '2147483647', fontFamily: 'monospace' };
608
+ switch (pos) {
609
+ case 'tl': return { ...base, top: '16px', left: '16px' };
610
+ case 'tr': return { ...base, top: '16px', right: '16px' };
611
+ case 'bl': return { ...base, bottom: '16px', left: '16px' };
612
+ case 'br':
613
+ default: return { ...base, bottom: '16px', right: '16px' };
614
+ }
615
+ }
616
+
617
+ function _esc(str) {
618
+ return String(str)
619
+ .replace(/&/g, '&amp;')
620
+ .replace(/</g, '&lt;')
621
+ .replace(/>/g, '&gt;')
622
+ .replace(/"/g, '&quot;');
623
+ }
624
+
625
+ function _stringify(val) {
626
+ if (val === undefined) return 'undefined';
627
+ if (val === null) return 'null';
628
+ try {
629
+ const str = JSON.stringify(val);
630
+ return str.length > 80 ? str.slice(0, 77) + '…' : str;
631
+ } catch {
632
+ return String(val);
633
+ }
634
+ }
635
+
636
+ // ─── Embedded CSS ─────────────────────────────────────────────────────────────
637
+
638
+ const _CSS = `
639
+ :host { all: initial; }
640
+
641
+ .panel {
642
+ display: flex;
643
+ flex-direction: column;
644
+ align-items: flex-end;
645
+ }
646
+
647
+ .toggle-btn {
648
+ width: 36px; height: 36px;
649
+ border-radius: 50%;
650
+ border: none;
651
+ background: #7c3aed;
652
+ color: #fff;
653
+ font-size: 18px;
654
+ cursor: pointer;
655
+ box-shadow: 0 2px 8px rgba(0,0,0,.4);
656
+ transition: transform .15s;
657
+ }
658
+ .toggle-btn:hover { transform: scale(1.1); }
659
+
660
+ .body {
661
+ display: none;
662
+ flex-direction: column;
663
+ width: 360px;
664
+ max-height: 520px;
665
+ background: #1e1e2e;
666
+ color: #cdd6f4;
667
+ border-radius: 10px;
668
+ box-shadow: 0 8px 32px rgba(0,0,0,.5);
669
+ margin-bottom: 8px;
670
+ overflow: hidden;
671
+ font-size: 12px;
672
+ line-height: 1.5;
673
+ }
674
+ .panel[data-open="true"] .body { display: flex; }
675
+
676
+ .header {
677
+ display: flex;
678
+ align-items: center;
679
+ padding: 8px 10px;
680
+ background: #181825;
681
+ gap: 8px;
682
+ flex-shrink: 0;
683
+ }
684
+ .title { font-weight: 700; color: #a6e3a1; flex: 0 0 auto; }
685
+
686
+ .tabs { display: flex; gap: 4px; flex: 1; }
687
+ .tabs button {
688
+ background: none; border: none; color: #6c7086;
689
+ cursor: pointer; padding: 2px 8px; border-radius: 4px;
690
+ font-size: 11px;
691
+ }
692
+ .tabs button.active { background: #313244; color: #cdd6f4; }
693
+ .tabs button:hover:not(.active) { color: #cdd6f4; }
694
+
695
+ .close-btn {
696
+ background: none; border: none; color: #6c7086;
697
+ cursor: pointer; font-size: 14px; padding: 2px 4px;
698
+ }
699
+ .close-btn:hover { color: #f38ba8; }
700
+
701
+ .content { overflow-y: auto; flex: 1; padding: 8px; }
702
+ .content::-webkit-scrollbar { width: 4px; }
703
+ .content::-webkit-scrollbar-thumb { background: #45475a; border-radius: 2px; }
704
+
705
+ .empty {
706
+ color: #6c7086; text-align: center;
707
+ padding: 24px 16px; margin: 0;
708
+ font-size: 12px; line-height: 1.8;
709
+ }
710
+ .empty code { background: #313244; padding: 1px 5px; border-radius: 3px; }
711
+
712
+ .list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
713
+ .item {
714
+ display: flex; align-items: center; gap: 6px;
715
+ padding: 5px 8px; border-radius: 6px;
716
+ background: #181825;
717
+ }
718
+ .item-icon { flex: 0 0 auto; }
719
+ .item-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
720
+ .item-meta { color: #6c7086; flex: 0 0 auto; font-size: 10px; }
721
+ .item-value {
722
+ color: #fab387; flex: 0 0 auto; max-width: 120px;
723
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px;
724
+ }
725
+
726
+ .perf-stat {
727
+ display: flex; justify-content: space-between;
728
+ padding: 6px 8px; background: #181825;
729
+ border-radius: 6px; margin-bottom: 4px;
730
+ }
731
+ .stat-label { color: #a6adc8; }
732
+ .stat-value { font-weight: 700; color: #89b4fa; }
733
+
734
+ .section-label { margin: 12px 0 6px; color: #a6adc8; font-size: 10px; text-transform: uppercase; }
735
+
736
+ .bar-wrap { flex: 1; height: 6px; background: #313244; border-radius: 3px; overflow: hidden; margin: 0 6px; }
737
+ .bar { display: block; height: 100%; background: #7c3aed; border-radius: 3px; }
738
+ .bar--signal { background: #89b4fa; }
739
+
740
+ .item-ms {
741
+ flex: 0 0 auto; font-size: 10px; font-weight: 600;
742
+ white-space: nowrap; padding-left: 2px;
743
+ }
744
+
745
+ .perf-clear-btn {
746
+ display: block; width: 100%;
747
+ background: #313244; border: none; color: #a6adc8;
748
+ border-radius: 6px; padding: 5px 10px; margin: 8px 0;
749
+ cursor: pointer; font-size: 11px; text-align: left;
750
+ }
751
+ .perf-clear-btn:hover { background: #45475a; color: #cdd6f4; }
752
+
753
+ .sparkline-wrap { margin-top: 6px; }
754
+ .spark-labels {
755
+ display: flex; justify-content: space-between;
756
+ color: #585b70; font-size: 9px; margin-top: 2px;
757
+ }
758
+
759
+ .perf-hint {
760
+ color: #585b70; font-size: 11px; text-align: center;
761
+ padding: 12px 8px; margin: 8px 0;
762
+ background: #181825; border-radius: 6px; line-height: 1.7;
763
+ }
764
+ .perf-hint code { background: #313244; padding: 1px 4px; border-radius: 3px; }
765
+ `;