@ytspar/devbar 1.3.1 → 1.4.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.
Files changed (55) hide show
  1. package/README.md +43 -0
  2. package/dist/accessibility.d.ts +4 -0
  3. package/dist/accessibility.d.ts.map +1 -1
  4. package/dist/accessibility.js +57 -0
  5. package/dist/accessibility.js.map +1 -1
  6. package/dist/constants.d.ts +0 -23
  7. package/dist/constants.d.ts.map +1 -1
  8. package/dist/constants.js +0 -3
  9. package/dist/constants.js.map +1 -1
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/modules/index.d.ts +1 -1
  15. package/dist/modules/index.d.ts.map +1 -1
  16. package/dist/modules/index.js +1 -0
  17. package/dist/modules/index.js.map +1 -1
  18. package/dist/modules/keyboard.d.ts +1 -1
  19. package/dist/modules/keyboard.d.ts.map +1 -1
  20. package/dist/modules/keyboard.js +4 -11
  21. package/dist/modules/keyboard.js.map +1 -1
  22. package/dist/modules/rendering.d.ts +1 -1
  23. package/dist/modules/rendering.d.ts.map +1 -1
  24. package/dist/modules/rendering.js +448 -466
  25. package/dist/modules/rendering.js.map +1 -1
  26. package/dist/modules/screenshot.d.ts +11 -2
  27. package/dist/modules/screenshot.d.ts.map +1 -1
  28. package/dist/modules/screenshot.js +32 -29
  29. package/dist/modules/screenshot.js.map +1 -1
  30. package/dist/modules/tooltips.d.ts +1 -1
  31. package/dist/modules/tooltips.d.ts.map +1 -1
  32. package/dist/modules/tooltips.js +13 -13
  33. package/dist/modules/tooltips.js.map +1 -1
  34. package/dist/modules/types.d.ts +7 -0
  35. package/dist/modules/types.d.ts.map +1 -1
  36. package/dist/modules/types.js +14 -1
  37. package/dist/modules/types.js.map +1 -1
  38. package/dist/modules/websocket.d.ts.map +1 -1
  39. package/dist/modules/websocket.js +334 -264
  40. package/dist/modules/websocket.js.map +1 -1
  41. package/dist/ui/buttons.d.ts.map +1 -1
  42. package/dist/ui/buttons.js +3 -1
  43. package/dist/ui/buttons.js.map +1 -1
  44. package/dist/ui/icons.d.ts +13 -0
  45. package/dist/ui/icons.d.ts.map +1 -1
  46. package/dist/ui/icons.js +24 -3
  47. package/dist/ui/icons.js.map +1 -1
  48. package/dist/ui/index.d.ts +1 -1
  49. package/dist/ui/index.d.ts.map +1 -1
  50. package/dist/ui/index.js.map +1 -1
  51. package/dist/ui/modals.d.ts +3 -2
  52. package/dist/ui/modals.d.ts.map +1 -1
  53. package/dist/ui/modals.js +22 -20
  54. package/dist/ui/modals.js.map +1 -1
  55. package/package.json +3 -4
