@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,535 @@
1
+ /**
2
+ * Clarity.js — Developer Error Overlay
3
+ *
4
+ * A fullscreen Shadow DOM overlay that surfaces:
5
+ * - Compiler errors (from the Clarity Vite plugin)
6
+ * - Runtime errors (window.onerror / unhandledrejection)
7
+ * - Manually reported errors (_overlayShowError)
8
+ *
9
+ * The overlay is completely isolated from page styles via Shadow DOM and
10
+ * renders zero bytes in production builds (guard with import.meta.env.DEV).
11
+ *
12
+ * Integration:
13
+ * import { initErrorOverlay } from '@ozsarman/clarityjs/error-overlay';
14
+ * if (import.meta.env.DEV) initErrorOverlay();
15
+ *
16
+ * Vite plugin usage:
17
+ * The clarity Vite plugin (vite-plugin.js) calls
18
+ * `_overlayShowError({ message, file, line, col, frame, type })` over the
19
+ * Vite HMR WebSocket when a .clarity file fails to compile.
20
+ *
21
+ * Author: Claude (Anthropic) + Özdemir Sarman
22
+ */
23
+
24
+ // ─── Public surface ───────────────────────────────────────────────────────────
25
+
26
+ let _overlay = null;
27
+
28
+ /**
29
+ * Mount the error overlay. Safe to call multiple times — only one instance
30
+ * is created.
31
+ *
32
+ * @param {object} [options]
33
+ * @param {boolean} [options.catchRuntime=true] Listen for window.onerror / unhandledrejection
34
+ * @param {boolean} [options.catchHMR=true] Listen for Vite HMR error events
35
+ * @returns {{ dismiss: () => void, show: _overlayShowError }}
36
+ */
37
+ export function initErrorOverlay({ catchRuntime = true, catchHMR = true } = {}) {
38
+ if (typeof document === 'undefined') return { dismiss: () => {}, show: () => {} };
39
+ if (_overlay) return { dismiss: () => _overlay?.dismiss(), show: _overlayShowError };
40
+
41
+ _overlay = new ErrorOverlay();
42
+
43
+ // ── Runtime error listeners ───────────────────────────────────────────────
44
+ if (catchRuntime) {
45
+ _overlay._stopRuntime = _listenRuntime();
46
+ }
47
+
48
+ // ── Vite HMR listeners ────────────────────────────────────────────────────
49
+ if (catchHMR) {
50
+ _overlay._stopHMR = _listenHMR();
51
+ }
52
+
53
+ return {
54
+ dismiss: () => _overlay?.dismiss(),
55
+ show: _overlayShowError,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Programmatically show an error in the overlay.
61
+ *
62
+ * @param {object} err
63
+ * @param {string} err.message - Human-readable error message
64
+ * @param {'compiler'|'runtime'|'network'} [err.type='runtime']
65
+ * @param {string} [err.file] - Source file path
66
+ * @param {number} [err.line] - 1-based line number
67
+ * @param {number} [err.col] - 1-based column number
68
+ * @param {string} [err.frame] - Code frame snippet (pre-formatted)
69
+ * @param {string} [err.stack] - Error stack trace string
70
+ * @param {string} [err.plugin] - Plugin/module name that produced the error
71
+ */
72
+ export function _overlayShowError(err) {
73
+ if (!_overlay) return;
74
+ _overlay.push(err);
75
+ }
76
+
77
+ /**
78
+ * Dismiss the overlay (same as clicking the close button).
79
+ */
80
+ export function _overlayDismiss() {
81
+ _overlay?.dismiss();
82
+ }
83
+
84
+ // ─── Runtime listener ─────────────────────────────────────────────────────────
85
+
86
+ function _listenRuntime() {
87
+ function onError(event) {
88
+ const e = event.error ?? event;
89
+ _overlayShowError({
90
+ type: 'runtime',
91
+ message: e?.message ?? String(event.message ?? event),
92
+ file: event.filename ?? e?.fileName,
93
+ line: event.lineno ?? e?.lineNumber,
94
+ col: event.colno ?? e?.columnNumber,
95
+ stack: e?.stack,
96
+ });
97
+ // Don't swallow — let the browser console also log it
98
+ }
99
+
100
+ function onUnhandled(event) {
101
+ const reason = event.reason;
102
+ _overlayShowError({
103
+ type: 'runtime',
104
+ message: reason instanceof Error ? reason.message : String(reason ?? 'Unhandled Promise rejection'),
105
+ stack: reason instanceof Error ? reason.stack : undefined,
106
+ });
107
+ }
108
+
109
+ window.addEventListener('error', onError, true);
110
+ window.addEventListener('unhandledrejection', onUnhandled, true);
111
+
112
+ return () => {
113
+ window.removeEventListener('error', onError, true);
114
+ window.removeEventListener('unhandledrejection', onUnhandled, true);
115
+ };
116
+ }
117
+
118
+ // ─── Vite HMR listener ────────────────────────────────────────────────────────
119
+
120
+ function _listenHMR() {
121
+ // Vite exposes import.meta.hot in dev mode
122
+ if (typeof import.meta === 'undefined' || !import.meta.hot) return () => {};
123
+
124
+ // Vite's built-in error event — fires for plugin transform errors
125
+ import.meta.hot.on('vite:error', (data) => {
126
+ const err = data.err ?? data;
127
+ _overlayShowError({
128
+ type: 'compiler',
129
+ message: err.message ?? String(err),
130
+ file: err.id ?? err.file,
131
+ line: err.loc?.line,
132
+ col: err.loc?.column,
133
+ frame: err.frame,
134
+ plugin: err.plugin,
135
+ stack: err.stack,
136
+ });
137
+ });
138
+
139
+ // Custom Clarity compiler error channel
140
+ import.meta.hot.on('clarity:error', (data) => {
141
+ _overlayShowError({ type: 'compiler', ...data });
142
+ });
143
+
144
+ // Clear overlay when HMR replaces the module successfully
145
+ import.meta.hot.on('vite:afterUpdate', () => {
146
+ _overlay?.clearAll();
147
+ });
148
+
149
+ return () => {}; // Vite HMR listeners can't be removed individually
150
+ }
151
+
152
+ // ─── ErrorOverlay class ───────────────────────────────────────────────────────
153
+
154
+ class ErrorOverlay {
155
+ constructor() {
156
+ /** @type {Array<object>} */
157
+ this._errors = [];
158
+ this._currentIndex = 0;
159
+ this._stopRuntime = null;
160
+ this._stopHMR = null;
161
+
162
+ this.el = document.createElement('div');
163
+ this.el.setAttribute('data-clarity-error-overlay', '');
164
+ Object.assign(this.el.style, {
165
+ position: 'fixed',
166
+ inset: '0',
167
+ zIndex: '2147483647',
168
+ display: 'none',
169
+ });
170
+
171
+ const shadow = this.el.attachShadow({ mode: 'open' });
172
+ const style = document.createElement('style');
173
+ style.textContent = _CSS;
174
+ shadow.appendChild(style);
175
+
176
+ this._root = document.createElement('div');
177
+ this._root.className = 'overlay';
178
+ shadow.appendChild(this._root);
179
+
180
+ document.body.appendChild(this.el);
181
+ this._buildShell();
182
+ }
183
+
184
+ // ── Public ─────────────────────────────────────────────────────────────────
185
+
186
+ push(err) {
187
+ this._errors.push(_normalizeError(err));
188
+ // Jump to the newest error
189
+ this._currentIndex = this._errors.length - 1;
190
+ this.el.style.display = '';
191
+ this._render();
192
+ }
193
+
194
+ dismiss() {
195
+ this.el.style.display = 'none';
196
+ this._errors = [];
197
+ this._currentIndex = 0;
198
+ }
199
+
200
+ clearAll() {
201
+ this.dismiss();
202
+ }
203
+
204
+ dispose() {
205
+ this._stopRuntime?.();
206
+ this._stopHMR?.();
207
+ this.el.remove();
208
+ _overlay = null;
209
+ }
210
+
211
+ // ── Private ────────────────────────────────────────────────────────────────
212
+
213
+ _buildShell() {
214
+ this._root.innerHTML = `
215
+ <div class="backdrop"></div>
216
+ <div class="card">
217
+ <div class="card-header">
218
+ <span class="badge badge--compiler hidden" id="badge-compiler">Compiler</span>
219
+ <span class="badge badge--runtime hidden" id="badge-runtime">Runtime</span>
220
+ <span class="badge badge--network hidden" id="badge-network">Network</span>
221
+ <span class="title" id="err-title">Error</span>
222
+ <div class="nav hidden" id="nav">
223
+ <button id="btn-prev" title="Previous error">‹</button>
224
+ <span id="nav-counter"></span>
225
+ <button id="btn-next" title="Next error">›</button>
226
+ </div>
227
+ <button class="close-btn" id="btn-close" title="Dismiss (Escape)">✕</button>
228
+ </div>
229
+
230
+ <div class="card-body">
231
+ <pre class="message" id="err-message"></pre>
232
+ <div class="location hidden" id="err-location"></div>
233
+ <pre class="frame hidden" id="err-frame"></pre>
234
+ <details class="stack-details hidden" id="err-stack-wrap">
235
+ <summary>Stack trace</summary>
236
+ <pre class="stack" id="err-stack"></pre>
237
+ </details>
238
+ </div>
239
+
240
+ <div class="card-footer">
241
+ <span class="hint">Press <kbd>Escape</kbd> to dismiss · errors also in the browser console</span>
242
+ </div>
243
+ </div>
244
+ `;
245
+
246
+ // Event bindings
247
+ const $ = id => this._root.querySelector(`#${id}`);
248
+
249
+ $('btn-close').addEventListener('click', () => this.dismiss());
250
+ $('btn-prev').addEventListener('click', () => { this._currentIndex = Math.max(0, this._currentIndex - 1); this._render(); });
251
+ $('btn-next').addEventListener('click', () => { this._currentIndex = Math.min(this._errors.length - 1, this._currentIndex + 1); this._render(); });
252
+ this._root.querySelector('.backdrop').addEventListener('click', () => this.dismiss());
253
+
254
+ // Keyboard: Escape to dismiss, arrow keys to navigate
255
+ this._onKey = (e) => {
256
+ if (e.key === 'Escape') this.dismiss();
257
+ if (e.key === 'ArrowLeft') { this._currentIndex = Math.max(0, this._currentIndex - 1); this._render(); }
258
+ if (e.key === 'ArrowRight') { this._currentIndex = Math.min(this._errors.length - 1, this._currentIndex + 1); this._render(); }
259
+ };
260
+ window.addEventListener('keydown', this._onKey);
261
+ }
262
+
263
+ _render() {
264
+ if (this._errors.length === 0) { this.dismiss(); return; }
265
+
266
+ const err = this._errors[this._currentIndex];
267
+ const $ = id => this._root.querySelector(`#${id}`);
268
+
269
+ // Badges
270
+ ['compiler', 'runtime', 'network'].forEach(t => {
271
+ $(`badge-${t}`).classList.toggle('hidden', err.type !== t);
272
+ });
273
+
274
+ // Title
275
+ $('err-title').textContent = err.title ?? _titleForType(err.type);
276
+
277
+ // Message
278
+ $('err-message').textContent = err.message;
279
+
280
+ // Location
281
+ const locEl = $('err-location');
282
+ if (err.file || err.line) {
283
+ locEl.classList.remove('hidden');
284
+ locEl.innerHTML = _buildLocation(err);
285
+ } else {
286
+ locEl.classList.add('hidden');
287
+ }
288
+
289
+ // Code frame
290
+ const frameEl = $('err-frame');
291
+ if (err.frame) {
292
+ frameEl.classList.remove('hidden');
293
+ frameEl.innerHTML = _highlightFrame(err.frame);
294
+ } else {
295
+ frameEl.classList.add('hidden');
296
+ }
297
+
298
+ // Stack
299
+ const stackWrap = $('err-stack-wrap');
300
+ if (err.stack) {
301
+ stackWrap.classList.remove('hidden');
302
+ $('err-stack').textContent = _cleanStack(err.stack);
303
+ } else {
304
+ stackWrap.classList.add('hidden');
305
+ }
306
+
307
+ // Nav
308
+ const navEl = $('nav');
309
+ if (this._errors.length > 1) {
310
+ navEl.classList.remove('hidden');
311
+ $('nav-counter').textContent = `${this._currentIndex + 1} / ${this._errors.length}`;
312
+ $('btn-prev').disabled = this._currentIndex === 0;
313
+ $('btn-next').disabled = this._currentIndex === this._errors.length - 1;
314
+ } else {
315
+ navEl.classList.add('hidden');
316
+ }
317
+ }
318
+ }
319
+
320
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
321
+
322
+ function _normalizeError(err) {
323
+ if (err instanceof Error) {
324
+ return { type: 'runtime', message: err.message, stack: err.stack };
325
+ }
326
+ return {
327
+ type: err.type ?? 'runtime',
328
+ message: err.message ?? String(err),
329
+ file: err.file,
330
+ line: err.line,
331
+ col: err.col,
332
+ frame: err.frame,
333
+ stack: err.stack,
334
+ plugin: err.plugin,
335
+ title: err.title,
336
+ };
337
+ }
338
+
339
+ function _titleForType(type) {
340
+ switch (type) {
341
+ case 'compiler': return 'Compiler Error';
342
+ case 'network': return 'Network Error';
343
+ default: return 'Runtime Error';
344
+ }
345
+ }
346
+
347
+ function _buildLocation({ file, line, col, plugin }) {
348
+ let parts = [];
349
+ if (file) parts.push(`<span class="loc-file">${_esc(file)}</span>`);
350
+ if (line) {
351
+ let pos = `:${line}`;
352
+ if (col) pos += `:${col}`;
353
+ parts.push(`<span class="loc-pos">${_esc(pos)}</span>`);
354
+ }
355
+ if (plugin) parts.push(`<span class="loc-plugin">via ${_esc(plugin)}</span>`);
356
+ return parts.join('');
357
+ }
358
+
359
+ /**
360
+ * Highlight the error pointer line (the one with `^`) in a code frame.
361
+ * Lines containing only `^` characters are wrapped in a <mark> span.
362
+ */
363
+ function _highlightFrame(frame) {
364
+ return frame
365
+ .split('\n')
366
+ .map(line => {
367
+ const safe = _esc(line);
368
+ if (/^\s*\^+\s*$/.test(line)) return `<mark>${safe}</mark>`;
369
+ if (/^\s*\|\s*$/.test(line)) return `<span class="frame-pipe">${safe}</span>`;
370
+ return safe;
371
+ })
372
+ .join('\n');
373
+ }
374
+
375
+ /** Remove internal Clarity frames from a stack trace. */
376
+ function _cleanStack(stack) {
377
+ return stack
378
+ .split('\n')
379
+ .filter(l => !l.includes('@ozsarman/clarityjs/src/devtools'))
380
+ .join('\n');
381
+ }
382
+
383
+ function _esc(str) {
384
+ return String(str ?? '')
385
+ .replace(/&/g, '&amp;')
386
+ .replace(/</g, '&lt;')
387
+ .replace(/>/g, '&gt;')
388
+ .replace(/"/g, '&quot;');
389
+ }
390
+
391
+ // ─── Embedded CSS ─────────────────────────────────────────────────────────────
392
+
393
+ const _CSS = `
394
+ :host { all: initial; font-family: monospace; }
395
+
396
+ .overlay {
397
+ position: fixed; inset: 0;
398
+ display: flex; align-items: center; justify-content: center;
399
+ padding: 24px;
400
+ box-sizing: border-box;
401
+ }
402
+
403
+ .backdrop {
404
+ position: absolute; inset: 0;
405
+ background: rgba(0,0,0,.65);
406
+ backdrop-filter: blur(2px);
407
+ }
408
+
409
+ .card {
410
+ position: relative;
411
+ width: 100%; max-width: 780px;
412
+ max-height: 90vh;
413
+ background: #1e1e2e;
414
+ border: 1px solid #f38ba8;
415
+ border-radius: 12px;
416
+ box-shadow: 0 20px 60px rgba(0,0,0,.6);
417
+ overflow: hidden;
418
+ display: flex;
419
+ flex-direction: column;
420
+ color: #cdd6f4;
421
+ font-size: 13px;
422
+ line-height: 1.5;
423
+ }
424
+
425
+ /* ── Header ── */
426
+ .card-header {
427
+ display: flex; align-items: center; gap: 8px;
428
+ padding: 12px 16px;
429
+ background: #181825;
430
+ border-bottom: 1px solid #313244;
431
+ flex-shrink: 0;
432
+ }
433
+
434
+ .badge {
435
+ padding: 2px 8px; border-radius: 20px;
436
+ font-size: 10px; font-weight: 700; text-transform: uppercase;
437
+ letter-spacing: .05em;
438
+ }
439
+ .badge--compiler { background: #f38ba8; color: #1e1e2e; }
440
+ .badge--runtime { background: #fab387; color: #1e1e2e; }
441
+ .badge--network { background: #89b4fa; color: #1e1e2e; }
442
+ .hidden { display: none !important; }
443
+
444
+ .title { font-weight: 700; color: #f38ba8; font-size: 14px; flex: 1; }
445
+
446
+ .nav { display: flex; align-items: center; gap: 4px; color: #a6adc8; font-size: 12px; }
447
+ .nav button {
448
+ background: #313244; border: none; color: #cdd6f4;
449
+ width: 22px; height: 22px; border-radius: 4px; cursor: pointer;
450
+ font-size: 14px; display: flex; align-items: center; justify-content: center;
451
+ }
452
+ .nav button:disabled { opacity: .4; cursor: default; }
453
+ .nav button:hover:not(:disabled) { background: #45475a; }
454
+
455
+ .close-btn {
456
+ background: none; border: none; color: #6c7086;
457
+ cursor: pointer; font-size: 16px; padding: 2px 6px;
458
+ border-radius: 4px; line-height: 1;
459
+ }
460
+ .close-btn:hover { background: #313244; color: #f38ba8; }
461
+
462
+ /* ── Body ── */
463
+ .card-body {
464
+ overflow-y: auto; flex: 1; padding: 16px;
465
+ display: flex; flex-direction: column; gap: 12px;
466
+ }
467
+ .card-body::-webkit-scrollbar { width: 4px; }
468
+ .card-body::-webkit-scrollbar-thumb { background: #45475a; border-radius: 2px; }
469
+
470
+ .message {
471
+ margin: 0;
472
+ color: #f38ba8;
473
+ font-size: 14px; font-weight: 600;
474
+ white-space: pre-wrap; word-break: break-word;
475
+ }
476
+
477
+ .location {
478
+ display: flex; gap: 8px; flex-wrap: wrap;
479
+ font-size: 11px;
480
+ }
481
+ .loc-file { color: #89b4fa; }
482
+ .loc-pos { color: #a6e3a1; }
483
+ .loc-plugin { color: #cba6f7; }
484
+
485
+ .frame {
486
+ margin: 0;
487
+ padding: 12px 14px;
488
+ background: #181825;
489
+ border: 1px solid #313244;
490
+ border-left: 3px solid #f38ba8;
491
+ border-radius: 6px;
492
+ font-size: 12px;
493
+ overflow-x: auto;
494
+ white-space: pre;
495
+ color: #cdd6f4;
496
+ tab-size: 2;
497
+ }
498
+ .frame mark {
499
+ background: none; color: #f38ba8; font-weight: 700;
500
+ }
501
+ .frame .frame-pipe { color: #585b70; }
502
+
503
+ .stack-details { }
504
+ .stack-details summary {
505
+ cursor: pointer; color: #6c7086; font-size: 11px;
506
+ padding: 2px 0; user-select: none;
507
+ }
508
+ .stack-details summary:hover { color: #a6adc8; }
509
+ .stack-details[open] summary { margin-bottom: 8px; }
510
+
511
+ .stack {
512
+ margin: 0;
513
+ padding: 10px 12px;
514
+ background: #181825;
515
+ border-radius: 6px;
516
+ font-size: 11px;
517
+ color: #6c7086;
518
+ overflow-x: auto;
519
+ white-space: pre;
520
+ }
521
+
522
+ /* ── Footer ── */
523
+ .card-footer {
524
+ padding: 8px 16px;
525
+ background: #181825;
526
+ border-top: 1px solid #313244;
527
+ flex-shrink: 0;
528
+ }
529
+ .hint { color: #585b70; font-size: 10px; }
530
+ kbd {
531
+ background: #313244; border: 1px solid #45475a;
532
+ border-radius: 3px; padding: 1px 5px; font-family: inherit;
533
+ font-size: 10px;
534
+ }
535
+ `;