@ytspar/devbar 0.0.1 → 1.0.0-canary.3007fc6

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,2110 @@
1
+ /**
2
+ * GlobalDevBar - Vanilla JS implementation
3
+ *
4
+ * A development toolbar that displays breakpoint info, performance stats,
5
+ * console error/warning counts, and provides screenshot capabilities via Sweetlink.
6
+ *
7
+ * This is a vanilla JS replacement for the React-based GlobalDevBar component
8
+ * to avoid React dependency conflicts in host applications.
9
+ */
10
+ import * as html2canvasModule from 'html2canvas-pro';
11
+ import { BASE_RECONNECT_DELAY_MS, BUTTON_COLORS, CATEGORY_COLORS, CLIPBOARD_NOTIFICATION_MS, COLORS, DESIGN_REVIEW_NOTIFICATION_MS, DEVBAR_SCREENSHOT_QUALITY, FONT_MONO, MAX_CONSOLE_LOGS, MAX_RECONNECT_ATTEMPTS, MAX_RECONNECT_DELAY_MS, SCREENSHOT_BLUR_DELAY_MS, SCREENSHOT_NOTIFICATION_MS, SCREENSHOT_SCALE, TAILWIND_BREAKPOINTS, TOOLTIP_STYLES, WS_PORT, } from './constants.js';
12
+ import { extractDocumentOutline, outlineToMarkdown } from './outline.js';
13
+ import { extractPageSchema, schemaToMarkdown } from './schema.js';
14
+ import { createEmptyMessage, createInfoBox, createModalBox, createModalContent, createModalHeader, createModalOverlay, createStyledButton, createSvgIcon, getButtonStyles, } from './ui/index.js';
15
+ import { canvasToDataUrl, copyCanvasToClipboard, delay, formatArgs, prepareForCapture, } from './utils.js';
16
+ const html2canvas = (html2canvasModule.default ??
17
+ html2canvasModule);
18
+ const earlyConsoleCapture = (() => {
19
+ const ssrFallback = {
20
+ errorCount: 0,
21
+ warningCount: 0,
22
+ logs: [],
23
+ originalConsole: null,
24
+ isPatched: false,
25
+ };
26
+ // Skip on server-side rendering
27
+ if (typeof window === 'undefined')
28
+ return ssrFallback;
29
+ const capture = {
30
+ errorCount: 0,
31
+ warningCount: 0,
32
+ logs: [],
33
+ originalConsole: {
34
+ log: console.log,
35
+ error: console.error,
36
+ warn: console.warn,
37
+ info: console.info,
38
+ },
39
+ isPatched: false,
40
+ };
41
+ const captureLog = (level, args) => {
42
+ capture.logs.push({ level, message: formatArgs(args), timestamp: Date.now() });
43
+ if (capture.logs.length > MAX_CONSOLE_LOGS)
44
+ capture.logs = capture.logs.slice(-MAX_CONSOLE_LOGS);
45
+ };
46
+ // Patch console immediately
47
+ if (!capture.isPatched && capture.originalConsole) {
48
+ console.log = (...args) => {
49
+ captureLog('log', args);
50
+ capture.originalConsole.log(...args);
51
+ };
52
+ console.error = (...args) => {
53
+ captureLog('error', args);
54
+ capture.errorCount++;
55
+ capture.originalConsole.error(...args);
56
+ };
57
+ console.warn = (...args) => {
58
+ captureLog('warn', args);
59
+ capture.warningCount++;
60
+ capture.originalConsole.warn(...args);
61
+ };
62
+ console.info = (...args) => {
63
+ captureLog('info', args);
64
+ capture.originalConsole.info(...args);
65
+ };
66
+ capture.isPatched = true;
67
+ }
68
+ return capture;
69
+ })();
70
+ // ============================================================================
71
+ // GlobalDevBar Class
72
+ // ============================================================================
73
+ export class GlobalDevBar {
74
+ constructor(options = {}) {
75
+ this.container = null;
76
+ this.ws = null;
77
+ this.consoleLogs = [];
78
+ this.sweetlinkConnected = false;
79
+ this.collapsed = false;
80
+ this.capturing = false;
81
+ this.copiedToClipboard = false;
82
+ this.copiedPath = false;
83
+ this.lastScreenshot = null;
84
+ this.designReviewInProgress = false;
85
+ this.lastDesignReview = null;
86
+ this.designReviewError = null;
87
+ this.showDesignReviewConfirm = false;
88
+ this.apiKeyStatus = null;
89
+ this.lastOutline = null;
90
+ this.lastSchema = null;
91
+ this.consoleFilter = null;
92
+ // Modal states
93
+ this.showOutlineModal = false;
94
+ this.showSchemaModal = false;
95
+ this.breakpointInfo = null;
96
+ this.perfStats = null;
97
+ this.lcpValue = null;
98
+ this.reconnectAttempts = 0;
99
+ // Track the position of the connection indicator dot for smooth collapse
100
+ this.lastDotPosition = null;
101
+ this.reconnectTimeout = null;
102
+ this.screenshotTimeout = null;
103
+ this.copiedPathTimeout = null;
104
+ this.designReviewTimeout = null;
105
+ this.designReviewErrorTimeout = null;
106
+ this.outlineTimeout = null;
107
+ this.schemaTimeout = null;
108
+ this.resizeHandler = null;
109
+ this.keydownHandler = null;
110
+ this.fcpObserver = null;
111
+ this.lcpObserver = null;
112
+ this.destroyed = false;
113
+ // Overlay element for modals
114
+ this.overlayElement = null;
115
+ this.options = {
116
+ position: options.position ?? 'bottom-left',
117
+ accentColor: options.accentColor ?? COLORS.primary,
118
+ showMetrics: {
119
+ breakpoint: options.showMetrics?.breakpoint ?? true,
120
+ fcp: options.showMetrics?.fcp ?? true,
121
+ lcp: options.showMetrics?.lcp ?? true,
122
+ pageSize: options.showMetrics?.pageSize ?? true,
123
+ },
124
+ showScreenshot: options.showScreenshot ?? true,
125
+ showConsoleBadges: options.showConsoleBadges ?? true,
126
+ showTooltips: options.showTooltips ?? true,
127
+ sizeOverrides: options.sizeOverrides,
128
+ };
129
+ }
130
+ /**
131
+ * Get tooltip class name(s) if tooltips are enabled, otherwise empty string
132
+ */
133
+ tooltipClass(direction = 'left', ...additionalClasses) {
134
+ if (!this.options.showTooltips) {
135
+ return additionalClasses.join(' ');
136
+ }
137
+ return ['devbar-tooltip', `devbar-tooltip-${direction}`, ...additionalClasses].join(' ');
138
+ }
139
+ /**
140
+ * Get current error and warning counts from the log array
141
+ */
142
+ getLogCounts() {
143
+ const logs = earlyConsoleCapture.logs;
144
+ let errorCount = 0;
145
+ let warningCount = 0;
146
+ for (const log of logs) {
147
+ if (log.level === 'error')
148
+ errorCount++;
149
+ else if (log.level === 'warn')
150
+ warningCount++;
151
+ }
152
+ return { errorCount, warningCount };
153
+ }
154
+ /**
155
+ * Create a collapsed count badge (used for error/warning counts in minimized state)
156
+ */
157
+ createCollapsedBadge(count, bgColor, rightPos) {
158
+ const badge = document.createElement('span');
159
+ Object.assign(badge.style, {
160
+ position: 'absolute',
161
+ top: '-6px',
162
+ right: rightPos,
163
+ minWidth: '16px',
164
+ height: '16px',
165
+ padding: '0 4px',
166
+ borderRadius: '9999px',
167
+ backgroundColor: bgColor,
168
+ color: '#fff',
169
+ fontSize: '0.5625rem',
170
+ fontWeight: '600',
171
+ display: 'flex',
172
+ alignItems: 'center',
173
+ justifyContent: 'center',
174
+ });
175
+ badge.textContent = count > 99 ? '!' : String(count);
176
+ return badge;
177
+ }
178
+ // ============================================================================
179
+ // Static Methods for Custom Controls
180
+ // ============================================================================
181
+ /**
182
+ * Register a custom control to be displayed in the devbar
183
+ */
184
+ static registerControl(control) {
185
+ // Remove existing control with same ID
186
+ GlobalDevBar.customControls = GlobalDevBar.customControls.filter((c) => c.id !== control.id);
187
+ GlobalDevBar.customControls.push(control);
188
+ // Trigger re-render of all instances
189
+ const instance = getGlobalInstance();
190
+ if (instance) {
191
+ instance.render();
192
+ }
193
+ }
194
+ /**
195
+ * Unregister a custom control by ID
196
+ */
197
+ static unregisterControl(id) {
198
+ GlobalDevBar.customControls = GlobalDevBar.customControls.filter((c) => c.id !== id);
199
+ // Trigger re-render of all instances
200
+ const instance = getGlobalInstance();
201
+ if (instance) {
202
+ instance.render();
203
+ }
204
+ }
205
+ /**
206
+ * Get all registered custom controls
207
+ */
208
+ static getControls() {
209
+ return [...GlobalDevBar.customControls];
210
+ }
211
+ /**
212
+ * Clear all custom controls
213
+ */
214
+ static clearControls() {
215
+ GlobalDevBar.customControls = [];
216
+ const instance = getGlobalInstance();
217
+ if (instance) {
218
+ instance.render();
219
+ }
220
+ }
221
+ /**
222
+ * Initialize and mount the devbar
223
+ */
224
+ init() {
225
+ if (typeof window === 'undefined')
226
+ return;
227
+ if (this.destroyed)
228
+ return;
229
+ // Inject tooltip styles
230
+ this.injectStyles();
231
+ // Copy early captured logs
232
+ this.consoleLogs = [...earlyConsoleCapture.logs];
233
+ // Setup WebSocket connection
234
+ this.connectWebSocket();
235
+ // Setup breakpoint detection
236
+ this.setupBreakpointDetection();
237
+ // Setup performance monitoring
238
+ this.setupPerformanceMonitoring();
239
+ // Setup keyboard shortcuts
240
+ this.setupKeyboardShortcuts();
241
+ // Initial render
242
+ this.render();
243
+ }
244
+ /**
245
+ * Get the current position
246
+ */
247
+ getPosition() {
248
+ return this.options.position;
249
+ }
250
+ /**
251
+ * Destroy the devbar and cleanup
252
+ */
253
+ destroy() {
254
+ this.destroyed = true;
255
+ // Close WebSocket
256
+ this.reconnectAttempts = MAX_RECONNECT_ATTEMPTS; // Prevent reconnection
257
+ if (this.reconnectTimeout)
258
+ clearTimeout(this.reconnectTimeout);
259
+ if (this.ws)
260
+ this.ws.close();
261
+ // Clear timeouts
262
+ if (this.screenshotTimeout)
263
+ clearTimeout(this.screenshotTimeout);
264
+ if (this.copiedPathTimeout)
265
+ clearTimeout(this.copiedPathTimeout);
266
+ if (this.designReviewTimeout)
267
+ clearTimeout(this.designReviewTimeout);
268
+ if (this.outlineTimeout)
269
+ clearTimeout(this.outlineTimeout);
270
+ if (this.schemaTimeout)
271
+ clearTimeout(this.schemaTimeout);
272
+ // Remove event listeners
273
+ if (this.resizeHandler)
274
+ window.removeEventListener('resize', this.resizeHandler);
275
+ if (this.keydownHandler)
276
+ window.removeEventListener('keydown', this.keydownHandler);
277
+ // Disconnect observers
278
+ if (this.fcpObserver)
279
+ this.fcpObserver.disconnect();
280
+ if (this.lcpObserver)
281
+ this.lcpObserver.disconnect();
282
+ // Restore console
283
+ if (earlyConsoleCapture.originalConsole) {
284
+ console.log = earlyConsoleCapture.originalConsole.log;
285
+ console.error = earlyConsoleCapture.originalConsole.error;
286
+ console.warn = earlyConsoleCapture.originalConsole.warn;
287
+ console.info = earlyConsoleCapture.originalConsole.info;
288
+ }
289
+ // Remove DOM elements
290
+ if (this.container) {
291
+ this.container.remove();
292
+ this.container = null;
293
+ }
294
+ if (this.overlayElement) {
295
+ this.overlayElement.remove();
296
+ this.overlayElement = null;
297
+ }
298
+ }
299
+ injectStyles() {
300
+ const styleId = 'devbar-tooltip-styles';
301
+ if (!document.getElementById(styleId)) {
302
+ const style = document.createElement('style');
303
+ style.id = styleId;
304
+ style.textContent = TOOLTIP_STYLES;
305
+ document.head.appendChild(style);
306
+ }
307
+ }
308
+ connectWebSocket() {
309
+ if (this.destroyed)
310
+ return;
311
+ const ws = new WebSocket(`ws://localhost:${WS_PORT}`);
312
+ this.ws = ws;
313
+ ws.onopen = () => {
314
+ this.sweetlinkConnected = true;
315
+ this.reconnectAttempts = 0;
316
+ ws.send(JSON.stringify({ type: 'browser-client-ready' }));
317
+ this.render();
318
+ };
319
+ ws.onmessage = async (event) => {
320
+ try {
321
+ const command = JSON.parse(event.data);
322
+ await this.handleSweetlinkCommand(command);
323
+ }
324
+ catch (e) {
325
+ console.error('[GlobalDevBar] Error handling command:', e);
326
+ }
327
+ };
328
+ ws.onclose = () => {
329
+ this.sweetlinkConnected = false;
330
+ this.render();
331
+ // Auto-reconnect with exponential backoff
332
+ if (!this.destroyed && this.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
333
+ const delayMs = BASE_RECONNECT_DELAY_MS * 2 ** this.reconnectAttempts;
334
+ this.reconnectAttempts++;
335
+ this.reconnectTimeout = setTimeout(() => this.connectWebSocket(), Math.min(delayMs, MAX_RECONNECT_DELAY_MS));
336
+ }
337
+ };
338
+ ws.onerror = () => {
339
+ // Error will trigger onclose, which handles reconnection
340
+ };
341
+ }
342
+ async handleSweetlinkCommand(command) {
343
+ const ws = this.ws;
344
+ if (!ws || ws.readyState !== WebSocket.OPEN)
345
+ return;
346
+ switch (command.type) {
347
+ case 'screenshot': {
348
+ const targetElement = command.selector
349
+ ? document.querySelector(command.selector) || document.body
350
+ : document.body;
351
+ const canvas = await html2canvas(targetElement, {
352
+ logging: false,
353
+ useCORS: true,
354
+ allowTaint: true,
355
+ });
356
+ ws.send(JSON.stringify({
357
+ success: true,
358
+ data: {
359
+ screenshot: canvas.toDataURL('image/png'),
360
+ width: canvas.width,
361
+ height: canvas.height,
362
+ selector: command.selector || 'body',
363
+ },
364
+ timestamp: Date.now(),
365
+ }));
366
+ break;
367
+ }
368
+ case 'get-logs': {
369
+ let logs = this.consoleLogs;
370
+ if (command.filter) {
371
+ const filter = command.filter.toLowerCase();
372
+ logs = logs.filter((log) => log.level.includes(filter) || log.message.toLowerCase().includes(filter));
373
+ }
374
+ ws.send(JSON.stringify({ success: true, data: logs, timestamp: Date.now() }));
375
+ break;
376
+ }
377
+ case 'query-dom': {
378
+ if (command.selector) {
379
+ const elements = Array.from(document.querySelectorAll(command.selector));
380
+ const results = elements.map((el) => {
381
+ if (command.property)
382
+ return el[command.property] ?? null;
383
+ return {
384
+ tagName: el.tagName,
385
+ className: el.className,
386
+ id: el.id,
387
+ textContent: el.textContent?.trim().slice(0, 100),
388
+ };
389
+ });
390
+ ws.send(JSON.stringify({
391
+ success: true,
392
+ data: { count: results.length, results },
393
+ timestamp: Date.now(),
394
+ }));
395
+ }
396
+ break;
397
+ }
398
+ case 'exec-js': {
399
+ if (command.code) {
400
+ try {
401
+ // Use indirect eval to avoid strict mode issues
402
+ const indirectEval = eval;
403
+ const result = indirectEval(command.code);
404
+ ws.send(JSON.stringify({ success: true, data: result, timestamp: Date.now() }));
405
+ }
406
+ catch (e) {
407
+ ws.send(JSON.stringify({
408
+ success: false,
409
+ error: e instanceof Error ? e.message : 'Execution failed',
410
+ timestamp: Date.now(),
411
+ }));
412
+ }
413
+ }
414
+ break;
415
+ }
416
+ case 'screenshot-saved':
417
+ this.handleNotification('screenshot', command.path, SCREENSHOT_NOTIFICATION_MS);
418
+ break;
419
+ case 'design-review-saved':
420
+ this.designReviewInProgress = false;
421
+ this.handleNotification('designReview', command.reviewPath, DESIGN_REVIEW_NOTIFICATION_MS);
422
+ break;
423
+ case 'design-review-error':
424
+ this.designReviewInProgress = false;
425
+ this.designReviewError = command.error || 'Unknown error';
426
+ console.error('[GlobalDevBar] Design review failed:', command.error);
427
+ // Clear error after notification duration
428
+ if (this.designReviewErrorTimeout)
429
+ clearTimeout(this.designReviewErrorTimeout);
430
+ this.designReviewErrorTimeout = setTimeout(() => {
431
+ this.designReviewError = null;
432
+ this.render();
433
+ }, DESIGN_REVIEW_NOTIFICATION_MS);
434
+ this.render();
435
+ break;
436
+ case 'api-key-status': {
437
+ // Properties are at top level of the response
438
+ const response = command;
439
+ this.apiKeyStatus = {
440
+ configured: response.configured ?? false,
441
+ maskedKey: response.maskedKey,
442
+ model: response.model,
443
+ pricing: response.pricing,
444
+ };
445
+ // Re-render to update the confirmation modal
446
+ this.render();
447
+ break;
448
+ }
449
+ case 'outline-saved':
450
+ this.handleNotification('outline', command.outlinePath, SCREENSHOT_NOTIFICATION_MS);
451
+ break;
452
+ case 'outline-error':
453
+ console.error('[GlobalDevBar] Outline save failed:', command.error);
454
+ break;
455
+ case 'schema-saved':
456
+ this.handleNotification('schema', command.schemaPath, SCREENSHOT_NOTIFICATION_MS);
457
+ break;
458
+ case 'schema-error':
459
+ console.error('[GlobalDevBar] Schema save failed:', command.error);
460
+ break;
461
+ }
462
+ }
463
+ /**
464
+ * Handle notification state updates with auto-clear timeout
465
+ */
466
+ handleNotification(type, path, durationMs) {
467
+ if (!path)
468
+ return;
469
+ // Update the appropriate state
470
+ switch (type) {
471
+ case 'screenshot':
472
+ this.lastScreenshot = path;
473
+ if (this.screenshotTimeout)
474
+ clearTimeout(this.screenshotTimeout);
475
+ this.screenshotTimeout = setTimeout(() => {
476
+ this.lastScreenshot = null;
477
+ this.render();
478
+ }, durationMs);
479
+ break;
480
+ case 'designReview':
481
+ this.lastDesignReview = path;
482
+ if (this.designReviewTimeout)
483
+ clearTimeout(this.designReviewTimeout);
484
+ this.designReviewTimeout = setTimeout(() => {
485
+ this.lastDesignReview = null;
486
+ this.render();
487
+ }, durationMs);
488
+ break;
489
+ case 'outline':
490
+ this.lastOutline = path;
491
+ if (this.outlineTimeout)
492
+ clearTimeout(this.outlineTimeout);
493
+ this.outlineTimeout = setTimeout(() => {
494
+ this.lastOutline = null;
495
+ this.render();
496
+ }, durationMs);
497
+ break;
498
+ case 'schema':
499
+ this.lastSchema = path;
500
+ if (this.schemaTimeout)
501
+ clearTimeout(this.schemaTimeout);
502
+ this.schemaTimeout = setTimeout(() => {
503
+ this.lastSchema = null;
504
+ this.render();
505
+ }, durationMs);
506
+ break;
507
+ }
508
+ this.render();
509
+ }
510
+ setupBreakpointDetection() {
511
+ const updateBreakpointInfo = () => {
512
+ const width = window.innerWidth;
513
+ const height = window.innerHeight;
514
+ // Determine breakpoint by checking thresholds in descending order
515
+ const breakpointOrder = [
516
+ '2xl',
517
+ 'xl',
518
+ 'lg',
519
+ 'md',
520
+ 'sm',
521
+ ];
522
+ const tailwindBreakpoint = breakpointOrder.find((bp) => width >= TAILWIND_BREAKPOINTS[bp].min) ?? 'base';
523
+ this.breakpointInfo = {
524
+ tailwindBreakpoint,
525
+ dimensions: `${width}x${height}`,
526
+ };
527
+ this.render();
528
+ };
529
+ updateBreakpointInfo();
530
+ this.resizeHandler = updateBreakpointInfo;
531
+ window.addEventListener('resize', this.resizeHandler);
532
+ }
533
+ setupPerformanceMonitoring() {
534
+ const updatePerfStats = () => {
535
+ // FCP
536
+ const paintEntries = performance.getEntriesByType('paint');
537
+ const fcpEntry = paintEntries.find((entry) => entry.name === 'first-contentful-paint');
538
+ const fcp = fcpEntry ? `${Math.round(fcpEntry.startTime)}ms` : '-';
539
+ // LCP (from cached value, updated by observer)
540
+ const lcp = this.lcpValue !== null ? `${Math.round(this.lcpValue)}ms` : '-';
541
+ // Total Resource Size
542
+ const resources = performance.getEntriesByType('resource');
543
+ let totalBytes = 0;
544
+ const navEntry = performance.getEntriesByType('navigation')[0];
545
+ if (navEntry) {
546
+ totalBytes += navEntry.transferSize || 0;
547
+ }
548
+ resources.forEach((entry) => {
549
+ const resourceEntry = entry;
550
+ totalBytes += resourceEntry.transferSize || 0;
551
+ });
552
+ const totalSize = totalBytes > 1024 * 1024
553
+ ? `${(totalBytes / (1024 * 1024)).toFixed(1)} MB`
554
+ : `${Math.round(totalBytes / 1024)} KB`;
555
+ this.perfStats = { fcp, lcp, totalSize };
556
+ this.render();
557
+ };
558
+ if (document.readyState === 'complete') {
559
+ setTimeout(updatePerfStats, 100);
560
+ }
561
+ else {
562
+ window.addEventListener('load', () => setTimeout(updatePerfStats, 100));
563
+ }
564
+ // FCP Observer
565
+ try {
566
+ this.fcpObserver = new PerformanceObserver((list) => {
567
+ const entries = list.getEntries();
568
+ entries.forEach((entry) => {
569
+ if (entry.name === 'first-contentful-paint') {
570
+ updatePerfStats();
571
+ }
572
+ });
573
+ });
574
+ this.fcpObserver.observe({ type: 'paint', buffered: true });
575
+ }
576
+ catch (e) {
577
+ console.warn('[GlobalDevBar] FCP PerformanceObserver not supported', e);
578
+ }
579
+ // LCP Observer
580
+ try {
581
+ this.lcpObserver = new PerformanceObserver((list) => {
582
+ const entries = list.getEntries();
583
+ const lastEntry = entries[entries.length - 1];
584
+ if (lastEntry) {
585
+ this.lcpValue = lastEntry.startTime;
586
+ updatePerfStats();
587
+ }
588
+ });
589
+ this.lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
590
+ }
591
+ catch (e) {
592
+ console.warn('[GlobalDevBar] LCP PerformanceObserver not supported', e);
593
+ }
594
+ }
595
+ setupKeyboardShortcuts() {
596
+ this.keydownHandler = (e) => {
597
+ // Close modals on Escape
598
+ if (e.key === 'Escape') {
599
+ if (this.consoleFilter ||
600
+ this.showOutlineModal ||
601
+ this.showSchemaModal ||
602
+ this.showDesignReviewConfirm) {
603
+ this.consoleFilter = null;
604
+ this.showOutlineModal = false;
605
+ this.showSchemaModal = false;
606
+ this.showDesignReviewConfirm = false;
607
+ this.render();
608
+ return;
609
+ }
610
+ }
611
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
612
+ if (e.key === 'S' || e.key === 's') {
613
+ e.preventDefault();
614
+ if (this.sweetlinkConnected && !this.capturing) {
615
+ this.handleScreenshot(false);
616
+ }
617
+ }
618
+ else if (e.key === 'C' || e.key === 'c') {
619
+ const selection = window.getSelection();
620
+ if (!selection || selection.toString().length === 0) {
621
+ e.preventDefault();
622
+ if (!this.capturing) {
623
+ this.handleScreenshot(true);
624
+ }
625
+ }
626
+ }
627
+ }
628
+ };
629
+ window.addEventListener('keydown', this.keydownHandler);
630
+ }
631
+ async copyPathToClipboard(path) {
632
+ try {
633
+ await navigator.clipboard.writeText(path);
634
+ this.copiedPath = true;
635
+ if (this.copiedPathTimeout)
636
+ clearTimeout(this.copiedPathTimeout);
637
+ this.copiedPathTimeout = setTimeout(() => {
638
+ this.copiedPath = false;
639
+ this.render();
640
+ }, CLIPBOARD_NOTIFICATION_MS);
641
+ this.render();
642
+ }
643
+ catch (error) {
644
+ console.error('[GlobalDevBar] Failed to copy path:', error);
645
+ }
646
+ }
647
+ async handleScreenshot(copyToClipboard = false) {
648
+ if (this.capturing)
649
+ return;
650
+ if (!copyToClipboard && !this.sweetlinkConnected)
651
+ return;
652
+ let cleanup = null;
653
+ try {
654
+ this.capturing = true;
655
+ this.render();
656
+ cleanup = prepareForCapture();
657
+ await delay(SCREENSHOT_BLUR_DELAY_MS);
658
+ const canvas = await html2canvas(document.body, {
659
+ logging: false,
660
+ useCORS: true,
661
+ allowTaint: true,
662
+ scale: SCREENSHOT_SCALE,
663
+ width: window.innerWidth,
664
+ windowWidth: window.innerWidth,
665
+ });
666
+ // Restore page state
667
+ cleanup();
668
+ cleanup = null;
669
+ if (copyToClipboard) {
670
+ try {
671
+ await copyCanvasToClipboard(canvas);
672
+ this.copiedToClipboard = true;
673
+ this.render();
674
+ if (this.screenshotTimeout)
675
+ clearTimeout(this.screenshotTimeout);
676
+ this.screenshotTimeout = setTimeout(() => {
677
+ this.copiedToClipboard = false;
678
+ this.render();
679
+ }, CLIPBOARD_NOTIFICATION_MS);
680
+ }
681
+ catch (e) {
682
+ console.error('[GlobalDevBar] Failed to copy to clipboard:', e);
683
+ }
684
+ }
685
+ else {
686
+ const dataUrl = canvasToDataUrl(canvas, {
687
+ format: 'jpeg',
688
+ quality: DEVBAR_SCREENSHOT_QUALITY,
689
+ });
690
+ if (this.ws?.readyState === WebSocket.OPEN) {
691
+ this.ws.send(JSON.stringify({
692
+ type: 'save-screenshot',
693
+ data: {
694
+ screenshot: dataUrl,
695
+ width: canvas.width,
696
+ height: canvas.height,
697
+ logs: this.consoleLogs,
698
+ url: window.location.href,
699
+ timestamp: Date.now(),
700
+ },
701
+ }));
702
+ }
703
+ }
704
+ }
705
+ catch (e) {
706
+ console.error('[GlobalDevBar] Screenshot failed:', e);
707
+ if (cleanup)
708
+ cleanup();
709
+ }
710
+ finally {
711
+ this.capturing = false;
712
+ this.render();
713
+ }
714
+ }
715
+ async handleDesignReview() {
716
+ if (this.designReviewInProgress || !this.sweetlinkConnected)
717
+ return;
718
+ let cleanup = null;
719
+ try {
720
+ this.designReviewInProgress = true;
721
+ this.designReviewError = null; // Clear any previous error
722
+ if (this.designReviewErrorTimeout) {
723
+ clearTimeout(this.designReviewErrorTimeout);
724
+ this.designReviewErrorTimeout = null;
725
+ }
726
+ this.render();
727
+ cleanup = prepareForCapture();
728
+ await delay(SCREENSHOT_BLUR_DELAY_MS);
729
+ const canvas = await html2canvas(document.body, {
730
+ logging: false,
731
+ useCORS: true,
732
+ allowTaint: true,
733
+ scale: 1, // Full quality for design review
734
+ width: window.innerWidth,
735
+ windowWidth: window.innerWidth,
736
+ });
737
+ // Restore page state
738
+ cleanup();
739
+ cleanup = null;
740
+ const dataUrl = canvasToDataUrl(canvas, { format: 'png' });
741
+ if (this.ws?.readyState === WebSocket.OPEN) {
742
+ this.ws.send(JSON.stringify({
743
+ type: 'design-review-screenshot',
744
+ data: {
745
+ screenshot: dataUrl,
746
+ width: canvas.width,
747
+ height: canvas.height,
748
+ logs: this.consoleLogs,
749
+ url: window.location.href,
750
+ timestamp: Date.now(),
751
+ },
752
+ }));
753
+ }
754
+ }
755
+ catch (e) {
756
+ console.error('[GlobalDevBar] Design review failed:', e);
757
+ if (cleanup)
758
+ cleanup();
759
+ this.designReviewInProgress = false;
760
+ this.render();
761
+ }
762
+ }
763
+ /**
764
+ * Show the design review confirmation modal
765
+ * Checks API key status first
766
+ */
767
+ showDesignReviewConfirmation() {
768
+ if (!this.sweetlinkConnected)
769
+ return;
770
+ // Request API key status from server
771
+ if (this.ws?.readyState === WebSocket.OPEN) {
772
+ this.ws.send(JSON.stringify({ type: 'check-api-key' }));
773
+ }
774
+ // Show the confirmation modal
775
+ this.showDesignReviewConfirm = true;
776
+ this.showOutlineModal = false;
777
+ this.showSchemaModal = false;
778
+ this.consoleFilter = null;
779
+ this.render();
780
+ }
781
+ /**
782
+ * Calculate estimated cost for design review based on viewport size
783
+ */
784
+ calculateCostEstimate() {
785
+ if (!this.apiKeyStatus?.pricing)
786
+ return null;
787
+ // Image token estimation for Claude Vision:
788
+ // Images are resized to fit within a bounding box, then tokenized
789
+ // Rough estimate: ~1 token per 1.5x1.5 pixels, or (width * height) / 750
790
+ const width = window.innerWidth;
791
+ const height = window.innerHeight;
792
+ const imageTokens = Math.ceil((width * height) / 750);
793
+ // Prompt is ~500 tokens, output up to 2048 tokens
794
+ const promptTokens = 500;
795
+ const estimatedOutputTokens = 1500; // Conservative estimate
796
+ const totalInputTokens = imageTokens + promptTokens;
797
+ const { input: inputPrice, output: outputPrice } = this.apiKeyStatus.pricing;
798
+ const inputCost = (totalInputTokens / 1000000) * inputPrice;
799
+ const outputCost = (estimatedOutputTokens / 1000000) * outputPrice;
800
+ const totalCost = inputCost + outputCost;
801
+ return {
802
+ tokens: totalInputTokens + estimatedOutputTokens,
803
+ cost: totalCost < 0.01 ? '<$0.01' : `~$${totalCost.toFixed(2)}`,
804
+ };
805
+ }
806
+ /**
807
+ * Close the design review confirmation modal
808
+ */
809
+ closeDesignReviewConfirm() {
810
+ this.showDesignReviewConfirm = false;
811
+ this.apiKeyStatus = null; // Reset so it's re-fetched next time
812
+ this.render();
813
+ }
814
+ /**
815
+ * Proceed with design review after confirmation
816
+ */
817
+ proceedWithDesignReview() {
818
+ this.showDesignReviewConfirm = false;
819
+ this.handleDesignReview();
820
+ }
821
+ handleDocumentOutline() {
822
+ // Toggle outline modal
823
+ this.showOutlineModal = !this.showOutlineModal;
824
+ this.showSchemaModal = false;
825
+ this.consoleFilter = null;
826
+ this.render();
827
+ }
828
+ handlePageSchema() {
829
+ // Toggle schema modal
830
+ this.showSchemaModal = !this.showSchemaModal;
831
+ this.showOutlineModal = false;
832
+ this.consoleFilter = null;
833
+ this.render();
834
+ }
835
+ handleSaveOutline() {
836
+ const outline = extractDocumentOutline();
837
+ const markdown = outlineToMarkdown(outline);
838
+ if (this.ws?.readyState === WebSocket.OPEN) {
839
+ this.ws.send(JSON.stringify({
840
+ type: 'save-outline',
841
+ data: {
842
+ outline,
843
+ markdown,
844
+ url: window.location.href,
845
+ title: document.title,
846
+ timestamp: Date.now(),
847
+ },
848
+ }));
849
+ }
850
+ }
851
+ handleSaveSchema() {
852
+ const schema = extractPageSchema();
853
+ const markdown = schemaToMarkdown(schema);
854
+ if (this.ws?.readyState === WebSocket.OPEN) {
855
+ this.ws.send(JSON.stringify({
856
+ type: 'save-schema',
857
+ data: {
858
+ schema,
859
+ markdown,
860
+ url: window.location.href,
861
+ title: document.title,
862
+ timestamp: Date.now(),
863
+ },
864
+ }));
865
+ }
866
+ }
867
+ clearConsoleLogs() {
868
+ // Clear the logs array
869
+ earlyConsoleCapture.logs = [];
870
+ earlyConsoleCapture.errorCount = 0;
871
+ earlyConsoleCapture.warningCount = 0;
872
+ this.consoleLogs = [];
873
+ this.consoleFilter = null;
874
+ this.render();
875
+ }
876
+ // ============================================================================
877
+ // Render Methods (Using Safe DOM Methods)
878
+ // ============================================================================
879
+ render() {
880
+ if (this.destroyed)
881
+ return;
882
+ if (typeof document === 'undefined')
883
+ return;
884
+ // Remove existing container if any
885
+ if (this.container) {
886
+ this.container.remove();
887
+ }
888
+ // Create new container
889
+ this.container = document.createElement('div');
890
+ this.container.setAttribute('data-devbar', 'true');
891
+ if (this.collapsed) {
892
+ this.renderCollapsed();
893
+ }
894
+ else {
895
+ this.renderExpanded();
896
+ }
897
+ document.body.appendChild(this.container);
898
+ // Render overlays/modals
899
+ this.renderOverlays();
900
+ }
901
+ renderOverlays() {
902
+ // Remove existing overlay
903
+ if (this.overlayElement) {
904
+ this.overlayElement.remove();
905
+ this.overlayElement = null;
906
+ }
907
+ // Render console popup if filter is active
908
+ if (this.consoleFilter) {
909
+ this.renderConsolePopup();
910
+ }
911
+ // Render outline modal
912
+ if (this.showOutlineModal) {
913
+ this.renderOutlineModal();
914
+ }
915
+ // Render schema modal
916
+ if (this.showSchemaModal) {
917
+ this.renderSchemaModal();
918
+ }
919
+ // Render design review confirmation modal
920
+ if (this.showDesignReviewConfirm) {
921
+ this.renderDesignReviewConfirmModal();
922
+ }
923
+ }
924
+ renderDesignReviewConfirmModal() {
925
+ const color = BUTTON_COLORS.review;
926
+ const closeModal = () => this.closeDesignReviewConfirm();
927
+ const overlay = createModalOverlay(closeModal);
928
+ // Override z-index for this modal to be above others
929
+ overlay.style.zIndex = '10003';
930
+ const modal = createModalBox(color);
931
+ modal.style.maxWidth = '450px';
932
+ // Header with title and close button
933
+ const header = document.createElement('div');
934
+ Object.assign(header.style, {
935
+ display: 'flex',
936
+ alignItems: 'center',
937
+ justifyContent: 'space-between',
938
+ padding: '14px 18px',
939
+ borderBottom: `1px solid ${color}40`,
940
+ backgroundColor: `${color}15`,
941
+ });
942
+ const title = document.createElement('span');
943
+ Object.assign(title.style, { color, fontSize: '0.875rem', fontWeight: '600' });
944
+ title.textContent = 'AI Design Review';
945
+ header.appendChild(title);
946
+ const closeBtn = createStyledButton({
947
+ color: COLORS.textMuted,
948
+ text: '×',
949
+ padding: '0',
950
+ fontSize: '1.25rem',
951
+ });
952
+ closeBtn.style.border = 'none';
953
+ closeBtn.onclick = closeModal;
954
+ header.appendChild(closeBtn);
955
+ modal.appendChild(header);
956
+ // Content
957
+ const content = document.createElement('div');
958
+ Object.assign(content.style, {
959
+ padding: '18px',
960
+ color: COLORS.text,
961
+ fontSize: '0.8125rem',
962
+ lineHeight: '1.6',
963
+ });
964
+ if (this.apiKeyStatus === null) {
965
+ content.appendChild(createEmptyMessage('Checking API key configuration...'));
966
+ }
967
+ else if (!this.apiKeyStatus.configured) {
968
+ content.appendChild(this.renderApiKeyNotConfiguredContent());
969
+ }
970
+ else {
971
+ content.appendChild(this.renderApiKeyConfiguredContent());
972
+ }
973
+ modal.appendChild(content);
974
+ // Footer with buttons
975
+ const footer = document.createElement('div');
976
+ Object.assign(footer.style, {
977
+ display: 'flex',
978
+ justifyContent: 'flex-end',
979
+ gap: '10px',
980
+ padding: '14px 18px',
981
+ borderTop: `1px solid ${COLORS.border}`,
982
+ });
983
+ const cancelBtn = createStyledButton({
984
+ color: COLORS.textMuted,
985
+ text: 'Cancel',
986
+ padding: '8px 16px',
987
+ });
988
+ cancelBtn.onclick = closeModal;
989
+ footer.appendChild(cancelBtn);
990
+ if (this.apiKeyStatus?.configured) {
991
+ const proceedBtn = createStyledButton({ color, text: 'Run Review', padding: '8px 16px' });
992
+ proceedBtn.style.backgroundColor = `${color}20`;
993
+ proceedBtn.onclick = () => this.proceedWithDesignReview();
994
+ footer.appendChild(proceedBtn);
995
+ }
996
+ modal.appendChild(footer);
997
+ overlay.appendChild(modal);
998
+ document.body.appendChild(overlay);
999
+ }
1000
+ /**
1001
+ * Render content when API key is not configured
1002
+ */
1003
+ renderApiKeyNotConfiguredContent() {
1004
+ const wrapper = document.createElement('div');
1005
+ wrapper.appendChild(createInfoBox(COLORS.error, 'API Key Not Configured', 'The ANTHROPIC_API_KEY environment variable is not set.'));
1006
+ // Instructions
1007
+ const instructions = document.createElement('div');
1008
+ Object.assign(instructions.style, { marginBottom: '12px' });
1009
+ const instructTitle = document.createElement('div');
1010
+ Object.assign(instructTitle.style, {
1011
+ color: COLORS.textSecondary,
1012
+ fontWeight: '600',
1013
+ marginBottom: '8px',
1014
+ });
1015
+ instructTitle.textContent = 'To configure:';
1016
+ instructions.appendChild(instructTitle);
1017
+ const steps = [
1018
+ { text: '1. Get an API key from console.anthropic.com', highlight: false },
1019
+ { text: '2. Add to your .env file:', highlight: false },
1020
+ { text: ' ANTHROPIC_API_KEY=sk-ant-...', highlight: true },
1021
+ { text: '3. Restart your dev server', highlight: false },
1022
+ ];
1023
+ steps.forEach(({ text, highlight }) => {
1024
+ const stepDiv = document.createElement('div');
1025
+ Object.assign(stepDiv.style, {
1026
+ color: highlight ? COLORS.primary : COLORS.textMuted,
1027
+ fontSize: '0.75rem',
1028
+ marginBottom: '4px',
1029
+ fontFamily: FONT_MONO,
1030
+ });
1031
+ stepDiv.textContent = text;
1032
+ instructions.appendChild(stepDiv);
1033
+ });
1034
+ wrapper.appendChild(instructions);
1035
+ return wrapper;
1036
+ }
1037
+ /**
1038
+ * Render content when API key is configured (cost estimate and model info)
1039
+ */
1040
+ renderApiKeyConfiguredContent() {
1041
+ const wrapper = document.createElement('div');
1042
+ Object.assign(wrapper.style, { marginBottom: '16px' });
1043
+ const desc = document.createElement('p');
1044
+ Object.assign(desc.style, { color: COLORS.textSecondary, marginBottom: '12px' });
1045
+ desc.textContent = 'This will capture a screenshot and send it to Claude for design analysis.';
1046
+ wrapper.appendChild(desc);
1047
+ // Cost estimate
1048
+ const estimate = this.calculateCostEstimate();
1049
+ if (estimate) {
1050
+ const costBox = createInfoBox(COLORS.primary, 'Estimated Cost', []);
1051
+ // Remove default margin and adjust padding
1052
+ costBox.style.marginBottom = '0';
1053
+ costBox.style.padding = '12px';
1054
+ const costDetails = document.createElement('div');
1055
+ Object.assign(costDetails.style, {
1056
+ display: 'flex',
1057
+ justifyContent: 'space-between',
1058
+ color: COLORS.textSecondary,
1059
+ fontSize: '0.75rem',
1060
+ });
1061
+ const tokensSpan = document.createElement('span');
1062
+ tokensSpan.textContent = `~${estimate.tokens.toLocaleString()} tokens`;
1063
+ costDetails.appendChild(tokensSpan);
1064
+ const priceSpan = document.createElement('span');
1065
+ Object.assign(priceSpan.style, { color: COLORS.warning, fontWeight: '600' });
1066
+ priceSpan.textContent = estimate.cost;
1067
+ costDetails.appendChild(priceSpan);
1068
+ costBox.appendChild(costDetails);
1069
+ wrapper.appendChild(costBox);
1070
+ }
1071
+ // Model info
1072
+ if (this.apiKeyStatus?.model) {
1073
+ const modelDiv = document.createElement('div');
1074
+ Object.assign(modelDiv.style, {
1075
+ color: COLORS.textMuted,
1076
+ fontSize: '0.6875rem',
1077
+ marginTop: '12px',
1078
+ });
1079
+ modelDiv.textContent = `Model: ${this.apiKeyStatus.model}`;
1080
+ if (this.apiKeyStatus.maskedKey) {
1081
+ modelDiv.textContent += ` | Key: ${this.apiKeyStatus.maskedKey}`;
1082
+ }
1083
+ wrapper.appendChild(modelDiv);
1084
+ }
1085
+ return wrapper;
1086
+ }
1087
+ renderConsolePopup() {
1088
+ const filterType = this.consoleFilter;
1089
+ if (!filterType)
1090
+ return;
1091
+ const logs = earlyConsoleCapture.logs.filter((log) => log.level === filterType);
1092
+ const color = filterType === 'error' ? BUTTON_COLORS.error : BUTTON_COLORS.warning;
1093
+ const label = filterType === 'error' ? 'Errors' : 'Warnings';
1094
+ const popup = document.createElement('div');
1095
+ popup.setAttribute('data-devbar', 'true');
1096
+ Object.assign(popup.style, {
1097
+ position: 'fixed',
1098
+ bottom: '60px',
1099
+ left: '80px',
1100
+ zIndex: '10002',
1101
+ backgroundColor: 'rgba(17, 24, 39, 0.98)',
1102
+ border: `1px solid ${color}`,
1103
+ borderRadius: '8px',
1104
+ boxShadow: `0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px ${color}33`,
1105
+ backdropFilter: 'blur(8px)',
1106
+ WebkitBackdropFilter: 'blur(8px)',
1107
+ minWidth: '400px',
1108
+ maxWidth: '600px',
1109
+ maxHeight: '400px',
1110
+ display: 'flex',
1111
+ flexDirection: 'column',
1112
+ fontFamily: FONT_MONO,
1113
+ });
1114
+ // Header
1115
+ const header = document.createElement('div');
1116
+ Object.assign(header.style, {
1117
+ display: 'flex',
1118
+ alignItems: 'center',
1119
+ justifyContent: 'space-between',
1120
+ padding: '10px 14px',
1121
+ borderBottom: `1px solid ${color}40`,
1122
+ });
1123
+ const title = document.createElement('span');
1124
+ Object.assign(title.style, { color, fontSize: '0.8125rem', fontWeight: '600' });
1125
+ title.textContent = `Console ${label} (${logs.length})`;
1126
+ header.appendChild(title);
1127
+ const headerButtons = document.createElement('div');
1128
+ Object.assign(headerButtons.style, { display: 'flex', gap: '8px' });
1129
+ // Clear button
1130
+ const clearBtn = createStyledButton({
1131
+ color,
1132
+ text: 'Clear All',
1133
+ padding: '4px 10px',
1134
+ borderRadius: '4px',
1135
+ fontSize: '0.6875rem',
1136
+ });
1137
+ clearBtn.onclick = () => this.clearConsoleLogs();
1138
+ headerButtons.appendChild(clearBtn);
1139
+ // Close button - match Clear button padding for consistent height
1140
+ const closeBtn = createStyledButton({
1141
+ color,
1142
+ text: '×',
1143
+ padding: '4px 8px',
1144
+ borderRadius: '4px',
1145
+ fontSize: '0.75rem',
1146
+ });
1147
+ closeBtn.onclick = () => {
1148
+ this.consoleFilter = null;
1149
+ this.render();
1150
+ };
1151
+ headerButtons.appendChild(closeBtn);
1152
+ header.appendChild(headerButtons);
1153
+ popup.appendChild(header);
1154
+ // Content
1155
+ const content = document.createElement('div');
1156
+ Object.assign(content.style, { flex: '1', overflow: 'auto', padding: '8px 0' });
1157
+ if (logs.length === 0) {
1158
+ const emptyMsg = document.createElement('div');
1159
+ Object.assign(emptyMsg.style, {
1160
+ padding: '20px',
1161
+ textAlign: 'center',
1162
+ color: COLORS.textMuted,
1163
+ fontSize: '0.75rem',
1164
+ });
1165
+ emptyMsg.textContent = `No ${filterType}s recorded`;
1166
+ content.appendChild(emptyMsg);
1167
+ }
1168
+ else {
1169
+ this.renderConsoleLogs(content, logs, color);
1170
+ }
1171
+ popup.appendChild(content);
1172
+ this.overlayElement = popup;
1173
+ document.body.appendChild(popup);
1174
+ }
1175
+ /**
1176
+ * Render console log items into a container
1177
+ */
1178
+ renderConsoleLogs(container, logs, color) {
1179
+ logs.forEach((log, index) => {
1180
+ const logItem = document.createElement('div');
1181
+ Object.assign(logItem.style, {
1182
+ padding: '8px 14px',
1183
+ borderBottom: index < logs.length - 1 ? '1px solid rgba(255, 255, 255, 0.05)' : 'none',
1184
+ });
1185
+ const timestamp = document.createElement('span');
1186
+ Object.assign(timestamp.style, {
1187
+ color: COLORS.textMuted,
1188
+ fontSize: '0.625rem',
1189
+ marginRight: '8px',
1190
+ });
1191
+ timestamp.textContent = new Date(log.timestamp).toLocaleTimeString();
1192
+ logItem.appendChild(timestamp);
1193
+ const message = document.createElement('span');
1194
+ Object.assign(message.style, {
1195
+ color,
1196
+ fontSize: '0.6875rem',
1197
+ wordBreak: 'break-word',
1198
+ whiteSpace: 'pre-wrap',
1199
+ });
1200
+ message.textContent =
1201
+ log.message.length > 500 ? `${log.message.slice(0, 500)}...` : log.message;
1202
+ logItem.appendChild(message);
1203
+ container.appendChild(logItem);
1204
+ });
1205
+ }
1206
+ renderOutlineModal() {
1207
+ const outline = extractDocumentOutline();
1208
+ const color = BUTTON_COLORS.outline;
1209
+ const closeModal = () => {
1210
+ this.showOutlineModal = false;
1211
+ this.render();
1212
+ };
1213
+ const overlay = createModalOverlay(closeModal);
1214
+ const modal = createModalBox(color);
1215
+ const header = createModalHeader({
1216
+ color,
1217
+ title: 'Document Outline',
1218
+ onClose: closeModal,
1219
+ onCopyMd: async () => {
1220
+ const markdown = outlineToMarkdown(outline);
1221
+ await navigator.clipboard.writeText(markdown);
1222
+ },
1223
+ onSave: () => this.handleSaveOutline(),
1224
+ sweetlinkConnected: this.sweetlinkConnected,
1225
+ });
1226
+ modal.appendChild(header);
1227
+ const content = createModalContent();
1228
+ if (outline.length === 0) {
1229
+ content.appendChild(createEmptyMessage('No semantic elements found in this document'));
1230
+ }
1231
+ else {
1232
+ this.renderOutlineNodes(outline, content, 0);
1233
+ }
1234
+ modal.appendChild(content);
1235
+ overlay.appendChild(modal);
1236
+ this.overlayElement = overlay;
1237
+ document.body.appendChild(overlay);
1238
+ }
1239
+ /**
1240
+ * Recursively render outline nodes into a container element
1241
+ */
1242
+ renderOutlineNodes(nodes, parentEl, depth) {
1243
+ for (const node of nodes) {
1244
+ const nodeEl = document.createElement('div');
1245
+ Object.assign(nodeEl.style, {
1246
+ padding: `4px 0 4px ${depth * 16}px`,
1247
+ });
1248
+ const tagSpan = document.createElement('span');
1249
+ const categoryColor = CATEGORY_COLORS[node.category || 'other'] || CATEGORY_COLORS.other;
1250
+ Object.assign(tagSpan.style, {
1251
+ color: categoryColor,
1252
+ fontSize: '0.6875rem',
1253
+ fontWeight: '500',
1254
+ });
1255
+ tagSpan.textContent = `<${node.tagName}>`;
1256
+ nodeEl.appendChild(tagSpan);
1257
+ if (node.category) {
1258
+ const categorySpan = document.createElement('span');
1259
+ Object.assign(categorySpan.style, {
1260
+ color: COLORS.textMuted,
1261
+ fontSize: '0.625rem',
1262
+ marginLeft: '6px',
1263
+ });
1264
+ categorySpan.textContent = `[${node.category}]`;
1265
+ nodeEl.appendChild(categorySpan);
1266
+ }
1267
+ const textSpan = document.createElement('span');
1268
+ Object.assign(textSpan.style, {
1269
+ color: '#d1d5db',
1270
+ fontSize: '0.6875rem',
1271
+ marginLeft: '8px',
1272
+ });
1273
+ const truncatedText = node.text.length > 60 ? `${node.text.slice(0, 60)}...` : node.text;
1274
+ textSpan.textContent = truncatedText;
1275
+ nodeEl.appendChild(textSpan);
1276
+ if (node.id) {
1277
+ const idSpan = document.createElement('span');
1278
+ Object.assign(idSpan.style, {
1279
+ color: '#9ca3af',
1280
+ fontSize: '0.625rem',
1281
+ marginLeft: '6px',
1282
+ });
1283
+ idSpan.textContent = `#${node.id}`;
1284
+ nodeEl.appendChild(idSpan);
1285
+ }
1286
+ parentEl.appendChild(nodeEl);
1287
+ if (node.children.length > 0) {
1288
+ this.renderOutlineNodes(node.children, parentEl, depth + 1);
1289
+ }
1290
+ }
1291
+ }
1292
+ renderSchemaModal() {
1293
+ const schema = extractPageSchema();
1294
+ const color = BUTTON_COLORS.schema;
1295
+ const closeModal = () => {
1296
+ this.showSchemaModal = false;
1297
+ this.render();
1298
+ };
1299
+ const overlay = createModalOverlay(closeModal);
1300
+ const modal = createModalBox(color);
1301
+ const header = createModalHeader({
1302
+ color,
1303
+ title: 'Page Schema',
1304
+ onClose: closeModal,
1305
+ onCopyMd: async () => {
1306
+ const markdown = schemaToMarkdown(schema);
1307
+ await navigator.clipboard.writeText(markdown);
1308
+ },
1309
+ onSave: () => this.handleSaveSchema(),
1310
+ sweetlinkConnected: this.sweetlinkConnected,
1311
+ });
1312
+ modal.appendChild(header);
1313
+ const content = createModalContent();
1314
+ const hasContent = schema.jsonLd.length > 0 ||
1315
+ Object.keys(schema.openGraph).length > 0 ||
1316
+ Object.keys(schema.twitter).length > 0 ||
1317
+ Object.keys(schema.metaTags).length > 0;
1318
+ if (!hasContent) {
1319
+ content.appendChild(createEmptyMessage('No structured data found on this page'));
1320
+ }
1321
+ else {
1322
+ this.renderSchemaSection(content, 'JSON-LD', schema.jsonLd, color);
1323
+ this.renderSchemaSection(content, 'Open Graph', schema.openGraph, COLORS.info);
1324
+ this.renderSchemaSection(content, 'Twitter Cards', schema.twitter, COLORS.cyan);
1325
+ this.renderSchemaSection(content, 'Meta Tags', schema.metaTags, COLORS.textMuted);
1326
+ }
1327
+ modal.appendChild(content);
1328
+ overlay.appendChild(modal);
1329
+ this.overlayElement = overlay;
1330
+ document.body.appendChild(overlay);
1331
+ }
1332
+ /**
1333
+ * Render a section of schema data (either array or key-value object)
1334
+ */
1335
+ renderSchemaSection(container, title, items, color) {
1336
+ const isEmpty = Array.isArray(items) ? items.length === 0 : Object.keys(items).length === 0;
1337
+ if (isEmpty)
1338
+ return;
1339
+ const section = document.createElement('div');
1340
+ section.style.marginBottom = '20px';
1341
+ const sectionTitle = document.createElement('h3');
1342
+ Object.assign(sectionTitle.style, {
1343
+ color,
1344
+ fontSize: '0.8125rem',
1345
+ fontWeight: '600',
1346
+ marginBottom: '10px',
1347
+ borderBottom: `1px solid ${color}40`,
1348
+ paddingBottom: '6px',
1349
+ });
1350
+ sectionTitle.textContent = title;
1351
+ section.appendChild(sectionTitle);
1352
+ if (Array.isArray(items)) {
1353
+ this.renderJsonLdItems(section, items);
1354
+ }
1355
+ else {
1356
+ this.renderKeyValueItems(section, items);
1357
+ }
1358
+ container.appendChild(section);
1359
+ }
1360
+ /**
1361
+ * Render JSON-LD items as formatted code blocks with syntax highlighting
1362
+ */
1363
+ renderJsonLdItems(container, items) {
1364
+ items.forEach((item, i) => {
1365
+ const itemEl = document.createElement('div');
1366
+ itemEl.style.marginBottom = '10px';
1367
+ const itemTitle = document.createElement('div');
1368
+ Object.assign(itemTitle.style, {
1369
+ color: '#9ca3af',
1370
+ fontSize: '0.6875rem',
1371
+ marginBottom: '4px',
1372
+ });
1373
+ itemTitle.textContent = `Schema ${i + 1}`;
1374
+ itemEl.appendChild(itemTitle);
1375
+ const codeEl = document.createElement('pre');
1376
+ Object.assign(codeEl.style, {
1377
+ backgroundColor: 'rgba(0, 0, 0, 0.3)',
1378
+ borderRadius: '4px',
1379
+ padding: '10px',
1380
+ overflow: 'auto',
1381
+ fontSize: '0.625rem',
1382
+ margin: '0',
1383
+ maxHeight: '300px', // Taller for more content
1384
+ });
1385
+ // Syntax highlight the JSON using DOM methods for safety
1386
+ this.appendHighlightedJson(codeEl, JSON.stringify(item, null, 2));
1387
+ itemEl.appendChild(codeEl);
1388
+ container.appendChild(itemEl);
1389
+ });
1390
+ }
1391
+ /**
1392
+ * Append syntax-highlighted JSON to an element using safe DOM methods
1393
+ * Uses textContent for all text to prevent XSS
1394
+ */
1395
+ appendHighlightedJson(container, json) {
1396
+ // Color map for different token types
1397
+ const colors = {
1398
+ key: COLORS.primary, // green
1399
+ string: COLORS.warning, // amber/yellow
1400
+ number: COLORS.purple, // purple
1401
+ boolean: COLORS.info, // blue
1402
+ nullVal: COLORS.error, // red
1403
+ punct: COLORS.textMuted, // gray
1404
+ };
1405
+ // Simple tokenizer for JSON using matchAll for safety
1406
+ const tokenPattern = /("(?:\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*")(\s*:)?|(\btrue\b|\bfalse\b)|(\bnull\b)|(-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)|([{}[\],])|(\s+)/g;
1407
+ for (const match of json.matchAll(tokenPattern)) {
1408
+ const [, str, colon, bool, nullToken, num, punct, whitespace] = match;
1409
+ if (whitespace) {
1410
+ container.appendChild(document.createTextNode(whitespace));
1411
+ }
1412
+ else if (str !== undefined) {
1413
+ const span = document.createElement('span');
1414
+ span.style.color = colon ? colors.key : colors.string;
1415
+ span.textContent = str;
1416
+ container.appendChild(span);
1417
+ if (colon) {
1418
+ const colonSpan = document.createElement('span');
1419
+ colonSpan.style.color = colors.punct;
1420
+ colonSpan.textContent = ':';
1421
+ container.appendChild(colonSpan);
1422
+ }
1423
+ }
1424
+ else if (bool) {
1425
+ const span = document.createElement('span');
1426
+ span.style.color = colors.boolean;
1427
+ span.textContent = bool;
1428
+ container.appendChild(span);
1429
+ }
1430
+ else if (nullToken) {
1431
+ const span = document.createElement('span');
1432
+ span.style.color = colors.nullVal;
1433
+ span.textContent = nullToken;
1434
+ container.appendChild(span);
1435
+ }
1436
+ else if (num) {
1437
+ const span = document.createElement('span');
1438
+ span.style.color = colors.number;
1439
+ span.textContent = num;
1440
+ container.appendChild(span);
1441
+ }
1442
+ else if (punct) {
1443
+ const span = document.createElement('span');
1444
+ span.style.color = colors.punct;
1445
+ span.textContent = punct;
1446
+ container.appendChild(span);
1447
+ }
1448
+ }
1449
+ }
1450
+ /**
1451
+ * Render key-value pairs as rows with ellipsis overflow and hover tooltip
1452
+ */
1453
+ renderKeyValueItems(container, items) {
1454
+ for (const [key, value] of Object.entries(items)) {
1455
+ const row = document.createElement('div');
1456
+ Object.assign(row.style, {
1457
+ display: 'flex',
1458
+ marginBottom: '4px',
1459
+ alignItems: 'flex-start',
1460
+ });
1461
+ const keyEl = document.createElement('span');
1462
+ Object.assign(keyEl.style, {
1463
+ color: '#9ca3af',
1464
+ fontSize: '0.6875rem',
1465
+ width: '120px',
1466
+ minWidth: '120px',
1467
+ maxWidth: '120px',
1468
+ flexShrink: '0',
1469
+ overflow: 'hidden',
1470
+ textOverflow: 'ellipsis',
1471
+ whiteSpace: 'nowrap',
1472
+ });
1473
+ keyEl.textContent = key;
1474
+ // Show full key on hover if it might be truncated
1475
+ if (key.length > 18) {
1476
+ keyEl.title = key;
1477
+ }
1478
+ row.appendChild(keyEl);
1479
+ const valueEl = document.createElement('span');
1480
+ const strValue = String(value);
1481
+ Object.assign(valueEl.style, {
1482
+ color: '#d1d5db',
1483
+ fontSize: '0.6875rem',
1484
+ flex: '1',
1485
+ wordBreak: 'break-word',
1486
+ whiteSpace: 'pre-wrap',
1487
+ });
1488
+ valueEl.textContent = strValue;
1489
+ row.appendChild(valueEl);
1490
+ container.appendChild(row);
1491
+ }
1492
+ }
1493
+ renderCollapsed() {
1494
+ if (!this.container)
1495
+ return;
1496
+ const { position, accentColor } = this.options;
1497
+ const { errorCount, warningCount } = this.getLogCounts();
1498
+ // Use captured dot position if available, otherwise fall back to preset positions
1499
+ // The 13px offset accounts for half the collapsed circle diameter (26px / 2)
1500
+ let posStyle;
1501
+ if (this.lastDotPosition) {
1502
+ // Position based on where the dot actually was
1503
+ const isTop = position.startsWith('top');
1504
+ posStyle = isTop
1505
+ ? { top: `${this.lastDotPosition.top - 13}px`, left: `${this.lastDotPosition.left - 13}px` }
1506
+ : { bottom: `${this.lastDotPosition.bottom - 13}px`, left: `${this.lastDotPosition.left - 13}px` };
1507
+ }
1508
+ else {
1509
+ // Fallback preset positions for when no dot position was captured
1510
+ const collapsedPositions = {
1511
+ 'bottom-left': { bottom: '27px', left: '86px' },
1512
+ 'bottom-right': { bottom: '27px', right: '29px' },
1513
+ 'top-left': { top: '27px', left: '86px' },
1514
+ 'top-right': { top: '27px', right: '29px' },
1515
+ 'bottom-center': { bottom: '19px', left: '50%', transform: 'translateX(-50%)' },
1516
+ };
1517
+ posStyle = collapsedPositions[position] ?? collapsedPositions['bottom-left'];
1518
+ }
1519
+ const wrapper = this.container;
1520
+ wrapper.className = this.tooltipClass('left', 'devbar-collapse');
1521
+ wrapper.setAttribute('data-tooltip', `Click to expand DevBar${this.sweetlinkConnected ? ' (Sweetlink connected)' : ' (Sweetlink not connected)'}${errorCount > 0 ? `\n${errorCount} console error${errorCount === 1 ? '' : 's'}` : ''}`);
1522
+ // Reset position properties first to avoid stale values
1523
+ wrapper.style.top = '';
1524
+ wrapper.style.bottom = '';
1525
+ wrapper.style.left = '';
1526
+ wrapper.style.right = '';
1527
+ wrapper.style.transform = '';
1528
+ Object.assign(wrapper.style, {
1529
+ position: 'fixed',
1530
+ ...posStyle,
1531
+ zIndex: '9999',
1532
+ backgroundColor: 'rgba(17, 24, 39, 0.95)',
1533
+ border: `1px solid ${accentColor}`,
1534
+ borderRadius: '50%',
1535
+ color: accentColor,
1536
+ boxShadow: `0 4px 12px rgba(0, 0, 0, 0.3), 0 0 0 1px ${accentColor}1A`,
1537
+ backdropFilter: 'blur(8px)',
1538
+ WebkitBackdropFilter: 'blur(8px)',
1539
+ cursor: 'pointer',
1540
+ display: 'flex',
1541
+ alignItems: 'center',
1542
+ justifyContent: 'center',
1543
+ width: '26px',
1544
+ height: '26px',
1545
+ boxSizing: 'border-box',
1546
+ animation: 'devbar-collapse 150ms ease-out',
1547
+ });
1548
+ wrapper.onclick = () => {
1549
+ this.collapsed = false;
1550
+ this.render();
1551
+ };
1552
+ // Connection indicator dot (same size as in expanded state)
1553
+ const dot = document.createElement('span');
1554
+ Object.assign(dot.style, {
1555
+ width: '6px',
1556
+ height: '6px',
1557
+ borderRadius: '50%',
1558
+ backgroundColor: this.sweetlinkConnected ? COLORS.primary : COLORS.textMuted,
1559
+ boxShadow: this.sweetlinkConnected ? `0 0 6px ${COLORS.primary}` : 'none',
1560
+ });
1561
+ wrapper.appendChild(dot);
1562
+ // Error badge (absolute, top-right of circle, shifted left if warning badge exists)
1563
+ if (errorCount > 0) {
1564
+ wrapper.appendChild(this.createCollapsedBadge(errorCount, 'rgba(239, 68, 68, 0.95)', warningCount > 0 ? '12px' : '-6px'));
1565
+ }
1566
+ // Warning badge (absolute, top-right)
1567
+ if (warningCount > 0) {
1568
+ wrapper.appendChild(this.createCollapsedBadge(warningCount, 'rgba(245, 158, 11, 0.95)', '-6px'));
1569
+ }
1570
+ }
1571
+ renderExpanded() {
1572
+ if (!this.container)
1573
+ return;
1574
+ const { position, accentColor, showMetrics, showScreenshot, showConsoleBadges } = this.options;
1575
+ const { errorCount, warningCount } = this.getLogCounts();
1576
+ const positionStyles = {
1577
+ 'bottom-left': { bottom: '20px', left: '80px' },
1578
+ 'bottom-right': { bottom: '20px', right: '16px' },
1579
+ 'top-left': { top: '20px', left: '80px' },
1580
+ 'top-right': { top: '20px', right: '16px' },
1581
+ 'bottom-center': { bottom: '12px', left: '50%', transform: 'translateX(-50%)' },
1582
+ };
1583
+ const posStyle = positionStyles[position] ?? positionStyles['bottom-left'];
1584
+ const isCentered = position === 'bottom-center';
1585
+ const sizeOverrides = this.options.sizeOverrides;
1586
+ const wrapper = this.container;
1587
+ // Reset position properties first to avoid stale values from previous renders
1588
+ wrapper.style.top = '';
1589
+ wrapper.style.bottom = '';
1590
+ wrapper.style.left = '';
1591
+ wrapper.style.right = '';
1592
+ wrapper.style.transform = '';
1593
+ // Calculate size values with overrides or defaults
1594
+ // Width always fit-content, maxWidth prevents overlap with other dev bars
1595
+ // BASE breakpoint (<640px) wraps buttons to centered second row via CSS
1596
+ const defaultWidth = 'fit-content';
1597
+ const defaultMinWidth = 'auto';
1598
+ const defaultMaxWidth = isCentered ? 'calc(100vw - 140px)' : 'calc(100vw - 32px)';
1599
+ Object.assign(wrapper.style, {
1600
+ position: 'fixed',
1601
+ ...posStyle,
1602
+ zIndex: '9999',
1603
+ backgroundColor: 'rgba(17, 24, 39, 0.95)',
1604
+ border: `1px solid ${accentColor}`,
1605
+ borderRadius: '12px',
1606
+ color: accentColor,
1607
+ boxShadow: `0 4px 12px rgba(0, 0, 0, 0.3), 0 0 0 1px ${accentColor}1A`,
1608
+ backdropFilter: 'blur(8px)',
1609
+ WebkitBackdropFilter: 'blur(8px)',
1610
+ boxSizing: 'border-box',
1611
+ width: sizeOverrides?.width ?? defaultWidth,
1612
+ maxWidth: sizeOverrides?.maxWidth ?? defaultMaxWidth,
1613
+ minWidth: sizeOverrides?.minWidth ?? defaultMinWidth,
1614
+ cursor: 'default',
1615
+ });
1616
+ wrapper.ondblclick = () => {
1617
+ // Capture dot position before collapsing
1618
+ const dotEl = wrapper.querySelector('.devbar-status span span');
1619
+ if (dotEl) {
1620
+ const rect = dotEl.getBoundingClientRect();
1621
+ this.lastDotPosition = {
1622
+ left: rect.left + rect.width / 2,
1623
+ top: rect.top + rect.height / 2,
1624
+ bottom: window.innerHeight - (rect.top + rect.height / 2),
1625
+ };
1626
+ }
1627
+ this.collapsed = true;
1628
+ this.render();
1629
+ };
1630
+ // Main row - wrapping controlled by CSS media query
1631
+ const mainRow = document.createElement('div');
1632
+ mainRow.className = 'devbar-main';
1633
+ Object.assign(mainRow.style, {
1634
+ display: 'flex',
1635
+ alignItems: 'center',
1636
+ alignContent: 'flex-start',
1637
+ justifyContent: 'flex-start',
1638
+ gap: '0.5rem',
1639
+ padding: '0.5rem 0.75rem',
1640
+ minWidth: '0',
1641
+ boxSizing: 'border-box',
1642
+ fontFamily: FONT_MONO,
1643
+ fontSize: '0.6875rem',
1644
+ lineHeight: '1rem',
1645
+ });
1646
+ // Connection indicator (click to collapse)
1647
+ const connIndicator = document.createElement('span');
1648
+ connIndicator.className = this.tooltipClass('left', 'devbar-clickable');
1649
+ connIndicator.setAttribute('data-tooltip', this.sweetlinkConnected
1650
+ ? 'Sweetlink connected (click to minimize)'
1651
+ : 'Sweetlink disconnected (click to minimize)');
1652
+ Object.assign(connIndicator.style, {
1653
+ width: '12px',
1654
+ height: '12px',
1655
+ borderRadius: '50%',
1656
+ backgroundColor: 'transparent',
1657
+ display: 'flex',
1658
+ alignItems: 'center',
1659
+ justifyContent: 'center',
1660
+ cursor: 'pointer',
1661
+ flexShrink: '0',
1662
+ });
1663
+ connIndicator.onclick = (e) => {
1664
+ e.stopPropagation();
1665
+ // Capture dot position before collapsing (connDot is the inner 6px dot)
1666
+ const rect = connIndicator.getBoundingClientRect();
1667
+ this.lastDotPosition = {
1668
+ left: rect.left + rect.width / 2,
1669
+ top: rect.top + rect.height / 2,
1670
+ bottom: window.innerHeight - (rect.top + rect.height / 2),
1671
+ };
1672
+ this.collapsed = true;
1673
+ this.render();
1674
+ };
1675
+ const connDot = document.createElement('span');
1676
+ Object.assign(connDot.style, {
1677
+ width: '6px',
1678
+ height: '6px',
1679
+ borderRadius: '50%',
1680
+ backgroundColor: this.sweetlinkConnected ? COLORS.primary : COLORS.textMuted,
1681
+ boxShadow: this.sweetlinkConnected ? `0 0 6px ${COLORS.primary}` : 'none',
1682
+ transition: 'all 300ms',
1683
+ });
1684
+ connIndicator.appendChild(connDot);
1685
+ // Status row wrapper - keeps connection dot, info, and badges together
1686
+ const statusRow = document.createElement('div');
1687
+ statusRow.className = 'devbar-status';
1688
+ Object.assign(statusRow.style, {
1689
+ display: 'flex',
1690
+ alignItems: 'center',
1691
+ gap: '0.5rem',
1692
+ flexWrap: 'nowrap',
1693
+ flexShrink: '0',
1694
+ });
1695
+ statusRow.appendChild(connIndicator);
1696
+ // Info section
1697
+ const infoSection = document.createElement('div');
1698
+ infoSection.className = 'devbar-info';
1699
+ Object.assign(infoSection.style, {
1700
+ display: 'flex',
1701
+ alignItems: 'center',
1702
+ gap: '0.5rem',
1703
+ textTransform: 'uppercase',
1704
+ letterSpacing: '0.05em',
1705
+ flexShrink: '1',
1706
+ minWidth: '0',
1707
+ overflow: 'visible',
1708
+ });
1709
+ // Breakpoint info
1710
+ if (showMetrics.breakpoint && this.breakpointInfo) {
1711
+ const bp = this.breakpointInfo.tailwindBreakpoint;
1712
+ const breakpointData = TAILWIND_BREAKPOINTS[bp];
1713
+ const bpSpan = document.createElement('span');
1714
+ bpSpan.className = this.tooltipClass('left', 'devbar-item');
1715
+ Object.assign(bpSpan.style, { opacity: '0.9', cursor: 'default' });
1716
+ bpSpan.setAttribute('data-tooltip', `Tailwind Breakpoint: ${bp}\n${breakpointData?.label || ''}\n\nViewport: ${this.breakpointInfo.dimensions}\n\nBreakpoints:\nbase: <640px | sm: >=640px\nmd: >=768px | lg: >=1024px\nxl: >=1280px | 2xl: >=1536px`);
1717
+ let bpText = bp;
1718
+ if (bp !== 'base') {
1719
+ bpText =
1720
+ bp === 'sm'
1721
+ ? `${bp} - ${this.breakpointInfo.dimensions.split('x')[0]}`
1722
+ : `${bp} - ${this.breakpointInfo.dimensions}`;
1723
+ }
1724
+ bpSpan.textContent = bpText;
1725
+ infoSection.appendChild(bpSpan);
1726
+ }
1727
+ // Performance stats
1728
+ if (this.perfStats) {
1729
+ const addSeparator = () => {
1730
+ const sep = document.createElement('span');
1731
+ sep.style.opacity = '0.4';
1732
+ sep.textContent = '|';
1733
+ infoSection.appendChild(sep);
1734
+ };
1735
+ if (showMetrics.fcp) {
1736
+ addSeparator();
1737
+ const fcpSpan = document.createElement('span');
1738
+ fcpSpan.className = this.tooltipClass('left', 'devbar-item');
1739
+ Object.assign(fcpSpan.style, { opacity: '0.85', cursor: 'default' });
1740
+ fcpSpan.setAttribute('data-tooltip', 'First Contentful Paint (FCP): Time until first text/image renders.\n\nGood: <1.8s\nNeeds work: 1.8-3s\nPoor: >3s');
1741
+ fcpSpan.textContent = `FCP ${this.perfStats.fcp}`;
1742
+ infoSection.appendChild(fcpSpan);
1743
+ }
1744
+ if (showMetrics.lcp) {
1745
+ addSeparator();
1746
+ const lcpSpan = document.createElement('span');
1747
+ lcpSpan.className = this.tooltipClass('left', 'devbar-item');
1748
+ Object.assign(lcpSpan.style, { opacity: '0.85', cursor: 'default' });
1749
+ lcpSpan.setAttribute('data-tooltip', 'Largest Contentful Paint (LCP): Time until largest visible element renders.\n\nGood: <2.5s\nNeeds work: 2.5-4s\nPoor: >4s');
1750
+ lcpSpan.textContent = `LCP ${this.perfStats.lcp}`;
1751
+ infoSection.appendChild(lcpSpan);
1752
+ }
1753
+ if (showMetrics.pageSize) {
1754
+ addSeparator();
1755
+ const sizeSpan = document.createElement('span');
1756
+ sizeSpan.className = this.tooltipClass('left', 'devbar-item');
1757
+ Object.assign(sizeSpan.style, { opacity: '0.7', cursor: 'default' });
1758
+ sizeSpan.setAttribute('data-tooltip', 'Total page size (compressed/transferred).\nIncludes HTML, CSS, JS, images, and other resources.');
1759
+ sizeSpan.textContent = this.perfStats.totalSize;
1760
+ infoSection.appendChild(sizeSpan);
1761
+ }
1762
+ }
1763
+ statusRow.appendChild(infoSection);
1764
+ // Console badges - add to status row so they stay with info
1765
+ if (showConsoleBadges) {
1766
+ if (errorCount > 0) {
1767
+ statusRow.appendChild(this.createConsoleBadge('error', errorCount, BUTTON_COLORS.error));
1768
+ }
1769
+ if (warningCount > 0) {
1770
+ statusRow.appendChild(this.createConsoleBadge('warn', warningCount, BUTTON_COLORS.warning));
1771
+ }
1772
+ }
1773
+ mainRow.appendChild(statusRow);
1774
+ // Action buttons - always render container for consistent height
1775
+ const actionsContainer = document.createElement('div');
1776
+ actionsContainer.className = 'devbar-actions';
1777
+ if (showScreenshot) {
1778
+ actionsContainer.appendChild(this.createScreenshotButton(accentColor));
1779
+ }
1780
+ actionsContainer.appendChild(this.createAIReviewButton());
1781
+ actionsContainer.appendChild(this.createOutlineButton());
1782
+ actionsContainer.appendChild(this.createSchemaButton());
1783
+ mainRow.appendChild(actionsContainer);
1784
+ wrapper.appendChild(mainRow);
1785
+ // Render custom controls row if there are any
1786
+ if (GlobalDevBar.customControls.length > 0) {
1787
+ const customRow = document.createElement('div');
1788
+ Object.assign(customRow.style, {
1789
+ display: 'flex',
1790
+ flexWrap: 'wrap',
1791
+ alignItems: 'center',
1792
+ gap: '0.5rem',
1793
+ padding: '0 0.75rem 0.5rem 0.75rem',
1794
+ borderTop: `1px solid ${accentColor}30`,
1795
+ marginTop: '0',
1796
+ paddingTop: '0.5rem',
1797
+ fontFamily: FONT_MONO,
1798
+ fontSize: '0.6875rem',
1799
+ });
1800
+ GlobalDevBar.customControls.forEach((control) => {
1801
+ const btn = document.createElement('button');
1802
+ btn.type = 'button';
1803
+ const color = control.variant === 'warning' ? BUTTON_COLORS.warning : accentColor;
1804
+ const isActive = control.active ?? false;
1805
+ const isDisabled = control.disabled ?? false;
1806
+ Object.assign(btn.style, {
1807
+ padding: '4px 10px',
1808
+ backgroundColor: isActive ? `${color}33` : 'transparent',
1809
+ border: `1px solid ${isActive ? color : `${color}60`}`,
1810
+ borderRadius: '6px',
1811
+ color: isActive ? color : `${color}99`,
1812
+ fontSize: '0.625rem',
1813
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
1814
+ opacity: isDisabled ? '0.5' : '1',
1815
+ transition: 'all 150ms',
1816
+ });
1817
+ btn.textContent = control.label;
1818
+ btn.disabled = isDisabled;
1819
+ if (!isDisabled) {
1820
+ btn.onmouseenter = () => {
1821
+ btn.style.backgroundColor = `${color}20`;
1822
+ btn.style.borderColor = color;
1823
+ btn.style.color = color;
1824
+ };
1825
+ btn.onmouseleave = () => {
1826
+ btn.style.backgroundColor = isActive ? `${color}33` : 'transparent';
1827
+ btn.style.borderColor = isActive ? color : `${color}60`;
1828
+ btn.style.color = isActive ? color : `${color}99`;
1829
+ };
1830
+ btn.onclick = () => control.onClick();
1831
+ }
1832
+ customRow.appendChild(btn);
1833
+ });
1834
+ wrapper.appendChild(customRow);
1835
+ }
1836
+ }
1837
+ /**
1838
+ * Create a console badge for error/warning counts
1839
+ */
1840
+ createConsoleBadge(type, count, color) {
1841
+ const label = type === 'error' ? 'error' : 'warning';
1842
+ const isActive = this.consoleFilter === type;
1843
+ const badge = document.createElement('span');
1844
+ badge.className = this.tooltipClass('right', 'devbar-badge');
1845
+ badge.setAttribute('data-tooltip', `${count} console ${label}${count === 1 ? '' : 's'} (click to view)`);
1846
+ Object.assign(badge.style, {
1847
+ display: 'flex',
1848
+ alignItems: 'center',
1849
+ justifyContent: 'center',
1850
+ minWidth: '18px',
1851
+ height: '18px',
1852
+ padding: '0 5px',
1853
+ borderRadius: '9999px',
1854
+ backgroundColor: isActive ? color : `${color}E6`,
1855
+ color: '#fff',
1856
+ fontSize: '0.625rem',
1857
+ fontWeight: '600',
1858
+ cursor: 'pointer',
1859
+ boxShadow: isActive ? `0 0 8px ${color}CC` : 'none',
1860
+ });
1861
+ badge.textContent = count > 99 ? '99+' : String(count);
1862
+ badge.onclick = () => {
1863
+ this.consoleFilter = this.consoleFilter === type ? null : type;
1864
+ this.showOutlineModal = false;
1865
+ this.showSchemaModal = false;
1866
+ this.render();
1867
+ };
1868
+ return badge;
1869
+ }
1870
+ createScreenshotButton(accentColor) {
1871
+ const btn = document.createElement('button');
1872
+ btn.type = 'button';
1873
+ btn.className = this.tooltipClass('right');
1874
+ const hasSuccessState = this.copiedToClipboard || this.copiedPath || this.lastScreenshot;
1875
+ const tooltip = this.getScreenshotTooltip();
1876
+ btn.setAttribute('data-tooltip', tooltip);
1877
+ Object.assign(btn.style, {
1878
+ display: 'flex',
1879
+ alignItems: 'center',
1880
+ justifyContent: 'center',
1881
+ width: '22px',
1882
+ height: '22px',
1883
+ minWidth: '22px',
1884
+ minHeight: '22px',
1885
+ flexShrink: '0',
1886
+ borderRadius: '50%',
1887
+ border: '1px solid',
1888
+ borderColor: hasSuccessState ? accentColor : `${accentColor}80`,
1889
+ backgroundColor: hasSuccessState ? `${accentColor}33` : 'transparent',
1890
+ color: hasSuccessState ? accentColor : `${accentColor}99`,
1891
+ cursor: !this.capturing ? 'pointer' : 'not-allowed',
1892
+ opacity: '1',
1893
+ transition: 'all 150ms',
1894
+ });
1895
+ btn.disabled = this.capturing;
1896
+ btn.onclick = (e) => {
1897
+ // If we have a saved screenshot path, clicking copies the path
1898
+ if (this.lastScreenshot && !e.shiftKey) {
1899
+ this.copyPathToClipboard(this.lastScreenshot);
1900
+ }
1901
+ else {
1902
+ this.handleScreenshot(e.shiftKey);
1903
+ }
1904
+ };
1905
+ // Button content
1906
+ if (this.copiedToClipboard || this.copiedPath || this.lastScreenshot) {
1907
+ btn.textContent = '✓';
1908
+ btn.style.fontSize = '0.6rem';
1909
+ }
1910
+ else if (this.capturing) {
1911
+ btn.textContent = '...';
1912
+ btn.style.fontSize = '0.5rem';
1913
+ }
1914
+ else {
1915
+ // Camera icon SVG
1916
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1917
+ svg.setAttribute('width', '12');
1918
+ svg.setAttribute('height', '12');
1919
+ svg.setAttribute('viewBox', '0 0 50.8 50.8');
1920
+ svg.style.stroke = 'currentColor';
1921
+ svg.style.fill = 'none';
1922
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
1923
+ g.setAttribute('stroke-linecap', 'round');
1924
+ g.setAttribute('stroke-linejoin', 'round');
1925
+ g.setAttribute('stroke-width', '4');
1926
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1927
+ path.setAttribute('d', 'M19.844 7.938H7.938v11.905m0 11.113v11.906h11.905m23.019-11.906v11.906H30.956m11.906-23.018V7.938H30.956');
1928
+ const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
1929
+ circle.setAttribute('cx', '25.4');
1930
+ circle.setAttribute('cy', '25.4');
1931
+ circle.setAttribute('r', '8.731');
1932
+ g.appendChild(path);
1933
+ g.appendChild(circle);
1934
+ svg.appendChild(g);
1935
+ btn.appendChild(svg);
1936
+ }
1937
+ return btn;
1938
+ }
1939
+ /**
1940
+ * Get the tooltip text for the screenshot button based on current state
1941
+ */
1942
+ getScreenshotTooltip() {
1943
+ if (this.copiedToClipboard) {
1944
+ return 'Copied to clipboard!';
1945
+ }
1946
+ if (this.copiedPath) {
1947
+ return 'Path copied to clipboard!';
1948
+ }
1949
+ if (this.lastScreenshot) {
1950
+ return `Screenshot saved!\n${this.lastScreenshot}\n\nClick to copy path`;
1951
+ }
1952
+ const baseTooltip = `Screenshot\n\nClick: Save to file\nShift+Click: Copy to clipboard\n\nKeyboard:\nCmd/Ctrl+Shift+S: Save\nCmd/Ctrl+Shift+C: Copy`;
1953
+ return this.sweetlinkConnected
1954
+ ? baseTooltip
1955
+ : `${baseTooltip}\n\nWarning: Sweetlink not connected`;
1956
+ }
1957
+ /**
1958
+ * Get the tooltip text for the AI review button based on current state
1959
+ */
1960
+ getAIReviewTooltip() {
1961
+ if (this.designReviewInProgress) {
1962
+ return 'AI Design Review in progress...';
1963
+ }
1964
+ if (this.designReviewError) {
1965
+ return `Design review failed:\n${this.designReviewError}`;
1966
+ }
1967
+ if (this.lastDesignReview) {
1968
+ return `Design review saved to:\n${this.lastDesignReview}`;
1969
+ }
1970
+ const baseTooltip = `AI Design Review\n\nCaptures screenshot and sends to\nClaude for design analysis.\n\nRequires ANTHROPIC_API_KEY.`;
1971
+ return this.sweetlinkConnected
1972
+ ? baseTooltip
1973
+ : `${baseTooltip}\n\nWarning: Sweetlink not connected`;
1974
+ }
1975
+ createAIReviewButton() {
1976
+ const btn = document.createElement('button');
1977
+ btn.type = 'button';
1978
+ btn.className = this.tooltipClass('right');
1979
+ const tooltip = this.getAIReviewTooltip();
1980
+ btn.setAttribute('data-tooltip', tooltip);
1981
+ const hasError = !!this.designReviewError;
1982
+ const isActive = this.designReviewInProgress || !!this.lastDesignReview || hasError;
1983
+ const isDisabled = this.designReviewInProgress || !this.sweetlinkConnected;
1984
+ // Use error color (red) when there's an error, otherwise normal review color
1985
+ const buttonColor = hasError ? '#ef4444' : BUTTON_COLORS.review;
1986
+ Object.assign(btn.style, getButtonStyles(buttonColor, isActive, isDisabled));
1987
+ if (!this.sweetlinkConnected)
1988
+ btn.style.opacity = '0.5';
1989
+ btn.disabled = isDisabled;
1990
+ btn.onclick = () => this.showDesignReviewConfirmation();
1991
+ if (this.designReviewInProgress) {
1992
+ btn.textContent = '~';
1993
+ btn.style.fontSize = '0.5rem';
1994
+ btn.style.animation = 'pulse 1s infinite';
1995
+ }
1996
+ else if (this.designReviewError) {
1997
+ // Show 'x' for error state
1998
+ btn.textContent = '×';
1999
+ btn.style.fontSize = '0.875rem';
2000
+ btn.style.fontWeight = 'bold';
2001
+ }
2002
+ else if (this.lastDesignReview) {
2003
+ btn.textContent = 'v';
2004
+ btn.style.fontSize = '0.5rem';
2005
+ }
2006
+ else {
2007
+ btn.appendChild(createSvgIcon('M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z', { fill: true }));
2008
+ }
2009
+ return btn;
2010
+ }
2011
+ createOutlineButton() {
2012
+ const btn = document.createElement('button');
2013
+ btn.type = 'button';
2014
+ btn.className = this.tooltipClass('right');
2015
+ const tooltip = this.lastOutline
2016
+ ? `Outline saved to:\n${this.lastOutline}`
2017
+ : `Document Outline\n\nView page heading structure and\nsave as markdown.`;
2018
+ btn.setAttribute('data-tooltip', tooltip);
2019
+ const isActive = this.showOutlineModal || !!this.lastOutline;
2020
+ Object.assign(btn.style, getButtonStyles(BUTTON_COLORS.outline, isActive, false));
2021
+ btn.onclick = () => this.handleDocumentOutline();
2022
+ if (this.lastOutline) {
2023
+ btn.textContent = 'v';
2024
+ btn.style.fontSize = '0.5rem';
2025
+ }
2026
+ else {
2027
+ btn.appendChild(createSvgIcon('M3 4h18v2H3V4zm0 7h12v2H3v-2zm0 7h18v2H3v-2z', { fill: true }));
2028
+ }
2029
+ return btn;
2030
+ }
2031
+ createSchemaButton() {
2032
+ const btn = document.createElement('button');
2033
+ btn.type = 'button';
2034
+ btn.className = this.tooltipClass('right');
2035
+ const tooltip = this.lastSchema
2036
+ ? `Schema saved to:\n${this.lastSchema}`
2037
+ : `Page Schema\n\nView JSON-LD, Open Graph, and\nother structured data.`;
2038
+ btn.setAttribute('data-tooltip', tooltip);
2039
+ const isActive = this.showSchemaModal || !!this.lastSchema;
2040
+ Object.assign(btn.style, getButtonStyles(BUTTON_COLORS.schema, isActive, false));
2041
+ btn.onclick = () => this.handlePageSchema();
2042
+ if (this.lastSchema) {
2043
+ btn.textContent = 'v';
2044
+ btn.style.fontSize = '0.5rem';
2045
+ }
2046
+ else {
2047
+ btn.appendChild(createSvgIcon('M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z', { fill: true }));
2048
+ }
2049
+ return btn;
2050
+ }
2051
+ }
2052
+ // Static storage for custom controls
2053
+ GlobalDevBar.customControls = [];
2054
+ // ============================================================================
2055
+ // Convenience Functions
2056
+ // ============================================================================
2057
+ // Use window-based global to survive HMR (Hot Module Replacement)
2058
+ const DEVBAR_GLOBAL_KEY = '__YTSPAR_DEVBAR_INSTANCE__';
2059
+ function getGlobalInstance() {
2060
+ if (typeof window === 'undefined')
2061
+ return null;
2062
+ return window[DEVBAR_GLOBAL_KEY] ?? null;
2063
+ }
2064
+ function setGlobalInstance(instance) {
2065
+ if (typeof window === 'undefined')
2066
+ return;
2067
+ window[DEVBAR_GLOBAL_KEY] = instance;
2068
+ }
2069
+ /**
2070
+ * Initialize and mount the GlobalDevBar
2071
+ *
2072
+ * HMR-safe: Uses window-based global that survives module reloads.
2073
+ * If an instance already exists, it will be destroyed and recreated.
2074
+ */
2075
+ export function initGlobalDevBar(options) {
2076
+ const existing = getGlobalInstance();
2077
+ if (existing) {
2078
+ // Check if already initialized with same position - skip re-init during HMR
2079
+ const existingPosition = existing.getPosition();
2080
+ const newPosition = options?.position ?? 'bottom-left';
2081
+ if (existingPosition === newPosition) {
2082
+ return existing;
2083
+ }
2084
+ // Position changed, destroy and recreate
2085
+ existing.destroy();
2086
+ setGlobalInstance(null);
2087
+ }
2088
+ const instance = new GlobalDevBar(options);
2089
+ instance.init();
2090
+ setGlobalInstance(instance);
2091
+ return instance;
2092
+ }
2093
+ /**
2094
+ * Get the current GlobalDevBar instance
2095
+ */
2096
+ export function getGlobalDevBar() {
2097
+ return getGlobalInstance();
2098
+ }
2099
+ /**
2100
+ * Destroy the GlobalDevBar
2101
+ */
2102
+ export function destroyGlobalDevBar() {
2103
+ const instance = getGlobalInstance();
2104
+ if (instance) {
2105
+ instance.destroy();
2106
+ setGlobalInstance(null);
2107
+ }
2108
+ }
2109
+ // Re-export console capture for external use
2110
+ export { earlyConsoleCapture };