@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.
- package/README.md +178 -0
- package/package.json +168 -0
- package/src/analyze.js +534 -0
- package/src/async-state.js +555 -0
- package/src/bundle-runtime.js +35 -0
- package/src/clarity-bundle.js +332 -0
- package/src/clarity-test.js +622 -0
- package/src/cli.js +453 -0
- package/src/codegen.js +1934 -0
- package/src/dev-server.js +362 -0
- package/src/devtools.js +765 -0
- package/src/edge.js +606 -0
- package/src/error-overlay.js +535 -0
- package/src/file-conventions.js +472 -0
- package/src/font.js +513 -0
- package/src/game-loop.js +106 -0
- package/src/head.js +393 -0
- package/src/hydrate.js +292 -0
- package/src/i18n.js +403 -0
- package/src/image.js +352 -0
- package/src/index.js +193 -0
- package/src/islands.js +284 -0
- package/src/isr.js +306 -0
- package/src/layout.js +342 -0
- package/src/lexer.js +572 -0
- package/src/linter.js +547 -0
- package/src/pages-router.js +229 -0
- package/src/parser.js +1108 -0
- package/src/router.js +732 -0
- package/src/runtime.js +1465 -0
- package/src/scoped-css.js +641 -0
- package/src/server-actions.js +439 -0
- package/src/server-data.js +225 -0
- package/src/sourcemap.js +130 -0
- package/src/ssg.js +310 -0
- package/src/ssr.js +621 -0
- package/src/store.js +276 -0
- package/src/transitions.js +438 -0
- package/src/ts-plugin.js +613 -0
- package/src/typegen.js +240 -0
- package/src/vite-plugin.js +447 -0
- package/types/index.d.ts +366 -0
package/src/devtools.js
ADDED
|
@@ -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, '&')
|
|
620
|
+
.replace(/</g, '<')
|
|
621
|
+
.replace(/>/g, '>')
|
|
622
|
+
.replace(/"/g, '"');
|
|
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
|
+
`;
|