@@ -10,10 +10,11 @@ import { checkMissingTags, extractFavicons, extractPageSchema, isImageKey, schem
10
10
  import { ACCENT_COLOR_PRESETS, DEFAULT_SETTINGS, resolveSaveLocation } from '../settings.js';
11
11
  import { createEmptyMessage, createInfoBox, createModalBox, createModalContent, createModalHeader, createModalOverlay, createCloseButton, createStyledButton, createSvgIcon, getButtonStyles, } from '../ui/index.js';
12
12
  import { getResponsiveMetricVisibility } from './performance.js';
13
- import { runA11yAudit, groupViolationsByImpact, getImpactColor, getViolationCounts, preloadAxe, } from '../accessibility.js';
14
- import { calculateCostEstimate, closeDesignReviewConfirm, copyPathToClipboard, handleA11yAudit, handleDocumentOutline, handlePageSchema, handleSaveA11yAudit, handleSaveConsoleLogs, handleSaveOutline, handleSaveSchema, proceedWithDesignReview, showDesignReviewConfirmation, } from './screenshot.js';
13
+ import { a11yToMarkdown, runA11yAudit, groupViolationsByImpact, getImpactColor, getViolationCounts, preloadAxe, } from '../accessibility.js';
14
+ import { calculateCostEstimate, closeDesignReviewConfirm, consoleLogsToMarkdown, copyPathToClipboard, handleA11yAudit, handleDocumentOutline, handlePageSchema, handleSaveA11yAudit, handleSaveConsoleLogs, handleSaveOutline, handleSaveSchema, proceedWithDesignReview, showDesignReviewConfirmation, } from './screenshot.js';
15
15
  import { setThemeMode } from './theme.js';
16
16
  import { addTooltipTitle, attachBreakpointTooltip, attachButtonTooltip, attachClickToggleTooltip, attachInfoTooltip, attachMetricTooltip, attachTextTooltip, clearAllTooltips, } from './tooltips.js';
17
+ import { closeAllModals } from './types.js';
17
18
  /**
18
19
  * Capture the center of an element's bounding rect as a dot position.
19
20
  * Used to animate the collapsed circle to the same spot as the connection dot.
@@ -120,36 +121,34 @@ function renderOverlays(state, consoleCaptureSingleton) {
120
121
  // Safety: only one overlay at a time. First match wins; close the rest.
121
122
  // (Overlay cleanup already performed by render() before calling this.)
122
123
  if (state.consoleFilter) {
123
- state.showOutlineModal = false;
124
- state.showSchemaModal = false;
125
- state.showA11yModal = false;
126
- state.showDesignReviewConfirm = false;
127
- state.showSettingsPopover = false;
124
+ const filter = state.consoleFilter;
125
+ closeAllModals(state);
126
+ state.consoleFilter = filter;
128
127
  renderConsolePopup(state, consoleCaptureSingleton);
129
128
  }
130
129
  else if (state.showOutlineModal) {
131
- state.showSchemaModal = false;
132
- state.showA11yModal = false;
133
- state.showDesignReviewConfirm = false;
134
- state.showSettingsPopover = false;
130
+ closeAllModals(state);
131
+ state.showOutlineModal = true;
135
132
  renderOutlineModal(state);
136
133
  }
137
134
  else if (state.showSchemaModal) {
138
- state.showA11yModal = false;
139
- state.showDesignReviewConfirm = false;
140
- state.showSettingsPopover = false;
135
+ closeAllModals(state);
136
+ state.showSchemaModal = true;
141
137
  renderSchemaModal(state);
142
138
  }
143
139
  else if (state.showA11yModal) {
144
- state.showDesignReviewConfirm = false;
145
- state.showSettingsPopover = false;
140
+ closeAllModals(state);
141
+ state.showA11yModal = true;
146
142
  renderA11yModal(state);
147
143
  }
148
144
  else if (state.showDesignReviewConfirm) {
149
- state.showSettingsPopover = false;
145
+ closeAllModals(state);
146
+ state.showDesignReviewConfirm = true;
150
147
  renderDesignReviewConfirmModal(state);
151
148
  }
152
149
  else if (state.showSettingsPopover) {
150
+ closeAllModals(state);
151
+ state.showSettingsPopover = true;
153
152
  renderSettingsPopover(state);
154
153
  }
155
154
  }
@@ -377,24 +376,23 @@ function renderCompact(state) {
377
376
  wrapper.appendChild(expandBtn);
378
377
  }
379
378
  // ============================================================================
380
- // Expanded State
379
+ // Expanded State — Helper Functions
381
380
  // ============================================================================
382
- function renderExpanded(state, customControls) {
383
- if (!state.container)
384
- return;
385
- const { position, accentColor, showMetrics, showScreenshot, showConsoleBadges } = state.options;
386
- const { errorCount, warningCount, infoCount } = state.getLogCounts();
381
+ /**
382
+ * Compute the CSS position for the expanded devbar wrapper.
383
+ * Uses the captured dot position when available for smooth collapse/expand transitions.
384
+ */
385
+ function computeExpandedPosition(state, position, isCentered) {
387
386
  // Dot offset from container edge in expanded mode:
388
387
  // border (1px) + padding (12px) + half indicator (6px) = 19px from left
389
388
  // border (1px) + padding (8px) + half indicator (6px) = 15px from top
390
389
  const DOT_OFFSET_LEFT = 19;
391
390
  const DOT_OFFSET_TOP = 15;
392
- const isCentered = position === 'bottom-center';
393
- let posStyle;
394
391
  // Use captured dot position to align the expanded bar's dot with where it was
395
392
  // Always use top/left positioning for precise alignment
396
393
  if (state.lastDotPosition && !isCentered) {
397
394
  const isRight = position.endsWith('right');
395
+ let posStyle;
398
396
  if (isRight) {
399
397
  // For right-aligned, fall back to default
400
398
  const isTop = position.startsWith('top');
@@ -409,20 +407,23 @@ function renderExpanded(state, customControls) {
409
407
  }
410
408
  // Clear the position after using it
411
409
  state.lastDotPosition = null;
410
+ return posStyle;
412
411
  }
413
- else {
414
- const positionStyles = {
415
- 'bottom-left': { bottom: '20px', left: '80px' },
416
- 'bottom-right': { bottom: '20px', right: '16px' },
417
- 'top-left': { top: '20px', left: '80px' },
418
- 'top-right': { top: '20px', right: '16px' },
419
- 'bottom-center': { bottom: '12px', left: '50%', transform: 'translateX(-50%)' },
420
- };
421
- posStyle = positionStyles[position] ?? positionStyles['bottom-left'];
422
- }
423
- const sizeOverrides = state.options.sizeOverrides;
424
- const wrapper = state.container;
412
+ const positionStyles = {
413
+ 'bottom-left': { bottom: '20px', left: '80px' },
414
+ 'bottom-right': { bottom: '20px', right: '16px' },
415
+ 'top-left': { top: '20px', left: '80px' },
416
+ 'top-right': { top: '20px', right: '16px' },
417
+ 'bottom-center': { bottom: '12px', left: '50%', transform: 'translateX(-50%)' },
418
+ };
419
+ return positionStyles[position] ?? positionStyles['bottom-left'];
420
+ }
421
+ /**
422
+ * Style the expanded wrapper container and attach the double-click-to-collapse handler.
423
+ */
424
+ function styleExpandedWrapper(state, wrapper, posStyle, accentColor, isCentered) {
425
425
  state.resetPositionStyles(wrapper);
426
+ const sizeOverrides = state.options.sizeOverrides;
426
427
  // Calculate size values with overrides or defaults
427
428
  // Use fit-content so DevBar only takes space it needs, but allow expansion up to max
428
429
  // Centered: 16px margin each side. Left/right: 80px for Next.js bar + 16px margin
@@ -460,7 +461,11 @@ function renderExpanded(state, customControls) {
460
461
  state.debug.state('Collapsed DevBar (double-click)');
461
462
  state.render();
462
463
  };
463
- // Main row - wrapping controlled by CSS media query
464
+ }
465
+ /**
466
+ * Create the main row flex container used in expanded mode.
467
+ */
468
+ function createExpandedMainRow() {
464
469
  const mainRow = document.createElement('div');
465
470
  mainRow.className = 'devbar-main';
466
471
  Object.assign(mainRow.style, {
@@ -476,7 +481,12 @@ function renderExpanded(state, customControls) {
476
481
  fontSize: '0.6875rem',
477
482
  lineHeight: '1rem',
478
483
  });
479
- // Connection indicator (click to collapse)
484
+ return mainRow;
485
+ }
486
+ /**
487
+ * Create the connection indicator configured to collapse the devbar on click.
488
+ */
489
+ function createExpandedConnectionIndicator(state) {
480
490
  const connIndicator = createConnectionIndicator(state);
481
491
  attachTextTooltip(state, connIndicator, () => state.sweetlinkConnected
482
492
  ? 'Sweetlink connected (click to minimize)'
@@ -488,18 +498,12 @@ function renderExpanded(state, customControls) {
488
498
  state.debug.state('Collapsed DevBar (connection dot click)');
489
499
  state.render();
490
500
  };
491
- // Status row wrapper - keeps connection dot, info, and badges together
492
- const statusRow = document.createElement('div');
493
- statusRow.className = 'devbar-status';
494
- Object.assign(statusRow.style, {
495
- display: 'flex',
496
- alignItems: 'center',
497
- gap: '0.5rem',
498
- flexWrap: 'nowrap',
499
- flexShrink: '0',
500
- });
501
- statusRow.appendChild(connIndicator);
502
- // Info section
501
+ return connIndicator;
502
+ }
503
+ /**
504
+ * Create the info section containing breakpoint display and performance metrics.
505
+ */
506
+ function createInfoSection(state, showMetrics) {
503
507
  const infoSection = document.createElement('div');
504
508
  infoSection.className = 'devbar-info';
505
509
  Object.assign(infoSection.style, {
@@ -514,135 +518,180 @@ function renderExpanded(state, customControls) {
514
518
  });
515
519
  // Breakpoint info
516
520
  if (showMetrics.breakpoint && state.breakpointInfo) {
517
- const bp = state.breakpointInfo.tailwindBreakpoint;
518
- const breakpointData = TAILWIND_BREAKPOINTS[bp];
519
- const bpSpan = document.createElement('span');
520
- bpSpan.className = 'devbar-item';
521
- Object.assign(bpSpan.style, { opacity: '0.9', cursor: 'default' });
522
- // Use HTML tooltip for breakpoint info
523
- attachBreakpointTooltip(state, bpSpan, bp, state.breakpointInfo.dimensions, breakpointData?.label || '');
524
- let bpText = bp;
525
- if (bp !== 'base') {
526
- bpText =
527
- bp === 'sm'
528
- ? `${bp} - ${state.breakpointInfo.dimensions.split('x')[0]}`
529
- : `${bp} - ${state.breakpointInfo.dimensions}`;
530
- }
531
- bpSpan.textContent = bpText;
532
- infoSection.appendChild(bpSpan);
521
+ appendBreakpointInfo(state, infoSection);
533
522
  }
534
523
  // Performance stats with responsive visibility
535
524
  if (state.perfStats) {
536
- const { visible, hidden } = getResponsiveMetricVisibility(state);
537
- const addSeparator = () => {
538
- const sep = document.createElement('span');
539
- sep.style.opacity = '0.4';
540
- sep.textContent = '|';
541
- infoSection.appendChild(sep);
542
- };
543
- // Metric configurations for reuse
544
- const metricConfigs = {
545
- fcp: {
546
- label: 'FCP',
547
- value: state.perfStats.fcp,
548
- title: 'First Contentful Paint (FCP)',
549
- description: 'Time until the first text or image renders on screen.',
550
- thresholds: { good: '<1.8s', needsWork: '1.8-3s', poor: '>3s' },
551
- },
552
- lcp: {
553
- label: 'LCP',
554
- value: state.perfStats.lcp,
555
- title: 'Largest Contentful Paint (LCP)',
556
- description: 'Time until the largest visible element renders on screen.',
557
- thresholds: { good: '<2.5s', needsWork: '2.5-4s', poor: '>4s' },
558
- },
559
- cls: {
560
- label: 'CLS',
561
- value: state.perfStats.cls,
562
- title: 'Cumulative Layout Shift (CLS)',
563
- description: 'Visual stability score. Higher values mean more unexpected layout shifts.',
564
- thresholds: { good: '<0.1', needsWork: '0.1-0.25', poor: '>0.25' },
565
- },
566
- inp: {
567
- label: 'INP',
568
- value: state.perfStats.inp,
569
- title: 'Interaction to Next Paint (INP)',
570
- description: 'Responsiveness to user input. Measures the longest interaction delay.',
571
- thresholds: { good: '<200ms', needsWork: '200-500ms', poor: '>500ms' },
572
- },
573
- pageSize: {
574
- label: '',
575
- value: state.perfStats.totalSize,
576
- title: 'Total Page Size',
577
- description: 'Compressed/transferred size including HTML, CSS, JS, images, and other resources.',
578
- },
579
- };
580
- // Render visible metrics
581
- for (const metric of visible) {
582
- if (!showMetrics[metric])
583
- continue;
584
- const config = metricConfigs[metric];
585
- addSeparator();
586
- const span = document.createElement('span');
587
- span.className = 'devbar-item';
588
- Object.assign(span.style, {
589
- opacity: metric === 'pageSize' ? '0.7' : '0.85',
590
- cursor: 'default',
591
- });
592
- span.textContent = config.label ? `${config.label} ${config.value}` : config.value;
593
- if (config.thresholds) {
594
- attachMetricTooltip(state, span, config.title, config.description, config.thresholds);
595
- }
596
- else {
597
- attachInfoTooltip(state, span, config.title, config.description);
598
- }
599
- infoSection.appendChild(span);
525
+ appendPerformanceMetrics(state, infoSection, showMetrics);
526
+ }
527
+ return infoSection;
528
+ }
529
+ /**
530
+ * Append the Tailwind breakpoint indicator to the info section.
531
+ */
532
+ function appendBreakpointInfo(state, infoSection) {
533
+ if (!state.breakpointInfo)
534
+ return;
535
+ const bp = state.breakpointInfo.tailwindBreakpoint;
536
+ const breakpointData = TAILWIND_BREAKPOINTS[bp];
537
+ const bpSpan = document.createElement('span');
538
+ bpSpan.className = 'devbar-item';
539
+ Object.assign(bpSpan.style, { opacity: '0.9', cursor: 'default' });
540
+ // Use HTML tooltip for breakpoint info
541
+ attachBreakpointTooltip(state, bpSpan, bp, state.breakpointInfo.dimensions, breakpointData?.label || '');
542
+ let bpText = bp;
543
+ if (bp !== 'base') {
544
+ bpText =
545
+ bp === 'sm'
546
+ ? `${bp} - ${state.breakpointInfo.dimensions.split('x')[0]}`
547
+ : `${bp} - ${state.breakpointInfo.dimensions}`;
548
+ }
549
+ bpSpan.textContent = bpText;
550
+ infoSection.appendChild(bpSpan);
551
+ }
552
+ /**
553
+ * Build the metric configuration map from current perf stats.
554
+ */
555
+ function buildMetricConfigs(perfStats) {
556
+ return {
557
+ fcp: {
558
+ label: 'FCP',
559
+ value: perfStats.fcp,
560
+ title: 'First Contentful Paint (FCP)',
561
+ description: 'Time until the first text or image renders on screen.',
562
+ thresholds: { good: '<1.8s', needsWork: '1.8-3s', poor: '>3s' },
563
+ },
564
+ lcp: {
565
+ label: 'LCP',
566
+ value: perfStats.lcp,
567
+ title: 'Largest Contentful Paint (LCP)',
568
+ description: 'Time until the largest visible element renders on screen.',
569
+ thresholds: { good: '<2.5s', needsWork: '2.5-4s', poor: '>4s' },
570
+ },
571
+ cls: {
572
+ label: 'CLS',
573
+ value: perfStats.cls,
574
+ title: 'Cumulative Layout Shift (CLS)',
575
+ description: 'Visual stability score. Higher values mean more unexpected layout shifts.',
576
+ thresholds: { good: '<0.1', needsWork: '0.1-0.25', poor: '>0.25' },
577
+ },
578
+ inp: {
579
+ label: 'INP',
580
+ value: perfStats.inp,
581
+ title: 'Interaction to Next Paint (INP)',
582
+ description: 'Responsiveness to user input. Measures the longest interaction delay.',
583
+ thresholds: { good: '<200ms', needsWork: '200-500ms', poor: '>500ms' },
584
+ },
585
+ pageSize: {
586
+ label: '',
587
+ value: perfStats.totalSize,
588
+ title: 'Total Page Size',
589
+ description: 'Compressed/transferred size including HTML, CSS, JS, images, and other resources.',
590
+ },
591
+ };
592
+ }
593
+ /**
594
+ * Append performance metric spans (visible metrics + hidden-metrics ellipsis) to the info section.
595
+ */
596
+ function appendPerformanceMetrics(state, infoSection, showMetrics) {
597
+ if (!state.perfStats)
598
+ return;
599
+ const { visible, hidden } = getResponsiveMetricVisibility(state);
600
+ const metricConfigs = buildMetricConfigs(state.perfStats);
601
+ const addSeparator = () => {
602
+ const sep = document.createElement('span');
603
+ sep.style.opacity = '0.4';
604
+ sep.textContent = '|';
605
+ infoSection.appendChild(sep);
606
+ };
607
+ // Render visible metrics
608
+ for (const metric of visible) {
609
+ if (!showMetrics[metric])
610
+ continue;
611
+ const config = metricConfigs[metric];
612
+ addSeparator();
613
+ const span = document.createElement('span');
614
+ span.className = 'devbar-item';
615
+ Object.assign(span.style, {
616
+ opacity: metric === 'pageSize' ? '0.7' : '0.85',
617
+ cursor: 'default',
618
+ });
619
+ span.textContent = config.label ? `${config.label} ${config.value}` : config.value;
620
+ if (config.thresholds) {
621
+ attachMetricTooltip(state, span, config.title, config.description, config.thresholds);
600
622
  }
601
- // Render ellipsis button for hidden metrics
602
- const hiddenMetricsEnabled = hidden.filter((m) => showMetrics[m]);
603
- if (hiddenMetricsEnabled.length > 0) {
604
- addSeparator();
605
- const ellipsisBtn = document.createElement('span');
606
- ellipsisBtn.className = 'devbar-item devbar-clickable';
607
- Object.assign(ellipsisBtn.style, {
608
- opacity: '0.7',
609
- cursor: 'pointer',
610
- padding: '0 2px',
611
- });
612
- ellipsisBtn.textContent = '\u00B7\u00B7\u00B7';
613
- // Attach click-toggle tooltip showing hidden metrics (for mobile support)
614
- attachClickToggleTooltip(state, ellipsisBtn, (tooltip) => {
615
- addTooltipTitle(state, tooltip, 'More Metrics');
616
- const metricsContainer = document.createElement('div');
617
- Object.assign(metricsContainer.style, {
618
- display: 'flex',
619
- flexDirection: 'column',
620
- gap: '6px',
621
- marginTop: '8px',
622
- });
623
- for (const metric of hiddenMetricsEnabled) {
624
- const config = metricConfigs[metric];
625
- const row = document.createElement('div');
626
- Object.assign(row.style, {
627
- display: 'flex',
628
- justifyContent: 'space-between',
629
- gap: '12px',
630
- });
631
- const labelSpan = document.createElement('span');
632
- Object.assign(labelSpan.style, { color: CSS_COLORS.textMuted });
633
- labelSpan.textContent = config.title.split('(')[0].trim();
634
- const valueSpan = document.createElement('span');
635
- Object.assign(valueSpan.style, { color: CSS_COLORS.text, fontWeight: '500' });
636
- valueSpan.textContent = config.value;
637
- row.appendChild(labelSpan);
638
- row.appendChild(valueSpan);
639
- metricsContainer.appendChild(row);
640
- }
641
- tooltip.appendChild(metricsContainer);
642
- });
643
- infoSection.appendChild(ellipsisBtn);
623
+ else {
624
+ attachInfoTooltip(state, span, config.title, config.description);
644
625
  }
626
+ infoSection.appendChild(span);
645
627
  }
628
+ // Render ellipsis button for hidden metrics
629
+ const hiddenMetricsEnabled = hidden.filter((m) => showMetrics[m]);
630
+ if (hiddenMetricsEnabled.length > 0) {
631
+ addSeparator();
632
+ appendHiddenMetricsEllipsis(state, infoSection, hiddenMetricsEnabled, metricConfigs);
633
+ }
634
+ }
635
+ /**
636
+ * Append the ellipsis button that reveals hidden metrics in a click-toggle tooltip.
637
+ */
638
+ function appendHiddenMetricsEllipsis(state, infoSection, hiddenMetricsEnabled, metricConfigs) {
639
+ const ellipsisBtn = document.createElement('span');
640
+ ellipsisBtn.className = 'devbar-item devbar-clickable';
641
+ Object.assign(ellipsisBtn.style, {
642
+ opacity: '0.7',
643
+ cursor: 'pointer',
644
+ padding: '0 2px',
645
+ });
646
+ ellipsisBtn.textContent = '\u00B7\u00B7\u00B7';
647
+ // Attach click-toggle tooltip showing hidden metrics (for mobile support)
648
+ attachClickToggleTooltip(state, ellipsisBtn, (tooltip) => {
649
+ addTooltipTitle(state, tooltip, 'More Metrics');
650
+ const metricsContainer = document.createElement('div');
651
+ Object.assign(metricsContainer.style, {
652
+ display: 'flex',
653
+ flexDirection: 'column',
654
+ gap: '6px',
655
+ marginTop: '8px',
656
+ });
657
+ for (const metric of hiddenMetricsEnabled) {
658
+ const config = metricConfigs[metric];
659
+ const row = document.createElement('div');
660
+ Object.assign(row.style, {
661
+ display: 'flex',
662
+ justifyContent: 'space-between',
663
+ gap: '12px',
664
+ });
665
+ const labelSpan = document.createElement('span');
666
+ Object.assign(labelSpan.style, { color: CSS_COLORS.textMuted });
667
+ labelSpan.textContent = config.title.split('(')[0].trim();
668
+ const valueSpan = document.createElement('span');
669
+ Object.assign(valueSpan.style, { color: CSS_COLORS.text, fontWeight: '500' });
670
+ valueSpan.textContent = config.value;
671
+ row.appendChild(labelSpan);
672
+ row.appendChild(valueSpan);
673
+ metricsContainer.appendChild(row);
674
+ }
675
+ tooltip.appendChild(metricsContainer);
676
+ });
677
+ infoSection.appendChild(ellipsisBtn);
678
+ }
679
+ /**
680
+ * Create the status row containing the connection indicator, info section, and console badges.
681
+ */
682
+ function createStatusRow(state, showMetrics, showConsoleBadges, errorCount, warningCount, infoCount) {
683
+ const connIndicator = createExpandedConnectionIndicator(state);
684
+ const statusRow = document.createElement('div');
685
+ statusRow.className = 'devbar-status';
686
+ Object.assign(statusRow.style, {
687
+ display: 'flex',
688
+ alignItems: 'center',
689
+ gap: '0.5rem',
690
+ flexWrap: 'nowrap',
691
+ flexShrink: '0',
692
+ });
693
+ statusRow.appendChild(connIndicator);
694
+ const infoSection = createInfoSection(state, showMetrics);
646
695
  statusRow.appendChild(infoSection);
647
696
  // Console badges - add to status row so they stay with info
648
697
  if (showConsoleBadges) {
@@ -656,8 +705,12 @@ function renderExpanded(state, customControls) {
656
705
  statusRow.appendChild(createConsoleBadge(state, 'info', infoCount, BUTTON_COLORS.info));
657
706
  }
658
707
  }
659
- mainRow.appendChild(statusRow);
660
- // Action buttons - always render container for consistent height
708
+ return statusRow;
709
+ }
710
+ /**
711
+ * Create the action buttons container (screenshot, AI review, outline, schema, a11y, settings, compact).
712
+ */
713
+ function createActionButtonsContainer(state, showScreenshot, accentColor) {
661
714
  const actionsContainer = document.createElement('div');
662
715
  actionsContainer.className = 'devbar-actions';
663
716
  if (showScreenshot) {
@@ -669,57 +722,89 @@ function renderExpanded(state, customControls) {
669
722
  actionsContainer.appendChild(createA11yButton(state));
670
723
  actionsContainer.appendChild(createSettingsButton(state));
671
724
  actionsContainer.appendChild(createCompactToggleButton(state));
725
+ return actionsContainer;
726
+ }
727
+ /**
728
+ * Create the custom controls row for user-defined buttons.
729
+ * Returns null if there are no custom controls.
730
+ */
731
+ function createCustomControlsRow(customControls, accentColor) {
732
+ if (customControls.length === 0)
733
+ return null;
734
+ const customRow = document.createElement('div');
735
+ Object.assign(customRow.style, {
736
+ display: 'flex',
737
+ flexWrap: 'wrap',
738
+ alignItems: 'center',
739
+ gap: '0.5rem',
740
+ padding: '0 0.75rem 0.5rem 0.75rem',
741
+ borderTop: `1px solid ${accentColor}30`,
742
+ marginTop: '0',
743
+ paddingTop: '0.5rem',
744
+ fontFamily: FONT_MONO,
745
+ fontSize: '0.6875rem',
746
+ });
747
+ customControls.forEach((control) => {
748
+ const btn = document.createElement('button');
749
+ btn.type = 'button';
750
+ const color = control.variant === 'warning' ? BUTTON_COLORS.warning : accentColor;
751
+ const isActive = control.active ?? false;
752
+ const isDisabled = control.disabled ?? false;
753
+ Object.assign(btn.style, {
754
+ padding: '4px 10px',
755
+ backgroundColor: isActive ? `${color}33` : 'transparent',
756
+ border: `1px solid ${isActive ? color : `${color}60`}`,
757
+ borderRadius: '6px',
758
+ color: isActive ? color : `${color}99`,
759
+ fontSize: '0.625rem',
760
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
761
+ opacity: isDisabled ? '0.5' : '1',
762
+ transition: 'all 150ms',
763
+ });
764
+ btn.textContent = control.label;
765
+ btn.disabled = isDisabled;
766
+ if (!isDisabled) {
767
+ btn.onmouseenter = () => {
768
+ btn.style.backgroundColor = `${color}20`;
769
+ btn.style.borderColor = color;
770
+ btn.style.color = color;
771
+ };
772
+ btn.onmouseleave = () => {
773
+ btn.style.backgroundColor = isActive ? `${color}33` : 'transparent';
774
+ btn.style.borderColor = isActive ? color : `${color}60`;
775
+ btn.style.color = isActive ? color : `${color}99`;
776
+ };
777
+ btn.onclick = () => control.onClick();
778
+ }
779
+ customRow.appendChild(btn);
780
+ });
781
+ return customRow;
782
+ }
783
+ // ============================================================================
784
+ // Expanded State — Orchestrator
785
+ // ============================================================================
786
+ function renderExpanded(state, customControls) {
787
+ if (!state.container)
788
+ return;
789
+ const { position, accentColor, showMetrics, showScreenshot, showConsoleBadges } = state.options;
790
+ const { errorCount, warningCount, infoCount } = state.getLogCounts();
791
+ const isCentered = position === 'bottom-center';
792
+ const wrapper = state.container;
793
+ // 1. Position and style the wrapper
794
+ const posStyle = computeExpandedPosition(state, position, isCentered);
795
+ styleExpandedWrapper(state, wrapper, posStyle, accentColor, isCentered);
796
+ // 2. Build the main row
797
+ const mainRow = createExpandedMainRow();
798
+ // 3. Status row (connection dot + info metrics + console badges)
799
+ const statusRow = createStatusRow(state, showMetrics, showConsoleBadges, errorCount, warningCount, infoCount);
800
+ mainRow.appendChild(statusRow);
801
+ // 4. Action buttons
802
+ const actionsContainer = createActionButtonsContainer(state, showScreenshot, accentColor);
672
803
  mainRow.appendChild(actionsContainer);
673
804
  wrapper.appendChild(mainRow);
674
- // Render custom controls row if there are any
675
- if (customControls.length > 0) {
676
- const customRow = document.createElement('div');
677
- Object.assign(customRow.style, {
678
- display: 'flex',
679
- flexWrap: 'wrap',
680
- alignItems: 'center',
681
- gap: '0.5rem',
682
- padding: '0 0.75rem 0.5rem 0.75rem',
683
- borderTop: `1px solid ${accentColor}30`,
684
- marginTop: '0',
685
- paddingTop: '0.5rem',
686
- fontFamily: FONT_MONO,
687
- fontSize: '0.6875rem',
688
- });
689
- customControls.forEach((control) => {
690
- const btn = document.createElement('button');
691
- btn.type = 'button';
692
- const color = control.variant === 'warning' ? BUTTON_COLORS.warning : accentColor;
693
- const isActive = control.active ?? false;
694
- const isDisabled = control.disabled ?? false;
695
- Object.assign(btn.style, {
696
- padding: '4px 10px',
697
- backgroundColor: isActive ? `${color}33` : 'transparent',
698
- border: `1px solid ${isActive ? color : `${color}60`}`,
699
- borderRadius: '6px',
700
- color: isActive ? color : `${color}99`,
701
- fontSize: '0.625rem',
702
- cursor: isDisabled ? 'not-allowed' : 'pointer',
703
- opacity: isDisabled ? '0.5' : '1',
704
- transition: 'all 150ms',
705
- });
706
- btn.textContent = control.label;
707
- btn.disabled = isDisabled;
708
- if (!isDisabled) {
709
- btn.onmouseenter = () => {
710
- btn.style.backgroundColor = `${color}20`;
711
- btn.style.borderColor = color;
712
- btn.style.color = color;
713
- };
714
- btn.onmouseleave = () => {
715
- btn.style.backgroundColor = isActive ? `${color}33` : 'transparent';
716
- btn.style.borderColor = isActive ? color : `${color}60`;
717
- btn.style.color = isActive ? color : `${color}99`;
718
- };
719
- btn.onclick = () => control.onClick();
720
- }
721
- customRow.appendChild(btn);
722
- });
805
+ // 5. Custom controls row (if any)
806
+ const customRow = createCustomControlsRow(customControls, accentColor);
807
+ if (customRow) {
723
808
  wrapper.appendChild(customRow);
724
809
  }
725
810
  }
@@ -750,10 +835,9 @@ function createConsoleBadge(state, type, count, color) {
750
835
  badge.textContent = count > 99 ? '99+' : String(count);
751
836
  attachTextTooltip(state, badge, () => `${count} console ${label}${count === 1 ? '' : 's'} (click to view)`);
752
837
  badge.onclick = () => {
753
- state.consoleFilter = state.consoleFilter === type ? null : type;
754
- state.showOutlineModal = false;
755
- state.showSchemaModal = false;
756
- state.showSettingsPopover = false;
838
+ const newFilter = state.consoleFilter === type ? null : type;
839
+ closeAllModals(state);
840
+ state.consoleFilter = newFilter;
757
841
  state.render();
758
842
  };
759
843
  return badge;
@@ -872,26 +956,12 @@ function createScreenshotButton(state, accentColor) {
872
956
  }
873
957
  else {
874
958
  // Camera icon SVG
875
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
876
- svg.setAttribute('width', '12');
877
- svg.setAttribute('height', '12');
878
- svg.setAttribute('viewBox', '0 0 50.8 50.8');
879
- svg.style.stroke = 'currentColor';
880
- svg.style.fill = 'none';
881
- const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
882
- g.setAttribute('stroke-linecap', 'round');
883
- g.setAttribute('stroke-linejoin', 'round');
884
- g.setAttribute('stroke-width', '4');
885
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
886
- path.setAttribute('d', 'M19.844 7.938H7.938v11.905m0 11.113v11.906h11.905m23.019-11.906v11.906H30.956m11.906-23.018V7.938H30.956');
887
- const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
888
- circle.setAttribute('cx', '25.4');
889
- circle.setAttribute('cy', '25.4');
890
- circle.setAttribute('r', '8.731');
891
- g.appendChild(path);
892
- g.appendChild(circle);
893
- svg.appendChild(g);
894
- btn.appendChild(svg);
959
+ btn.appendChild(createSvgIcon('M19.844 7.938H7.938v11.905m0 11.113v11.906h11.905m23.019-11.906v11.906H30.956m11.906-23.018V7.938H30.956', {
960
+ viewBox: '0 0 50.8 50.8',
961
+ stroke: true,
962
+ strokeWidth: '4',
963
+ children: [{ type: 'circle', cx: '25.4', cy: '25.4', r: '8.731' }],
964
+ }));
895
965
  }
896
966
  return btn;
897
967
  }
@@ -1056,49 +1126,15 @@ function createSettingsButton(state) {
1056
1126
  });
1057
1127
  const isActive = state.showSettingsPopover;
1058
1128
  const color = CSS_COLORS.textSecondary;
1059
- Object.assign(btn.style, {
1060
- display: 'flex',
1061
- alignItems: 'center',
1062
- justifyContent: 'center',
1063
- width: '22px',
1064
- height: '22px',
1065
- minWidth: '22px',
1066
- minHeight: '22px',
1067
- flexShrink: '0',
1068
- borderRadius: '50%',
1069
- border: `1px solid ${isActive ? color : `${color}60`}`,
1070
- backgroundColor: isActive ? `${color}20` : 'transparent',
1071
- color: isActive ? color : `${color}99`,
1072
- cursor: 'pointer',
1073
- transition: 'all 150ms',
1074
- });
1129
+ Object.assign(btn.style, getButtonStyles(color, isActive, false));
1075
1130
  btn.onclick = () => {
1076
- state.showSettingsPopover = !state.showSettingsPopover;
1077
- state.consoleFilter = null;
1078
- state.showOutlineModal = false;
1079
- state.showSchemaModal = false;
1080
- state.showDesignReviewConfirm = false;
1131
+ const wasOpen = state.showSettingsPopover;
1132
+ closeAllModals(state);
1133
+ state.showSettingsPopover = !wasOpen;
1081
1134
  state.render();
1082
1135
  };
1083
1136
  // Gear icon SVG
1084
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1085
- svg.setAttribute('width', '12');
1086
- svg.setAttribute('height', '12');
1087
- svg.setAttribute('viewBox', '0 0 24 24');
1088
- svg.setAttribute('fill', 'none');
1089
- svg.setAttribute('stroke', 'currentColor');
1090
- svg.setAttribute('stroke-width', '2');
1091
- svg.setAttribute('stroke-linecap', 'round');
1092
- svg.setAttribute('stroke-linejoin', 'round');
1093
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1094
- path.setAttribute('d', 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z');
1095
- svg.appendChild(path);
1096
- const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
1097
- circle.setAttribute('cx', '12');
1098
- circle.setAttribute('cy', '12');
1099
- circle.setAttribute('r', '3');
1100
- svg.appendChild(circle);
1101
- btn.appendChild(svg);
1137
+ btn.appendChild(createSvgIcon('M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z', { stroke: true, children: [{ type: 'circle', cx: '12', cy: '12', r: '3' }] }));
1102
1138
  return btn;
1103
1139
  }
1104
1140
  /**
@@ -1111,22 +1147,8 @@ function createCompactToggleButton(state) {
1111
1147
  const isCompact = state.compactMode;
1112
1148
  const { accentColor } = state.options;
1113
1149
  const iconColor = CSS_COLORS.textSecondary;
1114
- Object.assign(btn.style, {
1115
- display: 'flex',
1116
- alignItems: 'center',
1117
- justifyContent: 'center',
1118
- width: '22px',
1119
- height: '22px',
1120
- minWidth: '22px',
1121
- minHeight: '22px',
1122
- flexShrink: '0',
1123
- borderRadius: '50%',
1124
- border: `1px solid ${accentColor}60`,
1125
- backgroundColor: 'transparent',
1126
- color: `${iconColor}99`,
1127
- cursor: 'pointer',
1128
- transition: 'all 150ms',
1129
- });
1150
+ Object.assign(btn.style, getButtonStyles(iconColor, false, false));
1151
+ btn.style.borderColor = `${accentColor}60`;
1130
1152
  attachTextTooltip(state, btn, () => (isCompact ? 'Expand (Cmd or Ctrl+Shift+M)' : 'Compact (Cmd or Ctrl+Shift+M)'), {
1131
1153
  onEnter: () => {
1132
1154
  btn.style.borderColor = accentColor;
@@ -1143,20 +1165,8 @@ function createCompactToggleButton(state) {
1143
1165
  state.toggleCompactMode();
1144
1166
  };
1145
1167
  // Chevron icon SVG - points right when expanded, left when compact
1146
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1147
- svg.setAttribute('width', '12');
1148
- svg.setAttribute('height', '12');
1149
- svg.setAttribute('viewBox', '0 0 24 24');
1150
- svg.setAttribute('fill', 'none');
1151
- svg.setAttribute('stroke', 'currentColor');
1152
- svg.setAttribute('stroke-width', '2');
1153
- svg.setAttribute('stroke-linecap', 'round');
1154
- svg.setAttribute('stroke-linejoin', 'round');
1155
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
1156
- // Left chevron (<) when expanded to shrink, right chevron (>) when compact to expand
1157
- path.setAttribute('points', isCompact ? '9 18 15 12 9 6' : '15 18 9 12 15 6');
1158
- svg.appendChild(path);
1159
- btn.appendChild(svg);
1168
+ const chevronPoints = isCompact ? '9 18 15 12 9 6' : '15 18 9 12 15 6';
1169
+ btn.appendChild(createSvgIcon('', { stroke: true, children: [{ type: 'polyline', points: chevronPoints }] }));
1160
1170
  return btn;
1161
1171
  }
1162
1172
  // ============================================================================
@@ -1184,11 +1194,7 @@ function renderConsolePopup(state, consoleCaptureSingleton) {
1184
1194
  title: `Console ${label} (${logs.length})`,
1185
1195
  onClose: closeModal,
1186
1196
  onCopyMd: async () => {
1187
- const lines = logs.map((log) => {
1188
- const time = new Date(log.timestamp).toLocaleTimeString();
1189
- return `[${time}] ${log.level}: ${log.message}`;
1190
- });
1191
- await navigator.clipboard.writeText(lines.join('\n'));
1197
+ await navigator.clipboard.writeText(consoleLogsToMarkdown(logs));
1192
1198
  },
1193
1199
  onSave: () => handleSaveConsoleLogs(state, logs),
1194
1200
  onClear: () => state.clearConsoleLogs(),
@@ -1269,23 +1275,40 @@ function renderOutlineModal(state) {
1269
1275
  content.appendChild(createEmptyMessage('No semantic elements found in this document'));
1270
1276
  }
1271
1277
  else {
1272
- renderOutlineNodes(outline, content, 0);
1278
+ renderOutlineNodes(outline, content, 0, { lastHeadingLevel: 0 });
1273
1279
  }
1274
1280
  modal.appendChild(content);
1275
1281
  overlay.appendChild(modal);
1276
1282
  state.overlayElement = overlay;
1277
1283
  document.body.appendChild(overlay);
1278
1284
  }
1279
- function renderOutlineNodes(nodes, parentEl, depth) {
1285
+ function renderOutlineNodes(nodes, parentEl, depth, headingTracker) {
1280
1286
  for (const node of nodes) {
1287
+ const isHeading = node.category === 'heading' && node.level > 0;
1288
+ const skippedLevel = isHeading && node.level > headingTracker.lastHeadingLevel + 1;
1289
+ if (isHeading) {
1290
+ headingTracker.lastHeadingLevel = node.level;
1291
+ }
1281
1292
  const nodeEl = document.createElement('div');
1282
1293
  Object.assign(nodeEl.style, {
1283
1294
  padding: `4px 0 4px ${depth * 16}px`,
1284
1295
  });
1296
+ // Warning icon for heading hierarchy breaks
1297
+ if (skippedLevel) {
1298
+ const warn = document.createElement('span');
1299
+ Object.assign(warn.style, {
1300
+ color: CSS_COLORS.error,
1301
+ fontSize: '0.625rem',
1302
+ marginRight: '4px',
1303
+ });
1304
+ warn.textContent = '\u26A0';
1305
+ warn.title = `Heading level skipped (expected h${node.level - 1} or higher before h${node.level})`;
1306
+ nodeEl.appendChild(warn);
1307
+ }
1285
1308
  const tagSpan = document.createElement('span');
1286
1309
  const categoryColor = CATEGORY_COLORS[node.category || 'other'] || CATEGORY_COLORS.other;
1287
1310
  Object.assign(tagSpan.style, {
1288
- color: categoryColor,
1311
+ color: skippedLevel ? CSS_COLORS.error : categoryColor,
1289
1312
  fontSize: '0.6875rem',
1290
1313
  fontWeight: '500',
1291
1314
  });
@@ -1313,7 +1336,7 @@ function renderOutlineNodes(nodes, parentEl, depth) {
1313
1336
  if (node.id) {
1314
1337
  const idSpan = document.createElement('span');
1315
1338
  Object.assign(idSpan.style, {
1316
- color: '#9ca3af',
1339
+ color: CSS_COLORS.textSecondary,
1317
1340
  fontSize: '0.625rem',
1318
1341
  marginLeft: '6px',
1319
1342
  });
@@ -1322,7 +1345,7 @@ function renderOutlineNodes(nodes, parentEl, depth) {
1322
1345
  }
1323
1346
  parentEl.appendChild(nodeEl);
1324
1347
  if (node.children.length > 0) {
1325
- renderOutlineNodes(node.children, parentEl, depth + 1);
1348
+ renderOutlineNodes(node.children, parentEl, depth + 1, headingTracker);
1326
1349
  }
1327
1350
  }
1328
1351
  }
@@ -1335,12 +1358,14 @@ function renderSchemaModal(state) {
1335
1358
  };
1336
1359
  const overlay = createModalOverlay(closeModal);
1337
1360
  const modal = createModalBox(color);
1361
+ const missingTags = checkMissingTags(schema);
1362
+ const favicons = extractFavicons();
1338
1363
  const header = createModalHeader({
1339
1364
  color,
1340
1365
  title: 'Page Schema',
1341
1366
  onClose: closeModal,
1342
1367
  onCopyMd: async () => {
1343
- const markdown = schemaToMarkdown(schema);
1368
+ const markdown = schemaToMarkdown(schema, { missingTags, favicons });
1344
1369
  await navigator.clipboard.writeText(markdown);
1345
1370
  },
1346
1371
  onSave: () => handleSaveSchema(state),
@@ -1351,8 +1376,6 @@ function renderSchemaModal(state) {
1351
1376
  });
1352
1377
  modal.appendChild(header);
1353
1378
  const content = createModalContent();
1354
- const missingTags = checkMissingTags(schema);
1355
- const favicons = extractFavicons();
1356
1379
  const hasContent = schema.jsonLd.length > 0 ||
1357
1380
  Object.keys(schema.openGraph).length > 0 ||
1358
1381
  Object.keys(schema.twitter).length > 0 ||
@@ -1440,7 +1463,7 @@ function renderJsonLdItems(container, items, color) {
1440
1463
  });
1441
1464
  const itemTitle = document.createElement('span');
1442
1465
  Object.assign(itemTitle.style, {
1443
- color: '#9ca3af',
1466
+ color: CSS_COLORS.textSecondary,
1444
1467
  fontSize: '0.6875rem',
1445
1468
  });
1446
1469
  itemTitle.textContent = `Schema ${i + 1}`;
@@ -1465,10 +1488,10 @@ function renderJsonLdItems(container, items, color) {
1465
1488
  borderRadius: '4px',
1466
1489
  borderLeft: `2px solid ${color}50`,
1467
1490
  padding: '10px 10px 10px 12px',
1468
- overflow: 'auto',
1469
1491
  fontSize: '0.625rem',
1470
1492
  margin: '0',
1471
- maxHeight: '300px',
1493
+ whiteSpace: 'pre-wrap',
1494
+ wordBreak: 'break-word',
1472
1495
  });
1473
1496
  appendHighlightedJson(codeEl, JSON.stringify(item, null, 2));
1474
1497
  itemEl.appendChild(codeEl);
@@ -1870,60 +1893,6 @@ function renderMissingTagsSection(container, tags) {
1870
1893
  // ============================================================================
1871
1894
  // Accessibility Audit Modal
1872
1895
  // ============================================================================
1873
- function formatA11yMarkdown(result) {
1874
- const counts = getViolationCounts(result.violations);
1875
- const lines = [
1876
- '# Accessibility Audit Report',
1877
- '',
1878
- `**URL:** ${result.url}`,
1879
- `**Timestamp:** ${result.timestamp}`,
1880
- '',
1881
- '## Summary',
1882
- '',
1883
- `- **Total violations:** ${counts.total}`,
1884
- `- Critical: ${counts.critical}`,
1885
- `- Serious: ${counts.serious}`,
1886
- `- Moderate: ${counts.moderate}`,
1887
- `- Minor: ${counts.minor}`,
1888
- `- Passes: ${result.passes.length}`,
1889
- `- Incomplete: ${result.incomplete.length}`,
1890
- '',
1891
- ];
1892
- if (result.violations.length === 0) {
1893
- lines.push('No accessibility violations found.');
1894
- return lines.join('\n');
1895
- }
1896
- const grouped = groupViolationsByImpact(result.violations);
1897
- for (const [impact, violations] of grouped) {
1898
- if (violations.length === 0)
1899
- continue;
1900
- lines.push(`## ${impact.charAt(0).toUpperCase() + impact.slice(1)} (${violations.length})`);
1901
- lines.push('');
1902
- for (const v of violations) {
1903
- lines.push(`### ${v.id}`);
1904
- lines.push('');
1905
- lines.push(`**${v.help}**`);
1906
- lines.push('');
1907
- lines.push(v.description);
1908
- lines.push('');
1909
- lines.push(`- Help: ${v.helpUrl}`);
1910
- lines.push(`- Elements affected: ${v.nodes.length}`);
1911
- lines.push('');
1912
- for (const node of v.nodes.slice(0, 10)) {
1913
- const html = node.html.length > 120 ? `${node.html.slice(0, 120)}...` : node.html;
1914
- lines.push(` - \`${html}\``);
1915
- if (node.target.length > 0) {
1916
- lines.push(` Selector: \`${node.target.join(', ')}\``);
1917
- }
1918
- }
1919
- if (v.nodes.length > 10) {
1920
- lines.push(` - ... and ${v.nodes.length - 10} more`);
1921
- }
1922
- lines.push('');
1923
- }
1924
- }
1925
- return lines.join('\n');
1926
- }
1927
1896
  function clearChildren(el) {
1928
1897
  while (el.firstChild) {
1929
1898
  el.removeChild(el.firstChild);
@@ -1968,7 +1937,7 @@ function renderA11yModal(state) {
1968
1937
  // Check modal is still open
1969
1938
  if (!state.showA11yModal)
1970
1939
  return;
1971
- const markdown = formatA11yMarkdown(result);
1940
+ const markdown = a11yToMarkdown(result);
1972
1941
  // Replace modal content
1973
1942
  clearChildren(modal);
1974
1943
  const violationCount = result.violations.length;
@@ -1982,7 +1951,7 @@ function renderA11yModal(state) {
1982
1951
  onCopyMd: async () => {
1983
1952
  await navigator.clipboard.writeText(markdown);
1984
1953
  },
1985
- onSave: () => handleSaveA11yAudit(state, markdown),
1954
+ onSave: () => handleSaveA11yAudit(state, result),
1986
1955
  sweetlinkConnected: state.sweetlinkConnected,
1987
1956
  saveLocation: state.options.saveLocation,
1988
1957
  isSaving: state.savingA11yAudit,
@@ -1995,7 +1964,7 @@ function renderA11yModal(state) {
1995
1964
  Object.assign(successMsg.style, {
1996
1965
  textAlign: 'center',
1997
1966
  padding: '40px',
1998
- color: '#10b981',
1967
+ color: CSS_COLORS.primary,
1999
1968
  fontSize: '0.875rem',
2000
1969
  });
2001
1970
  successMsg.textContent = 'No accessibility violations found!';
@@ -2214,30 +2183,13 @@ function renderDesignReviewConfirmModal(state) {
2214
2183
  const color = BUTTON_COLORS.review;
2215
2184
  const closeModal = () => closeDesignReviewConfirm(state);
2216
2185
  const overlay = createModalOverlay(closeModal);
2217
- // Override z-index for this modal to be above others
2218
- overlay.style.zIndex = '10003';
2219
2186
  const modal = createModalBox(color);
2220
2187
  modal.style.maxWidth = '450px';
2221
- // Header with title and close button
2222
- const header = document.createElement('div');
2223
- Object.assign(header.style, {
2224
- display: 'flex',
2225
- alignItems: 'center',
2226
- justifyContent: 'space-between',
2227
- padding: '14px 18px',
2228
- borderBottom: `1px solid ${color}40`,
2229
- backgroundColor: `${color}15`,
2230
- });
2231
- const title = document.createElement('span');
2232
- Object.assign(title.style, { color, fontSize: '0.875rem', fontWeight: '600' });
2233
- title.textContent = 'AI Design Review';
2234
- header.appendChild(title);
2235
- header.appendChild(createCloseButton(closeModal));
2236
- modal.appendChild(header);
2188
+ // Minimal header (title + close only, no Copy MD / Save)
2189
+ modal.appendChild(createModalHeader({ color, title: 'AI Design Review', onClose: closeModal }));
2237
2190
  // Content
2238
- const content = document.createElement('div');
2191
+ const content = createModalContent();
2239
2192
  Object.assign(content.style, {
2240
- padding: '18px',
2241
2193
  color: CSS_COLORS.text,
2242
2194
  fontSize: '0.8125rem',
2243
2195
  lineHeight: '1.6',
@@ -2252,23 +2204,22 @@ function renderDesignReviewConfirmModal(state) {
2252
2204
  content.appendChild(renderApiKeyConfiguredContent(state));
2253
2205
  }
2254
2206
  modal.appendChild(content);
2255
- // Footer with buttons
2256
- const footer = document.createElement('div');
2257
- Object.assign(footer.style, {
2258
- display: 'flex',
2259
- justifyContent: 'flex-end',
2260
- gap: '10px',
2261
- padding: '14px 18px',
2262
- borderTop: `1px solid ${CSS_COLORS.border}`,
2263
- });
2264
- footer.appendChild(createCloseButton(closeModal, 'Cancel'));
2207
+ // Footer with action button
2265
2208
  if (state.apiKeyStatus?.configured) {
2209
+ const footer = document.createElement('div');
2210
+ Object.assign(footer.style, {
2211
+ display: 'flex',
2212
+ justifyContent: 'flex-end',
2213
+ gap: '10px',
2214
+ padding: '14px 18px',
2215
+ borderTop: `1px solid ${CSS_COLORS.border}`,
2216
+ });
2266
2217
  const proceedBtn = createStyledButton({ color, text: 'Run Review', padding: '8px 16px' });
2267
2218
  proceedBtn.style.backgroundColor = `${color}20`;
2268
2219
  proceedBtn.onclick = () => proceedWithDesignReview(state);
2269
2220
  footer.appendChild(proceedBtn);
2221
+ modal.appendChild(footer);
2270
2222
  }
2271
- modal.appendChild(footer);
2272
2223
  overlay.appendChild(modal);
2273
2224
  state.overlayElement = overlay;
2274
2225
  document.body.appendChild(overlay);
@@ -2356,9 +2307,26 @@ function renderApiKeyConfiguredContent(state) {
2356
2307
  // ============================================================================
2357
2308
  function renderSettingsPopover(state) {
2358
2309
  const { position, accentColor } = state.options;
2310
+ // Transparent overlay for click-outside-to-close (consistent with other modals)
2311
+ const overlay = document.createElement('div');
2312
+ overlay.setAttribute('data-devbar', 'true');
2313
+ overlay.setAttribute('data-devbar-overlay', 'true');
2314
+ Object.assign(overlay.style, {
2315
+ position: 'fixed',
2316
+ top: '0',
2317
+ left: '0',
2318
+ right: '0',
2319
+ bottom: '0',
2320
+ zIndex: '10003',
2321
+ });
2322
+ overlay.onclick = (e) => {
2323
+ if (e.target === overlay) {
2324
+ state.showSettingsPopover = false;
2325
+ state.render();
2326
+ }
2327
+ };
2359
2328
  const popover = document.createElement('div');
2360
2329
  popover.setAttribute('data-devbar', 'true');
2361
- popover.setAttribute('data-devbar-overlay', 'true');
2362
2330
  // Position: centered over the devbar on desktop, centered on screen on mobile
2363
2331
  const isTop = position.startsWith('top');
2364
2332
  const popoverWidth = 480;
@@ -2411,8 +2379,9 @@ function renderSettingsPopover(state) {
2411
2379
  grid.appendChild(rightCol);
2412
2380
  popover.appendChild(grid);
2413
2381
  popover.appendChild(createResetSection(state));
2414
- state.overlayElement = popover;
2415
- document.body.appendChild(popover);
2382
+ overlay.appendChild(popover);
2383
+ state.overlayElement = overlay;
2384
+ document.body.appendChild(overlay);
2416
2385
  }
2417
2386
  // ============================================================================
2418
2387
  // Settings Popover Section Builders
@@ -2443,29 +2412,18 @@ function createSettingsHeader(state) {
2443
2412
  }
2444
2413
  function createThemeSection(state) {
2445
2414
  const { accentColor } = state.options;
2446
- const color = CSS_COLORS.textSecondary;
2447
2415
  const themeSection = createSettingsSection('Theme');
2448
2416
  const themeOptions = document.createElement('div');
2449
2417
  Object.assign(themeOptions.style, { display: 'flex', gap: '6px' });
2450
2418
  const themeModes = ['system', 'dark', 'light'];
2451
2419
  themeModes.forEach((mode) => {
2452
- const btn = document.createElement('button');
2453
- const isActive = state.themeMode === mode;
2454
- Object.assign(btn.style, {
2455
- padding: '4px 10px',
2456
- backgroundColor: isActive ? `${accentColor}20` : 'transparent',
2457
- border: `1px solid ${isActive ? accentColor : `${color}40`}`,
2458
- borderRadius: '4px',
2459
- color: isActive ? accentColor : color,
2460
- fontSize: '0.625rem',
2461
- cursor: 'pointer',
2462
- textTransform: 'capitalize',
2463
- transition: 'all 150ms',
2420
+ const btn = createSettingsRadioButton({
2421
+ label: mode,
2422
+ isActive: state.themeMode === mode,
2423
+ accentColor,
2424
+ onClick: () => setThemeMode(state, mode),
2464
2425
  });
2465
- btn.textContent = mode;
2466
- btn.onclick = () => {
2467
- setThemeMode(state, mode);
2468
- };
2426
+ btn.style.textTransform = 'capitalize';
2469
2427
  themeOptions.appendChild(btn);
2470
2428
  });
2471
2429
  themeSection.appendChild(themeOptions);
@@ -2724,7 +2682,6 @@ function createDisplaySection(state) {
2724
2682
  }
2725
2683
  function createFeaturesSection(state) {
2726
2684
  const { accentColor } = state.options;
2727
- const color = CSS_COLORS.textSecondary;
2728
2685
  const featuresSection = createSettingsSection('Features');
2729
2686
  featuresSection.appendChild(createToggleRow('Screenshot Button', state.options.showScreenshot, accentColor, () => {
2730
2687
  state.options.showScreenshot = !state.options.showScreenshot;
@@ -2760,31 +2717,19 @@ function createFeaturesSection(state) {
2760
2717
  { value: 'local', label: 'Local' },
2761
2718
  ];
2762
2719
  saveLocChoices.forEach(({ value, label }) => {
2763
- const btn = document.createElement('button');
2764
- const isActive = state.options.saveLocation === value;
2765
2720
  const isLocalDisabled = value === 'local' && !state.sweetlinkConnected;
2766
- Object.assign(btn.style, {
2767
- padding: '4px 10px',
2768
- backgroundColor: isActive ? `${accentColor}20` : 'transparent',
2769
- border: `1px solid ${isActive ? accentColor : `${color}40`}`,
2770
- borderRadius: '4px',
2771
- color: isActive ? accentColor : color,
2772
- fontSize: '0.625rem',
2773
- cursor: isLocalDisabled ? 'not-allowed' : 'pointer',
2774
- transition: 'all 150ms',
2775
- opacity: isLocalDisabled ? '0.5' : '1',
2721
+ const btn = createSettingsRadioButton({
2722
+ label,
2723
+ isActive: state.options.saveLocation === value,
2724
+ accentColor,
2725
+ disabled: isLocalDisabled,
2726
+ disabledTitle: 'Sweetlink not connected',
2727
+ onClick: () => {
2728
+ state.options.saveLocation = value;
2729
+ state.settingsManager.saveSettings({ saveLocation: value });
2730
+ state.render();
2731
+ },
2776
2732
  });
2777
- btn.textContent = label;
2778
- if (isLocalDisabled) {
2779
- btn.title = 'Sweetlink not connected';
2780
- }
2781
- btn.onclick = () => {
2782
- if (isLocalDisabled)
2783
- return;
2784
- state.options.saveLocation = value;
2785
- state.settingsManager.saveSettings({ saveLocation: value });
2786
- state.render();
2787
- };
2788
2733
  saveLocOptions.appendChild(btn);
2789
2734
  });
2790
2735
  saveLocRow.appendChild(saveLocOptions);
@@ -2878,6 +2823,43 @@ function createSettingsSection(title, hasBorder = true) {
2878
2823
  section.appendChild(sectionTitle);
2879
2824
  return section;
2880
2825
  }
2826
+ function createSettingsRadioButton(options) {
2827
+ const { label, isActive, accentColor, disabled, disabledTitle, onClick } = options;
2828
+ const color = CSS_COLORS.textSecondary;
2829
+ const btn = document.createElement('button');
2830
+ Object.assign(btn.style, {
2831
+ padding: '4px 10px',
2832
+ backgroundColor: isActive ? `${accentColor}20` : 'transparent',
2833
+ border: `1px solid ${isActive ? accentColor : 'transparent'}`,
2834
+ borderRadius: '4px',
2835
+ color: isActive ? accentColor : color,
2836
+ fontFamily: FONT_MONO,
2837
+ fontSize: '0.625rem',
2838
+ cursor: disabled ? 'not-allowed' : 'pointer',
2839
+ transition: 'all 150ms',
2840
+ opacity: disabled ? '0.5' : '1',
2841
+ });
2842
+ btn.textContent = label;
2843
+ if (disabled) {
2844
+ if (disabledTitle)
2845
+ btn.title = disabledTitle;
2846
+ }
2847
+ else if (!isActive) {
2848
+ btn.onmouseenter = () => {
2849
+ btn.style.borderColor = `${color}`;
2850
+ btn.style.backgroundColor = `${color}10`;
2851
+ };
2852
+ btn.onmouseleave = () => {
2853
+ btn.style.borderColor = 'transparent';
2854
+ btn.style.backgroundColor = 'transparent';
2855
+ };
2856
+ }
2857
+ btn.onclick = () => {
2858
+ if (!disabled)
2859
+ onClick();
2860
+ };
2861
+ return btn;
2862
+ }
2881
2863
  function createToggleRow(label, checked, accentColor, onChange) {
2882
2864
  const row = document.createElement('div');
2883
2865
  Object.assign(row.style, {