From aa2836ba10c75f2c561d8f350ce15e6211e83a7a Mon Sep 17 00:00:00 2001 From: Mathias Elle Date: Tue, 10 Feb 2026 00:40:14 +0100 Subject: [PATCH 1/3] feat: add cache and webvitals tabs to inspector and improve overlay ux --- .../Decorator/InspectorHints.php | 35 +- src/Service/Cache/BlockCacheCollector.php | 181 +++ .../Inspector/Cache/BlockCacheCollector.php | 181 +++ src/etc/frontend/di.xml | 12 + src/view/frontend/web/css/inspector.css | 410 ++++- src/view/frontend/web/js/inspector.js | 1419 +++++++++++++---- 6 files changed, 1915 insertions(+), 323 deletions(-) create mode 100644 src/Service/Cache/BlockCacheCollector.php create mode 100644 src/Service/Inspector/Cache/BlockCacheCollector.php diff --git a/src/Model/TemplateEngine/Decorator/InspectorHints.php b/src/Model/TemplateEngine/Decorator/InspectorHints.php index 58cb927..e738330 100644 --- a/src/Model/TemplateEngine/Decorator/InspectorHints.php +++ b/src/Model/TemplateEngine/Decorator/InspectorHints.php @@ -8,6 +8,7 @@ use Magento\Framework\View\Element\AbstractBlock; use Magento\Framework\View\Element\BlockInterface; use Magento\Framework\View\TemplateEngineInterface; +use OpenForgeProject\MageForge\Service\Inspector\Cache\BlockCacheCollector; /** * Decorates block with inspector data attributes for frontend debugging @@ -22,11 +23,13 @@ class InspectorHints implements TemplateEngineInterface * @param TemplateEngineInterface $subject * @param bool $showBlockHints * @param Random $random + * @param BlockCacheCollector $cacheCollector */ public function __construct( private readonly TemplateEngineInterface $subject, private readonly bool $showBlockHints, - private readonly Random $random + private readonly Random $random, + private readonly BlockCacheCollector $cacheCollector ) { // Get Magento root directory - try multiple strategies // 1. Try from BP constant (most reliable) @@ -49,7 +52,10 @@ public function __construct( */ public function render(BlockInterface $block, $templateFile, array $dictionary = []): string { + // Measure render time + $startTime = hrtime(true); $result = $this->subject->render($block, $templateFile, $dictionary); + $endTime = hrtime(true); if (!$this->showBlockHints) { return $result; @@ -60,7 +66,17 @@ public function render(BlockInterface $block, $templateFile, array $dictionary = return $result; } - return $this->injectInspectorAttributes($result, $block, $templateFile); + // Calculate render time in milliseconds + $renderTimeNs = $endTime - $startTime; + $renderTimeMs = $renderTimeNs / 1_000_000; + + $renderMetrics = [ + 'renderTimeMs' => round($renderTimeMs, 2), + 'startTime' => $startTime, + 'endTime' => $endTime, + ]; + + return $this->injectInspectorAttributes($result, $block, $templateFile, $renderMetrics); } /** @@ -69,10 +85,15 @@ public function render(BlockInterface $block, $templateFile, array $dictionary = * @param string $html * @param BlockInterface $block * @param string $templateFile + * @param array{renderTimeMs: float, startTime: int, endTime: int} $renderMetrics * @return string */ - private function injectInspectorAttributes(string $html, BlockInterface $block, string $templateFile): string - { + private function injectInspectorAttributes( + string $html, + BlockInterface $block, + string $templateFile, + array $renderMetrics + ): string { $wrapperId = 'mageforge-' . $this->random->getRandomString(16); // Get block class name @@ -90,6 +111,10 @@ private function injectInspectorAttributes(string $html, BlockInterface $block, $blockAlias = $this->getBlockAlias($block); $isOverride = $this->isTemplateOverride($templateFile, $moduleName) ? '1' : '0'; + // Collect performance and cache metrics + $cacheMetrics = $this->cacheCollector->getCacheInfo($block); + $formattedMetrics = $this->cacheCollector->formatMetricsForJson($renderMetrics, $cacheMetrics); + // Build metadata as JSON $metadata = [ 'id' => $wrapperId, @@ -100,6 +125,8 @@ private function injectInspectorAttributes(string $html, BlockInterface $block, 'parent' => $parentBlock, 'alias' => $blockAlias, 'override' => $isOverride, + 'performance' => $formattedMetrics['performance'], + 'cache' => $formattedMetrics['cache'], ]; // JSON encode with proper escaping for HTML comments diff --git a/src/Service/Cache/BlockCacheCollector.php b/src/Service/Cache/BlockCacheCollector.php new file mode 100644 index 0000000..7ee2020 --- /dev/null +++ b/src/Service/Cache/BlockCacheCollector.php @@ -0,0 +1,181 @@ +, pageCacheable: bool} + */ + public function getCacheInfo(BlockInterface $block): array + { + $lifetime = null; + $cacheKey = ''; + $cacheTags = []; + $cacheable = false; + + // Type guard: Check if method exists before calling + if (method_exists($block, 'getCacheLifetime')) { + $lifetimeRaw = $block->getCacheLifetime(); + + // In Magento: + // - false = not cacheable + // - null = unlimited cache (cacheable!) + // - int = specific cache lifetime in seconds (cacheable!) + if ($lifetimeRaw !== false) { + $cacheable = true; + // Convert to int or null for type safety + if (is_int($lifetimeRaw)) { + $lifetime = $lifetimeRaw; + } elseif ($lifetimeRaw === null) { + // null = unlimited cache + $lifetime = null; + } elseif (is_numeric($lifetimeRaw) && (int)$lifetimeRaw === 0) { + // 0 = unlimited cache + $lifetime = null; + } + } + } + + // Check if block is private/customer-specific (not cacheable) + // Private blocks (like checkout, customer account) should not be cached + if ($cacheable && method_exists($block, 'isScopePrivate')) { + if ($block->isScopePrivate()) { + $cacheable = false; + $lifetime = null; + } + } + + // Additional fallback: Check protected property via reflection if available + if ($cacheable && property_exists($block, '_isScopePrivate')) { + try { + $reflection = new \ReflectionProperty($block, '_isScopePrivate'); + $reflection->setAccessible(true); + $isScopePrivate = $reflection->getValue($block); + if ($isScopePrivate === true) { + $cacheable = false; + $lifetime = null; + } + } catch (\ReflectionException $e) { + // If reflection fails, keep current cacheable value + } + } + + if (method_exists($block, 'getCacheKey')) { + $keyRaw = $block->getCacheKey(); + $cacheKey = is_string($keyRaw) && $keyRaw !== '' ? $keyRaw : ''; + } + + if (method_exists($block, 'getCacheTags')) { + $tagsRaw = $block->getCacheTags(); + // Ensure string array (PHPStan strict) + if (is_array($tagsRaw)) { + foreach ($tagsRaw as $tag) { + if (is_string($tag)) { + $cacheTags[] = $tag; + } + } + } + } + + // Check if page itself is cacheable + $pageCacheable = $this->isPageCacheable(); + + return [ + 'cacheable' => $cacheable, + 'lifetime' => $lifetime, + 'cacheKey' => $cacheKey, + 'cacheTags' => $cacheTags, + 'pageCacheable' => $pageCacheable, + ]; + } + + /** + * Check if current page is cacheable + * + * Checks layout configuration to determine if page has cacheable="false" attribute. + * If ANY block on the page is marked as non-cacheable in layout XML, the entire page is non-cacheable. + * + * @return bool True if page is cacheable, false otherwise + */ + private function isPageCacheable(): bool + { + try { + // Get all blocks from layout + $allBlocks = $this->layout->getAllBlocks(); + + foreach ($allBlocks as $block) { + // Check if block has isCacheable method (added by layout processor) + if (method_exists($block, 'isCacheable')) { + // @phpstan-ignore-next-line + if (!$block->isCacheable()) { + return false; + } + } + + // Check data key 'cacheable' set by layout XML + if (method_exists($block, 'getData')) { + // @phpstan-ignore-next-line + $cacheableData = $block->getData('cacheable'); + if ($cacheableData === false || $cacheableData === 'false') { + return false; + } + } + } + + return true; + } catch (\Exception $e) { + // If we can't determine, assume cacheable to avoid false alarms + return true; + } + } + + /** + * Format metrics for JSON export to frontend + * + * @param array{renderTimeMs: float, startTime: int, endTime: int} $renderMetrics + * @param array{cacheable: bool, lifetime: int|null, cacheKey: string, cacheTags: array, pageCacheable: bool} $cacheMetrics + * @return array{performance: array{renderTime: string, timestamp: int}, cache: array{cacheable: bool, lifetime: int|null, key: string, tags: array, pageCacheable: bool}} + */ + public function formatMetricsForJson(array $renderMetrics, array $cacheMetrics): array + { + return [ + 'performance' => [ + 'renderTime' => number_format($renderMetrics['renderTimeMs'], 2), + 'timestamp' => (int)($renderMetrics['startTime'] / 1_000_000_000), // Convert ns to seconds + ], + 'cache' => [ + 'cacheable' => $cacheMetrics['cacheable'], + 'lifetime' => $cacheMetrics['lifetime'], + 'key' => $cacheMetrics['cacheKey'], + 'tags' => $cacheMetrics['cacheTags'], + 'pageCacheable' => $cacheMetrics['pageCacheable'], + ], + ]; + } +} diff --git a/src/Service/Inspector/Cache/BlockCacheCollector.php b/src/Service/Inspector/Cache/BlockCacheCollector.php new file mode 100644 index 0000000..101b662 --- /dev/null +++ b/src/Service/Inspector/Cache/BlockCacheCollector.php @@ -0,0 +1,181 @@ +, pageCacheable: bool} + */ + public function getCacheInfo(BlockInterface $block): array + { + $lifetime = null; + $cacheKey = ''; + $cacheTags = []; + $cacheable = false; + + // Type guard: Check if method exists before calling + if (method_exists($block, 'getCacheLifetime')) { + $lifetimeRaw = $block->getCacheLifetime(); + + // In Magento: + // - false = not cacheable + // - null = unlimited cache (cacheable!) + // - int = specific cache lifetime in seconds (cacheable!) + if ($lifetimeRaw !== false) { + $cacheable = true; + // Convert to int or null for type safety + if (is_int($lifetimeRaw)) { + $lifetime = $lifetimeRaw; + } elseif ($lifetimeRaw === null) { + // null = unlimited cache + $lifetime = null; + } elseif (is_numeric($lifetimeRaw) && (int)$lifetimeRaw === 0) { + // 0 = unlimited cache + $lifetime = null; + } + } + } + + // Check if block is private/customer-specific (not cacheable) + // Private blocks (like checkout, customer account) should not be cached + if ($cacheable && method_exists($block, 'isScopePrivate')) { + if ($block->isScopePrivate()) { + $cacheable = false; + $lifetime = null; + } + } + + // Additional fallback: Check protected property via reflection if available + if ($cacheable && property_exists($block, '_isScopePrivate')) { + try { + $reflection = new \ReflectionProperty($block, '_isScopePrivate'); + $reflection->setAccessible(true); + $isScopePrivate = $reflection->getValue($block); + if ($isScopePrivate === true) { + $cacheable = false; + $lifetime = null; + } + } catch (\ReflectionException $e) { + // If reflection fails, keep current cacheable value + } + } + + if (method_exists($block, 'getCacheKey')) { + $keyRaw = $block->getCacheKey(); + $cacheKey = is_string($keyRaw) && $keyRaw !== '' ? $keyRaw : ''; + } + + if (method_exists($block, 'getCacheTags')) { + $tagsRaw = $block->getCacheTags(); + // Ensure string array (PHPStan strict) + if (is_array($tagsRaw)) { + foreach ($tagsRaw as $tag) { + if (is_string($tag)) { + $cacheTags[] = $tag; + } + } + } + } + + // Check if page itself is cacheable + $pageCacheable = $this->isPageCacheable(); + + return [ + 'cacheable' => $cacheable, + 'lifetime' => $lifetime, + 'cacheKey' => $cacheKey, + 'cacheTags' => $cacheTags, + 'pageCacheable' => $pageCacheable, + ]; + } + + /** + * Check if current page is cacheable + * + * Checks layout configuration to determine if page has cacheable="false" attribute. + * If ANY block on the page is marked as non-cacheable in layout XML, the entire page is non-cacheable. + * + * @return bool True if page is cacheable, false otherwise + */ + private function isPageCacheable(): bool + { + try { + // Get all blocks from layout + $allBlocks = $this->layout->getAllBlocks(); + + foreach ($allBlocks as $block) { + // Check if block has isCacheable method (added by layout processor) + if (method_exists($block, 'isCacheable')) { + // @phpstan-ignore-next-line + if (!$block->isCacheable()) { + return false; + } + } + + // Check data key 'cacheable' set by layout XML + if (method_exists($block, 'getData')) { + // @phpstan-ignore-next-line + $cacheableData = $block->getData('cacheable'); + if ($cacheableData === false || $cacheableData === 'false') { + return false; + } + } + } + + return true; + } catch (\Exception $e) { + // If we can't determine, assume cacheable to avoid false alarms + return true; + } + } + + /** + * Format metrics for JSON export to frontend + * + * @param array{renderTimeMs: float, startTime: int, endTime: int} $renderMetrics + * @param array{cacheable: bool, lifetime: int|null, cacheKey: string, cacheTags: array, pageCacheable: bool} $cacheMetrics + * @return array{performance: array{renderTime: string, timestamp: int}, cache: array{cacheable: bool, lifetime: int|null, key: string, tags: array, pageCacheable: bool}} + */ + public function formatMetricsForJson(array $renderMetrics, array $cacheMetrics): array + { + return [ + 'performance' => [ + 'renderTime' => number_format($renderMetrics['renderTimeMs'], 2), + 'timestamp' => (int)($renderMetrics['startTime'] / 1_000_000_000), // Convert ns to seconds + ], + 'cache' => [ + 'cacheable' => $cacheMetrics['cacheable'], + 'lifetime' => $cacheMetrics['lifetime'], + 'key' => $cacheMetrics['cacheKey'], + 'tags' => $cacheMetrics['cacheTags'], + 'pageCacheable' => $cacheMetrics['pageCacheable'], + ], + ]; + } +} diff --git a/src/etc/frontend/di.xml b/src/etc/frontend/di.xml index 6b7a1eb..6074203 100644 --- a/src/etc/frontend/di.xml +++ b/src/etc/frontend/di.xml @@ -3,6 +3,18 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd" > + + + + + + + + + OpenForgeProject\MageForge\Service\Inspector\Cache\BlockCacheCollector + + + { module: '', }, + // Dragging & Connector State + isDragging: false, + dragStartX: 0, + dragStartY: 0, + initialBadgeX: 0, + initialBadgeY: 0, + connectorSvg: null, + dragHandler: null, + dragEndHandler: null, + + // Performance Thresholds + PERF_RENDER_TIME_GOOD: 50, // ms + PERF_RENDER_TIME_WARNING: 200, // ms + PERF_DOM_COMPLEXITY_LOW: 50, // nodes + PERF_DOM_COMPLEXITY_HIGH: 200, // nodes + PERF_DOM_DEPTH_WARNING: 10, // levels + + // Browser Metrics tracking + webVitals: { + lcp: null, + cls: [], + inp: null, + fcp: null, + elementTimings: [] // Element Timing API results + }, + longTasks: [], + resourceMetrics: null, + pageTimings: null, + + // Feature Discovery + MAX_NEW_BADGE_VIEWS: 5, + featureViews: { + 'performance': 0, + 'core-web-vitals': 0 + }, + init() { // Bind event handlers to preserve context this.mouseMoveHandler = (e) => this.handleMouseMove(e); @@ -40,11 +76,43 @@ document.addEventListener('alpine:init', () => { this.createHighlightBox(); this.createInfoBadge(); this.createFloatingButton(); + this.initWebVitalsTracking(); + this.cachePageTimings(); + this.loadFeatureViews(); // Dispatch init event for Hyvรค integration this.$dispatch('mageforge:inspector:init'); }, + loadFeatureViews() { + try { + const stored = localStorage.getItem('mageforge_feature_views'); + if (stored) { + this.featureViews = { ...this.featureViews, ...JSON.parse(stored) }; + } + } catch (e) { + console.warn('MageForge: Failed to load feature views', e); + } + }, + + incrementFeatureViews() { + let changed = false; + ['performance', 'core-web-vitals'].forEach(feature => { + if (this.featureViews[feature] < this.MAX_NEW_BADGE_VIEWS) { + this.featureViews[feature]++; + changed = true; + } + }); + + if (changed) { + try { + localStorage.setItem('mageforge_feature_views', JSON.stringify(this.featureViews)); + } catch (e) { + // Ignore storage errors + } + } + }, + /** * Parse MageForge comment markers in DOM */ @@ -201,17 +269,7 @@ document.addEventListener('alpine:init', () => { createHighlightBox() { this.highlightBox = document.createElement('div'); this.highlightBox.className = 'mageforge-inspector mageforge-inspector-highlight'; - - // Add inline styles to ensure it's visible - this.highlightBox.style.cssText = ` - position: absolute; - background: rgba(59, 130, 246, 0.2); - border: 2px solid rgb(59, 130, 246); - pointer-events: none; - z-index: 9999999; - display: none; - box-sizing: border-box; - `; + this.highlightBox.style.display = 'none'; document.body.appendChild(this.highlightBox); }, @@ -222,43 +280,11 @@ document.addEventListener('alpine:init', () => { createInfoBadge() { this.infoBadge = document.createElement('div'); this.infoBadge.className = 'mageforge-inspector mageforge-inspector-info-badge'; + this.infoBadge.style.display = 'none'; - // Modern Tailwind-style design - this.infoBadge.style.cssText = ` - position: absolute; - background: linear-gradient(135deg, rgba(15, 23, 42, 0.98) 0%, rgba(30, 41, 59, 0.98) 100%); - backdrop-filter: blur(12px); - color: white; - padding: 16px; - border-radius: 12px; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; - font-size: 11px; - line-height: 1.6; - pointer-events: auto; - z-index: 10000000; - display: none; - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.05); - min-width: 320px; - max-width: 520px; - word-wrap: break-word; - border: 1px solid rgba(148, 163, 184, 0.15); - `; - - // Create arrow element with modern styling + // Create arrow element const arrow = document.createElement('div'); arrow.className = 'mageforge-inspector-arrow'; - arrow.style.cssText = ` - position: absolute; - top: -8px; - left: 24px; - width: 0; - height: 0; - border-left: 8px solid transparent; - border-right: 8px solid transparent; - border-bottom: 8px solid rgba(15, 23, 42, 0.98); - pointer-events: none; - filter: drop-shadow(0 -2px 4px rgba(0,0,0,0.2)); - `; this.infoBadge.appendChild(arrow); document.body.appendChild(this.infoBadge); @@ -276,32 +302,6 @@ document.addEventListener('alpine:init', () => { // Generate unique ID for SVG gradient to avoid collisions const gradientId = 'mageforge-gradient-' + Math.random().toString(36).substr(2, 9); - // Modern floating button design - this.floatingButton.style.cssText = ` - position: fixed; - bottom: 20px; - left: 20px; - height: 36px; - padding: 0 14px; - background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); - color: white; - border: none; - border-radius: 10px; - cursor: pointer; - z-index: 9999998; - display: flex; - align-items: center; - gap: 8px; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - font-size: 12px; - font-weight: 600; - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4), 0 2px 4px rgba(0, 0, 0, 0.2); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - pointer-events: auto; - backdrop-filter: blur(8px); - letter-spacing: 0.025em; - `; - // Icon + Text with unique gradient ID this.floatingButton.innerHTML = ` @@ -321,19 +321,6 @@ document.addEventListener('alpine:init', () => { MageForge Inspector `; - // Hover effect - this.floatingButton.onmouseenter = () => { - this.floatingButton.style.transform = 'translateY(-2px)'; - this.floatingButton.style.boxShadow = '0 8px 20px rgba(59, 130, 246, 0.5), 0 4px 8px rgba(0, 0, 0, 0.3)'; - }; - - this.floatingButton.onmouseleave = () => { - if (!this.isOpen) { - this.floatingButton.style.transform = 'translateY(0)'; - this.floatingButton.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.4), 0 2px 4px rgba(0, 0, 0, 0.2)'; - } - }; - // Click to toggle inspector this.floatingButton.onclick = (e) => { e.preventDefault(); @@ -353,15 +340,9 @@ document.addEventListener('alpine:init', () => { if (this.isOpen) { // Active state this.floatingButton.classList.add('mageforge-active'); - this.floatingButton.style.background = 'linear-gradient(135deg, #10b981 0%, #059669 100%)'; - this.floatingButton.style.transform = 'translateY(-2px)'; - this.floatingButton.style.boxShadow = '0 0 0 3px rgba(16, 185, 129, 0.2), 0 8px 20px rgba(16, 185, 129, 0.5)'; } else { // Inactive state this.floatingButton.classList.remove('mageforge-active'); - this.floatingButton.style.background = 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)'; - this.floatingButton.style.transform = 'translateY(0)'; - this.floatingButton.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.4), 0 2px 4px rgba(0, 0, 0, 0.2)'; } }, @@ -388,6 +369,7 @@ document.addEventListener('alpine:init', () => { closeInspector() { this.isOpen = false; this.isPinned = false; + this.removeDraggable(); this.deactivatePicker(); this.hideHighlight(); this.$dispatch('mageforge:inspector:closed'); @@ -478,6 +460,7 @@ document.addEventListener('alpine:init', () => { this.showHighlight(element); this.updatePanelData(element); this.showInfoBadge(element); + this.incrementFeatureViews(); }, this.hoverDelay); } else if (!element && this.hoveredElement) { // Only hide highlight when leaving element, keep badge visible @@ -533,6 +516,7 @@ document.addEventListener('alpine:init', () => { if (this.selectedElement) { this.buildBadgeContent(this.selectedElement); } + this.setupDraggable(); }, /** @@ -540,6 +524,7 @@ document.addEventListener('alpine:init', () => { */ unpinBadge() { this.isPinned = false; + this.removeDraggable(); this.hideHighlight(); this.selectedElement = null; @@ -692,42 +677,6 @@ document.addEventListener('alpine:init', () => { closeBtn.className = 'mageforge-inspector-close'; closeBtn.innerHTML = 'โœ•'; closeBtn.title = 'Close (or click outside)'; - closeBtn.style.cssText = ` - position: absolute; - top: 12px; - right: 12px; - width: 28px; - height: 28px; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 6px; - color: #94a3b8; - font-size: 16px; - font-weight: 600; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s ease; - z-index: 10; - font-family: inherit; - line-height: 1; - padding: 0; - `; - - closeBtn.onmouseenter = () => { - closeBtn.style.background = 'rgba(239, 68, 68, 0.15)'; - closeBtn.style.borderColor = 'rgba(239, 68, 68, 0.3)'; - closeBtn.style.color = '#ef4444'; - closeBtn.style.transform = 'scale(1.05)'; - }; - - closeBtn.onmouseleave = () => { - closeBtn.style.background = 'rgba(255, 255, 255, 0.05)'; - closeBtn.style.borderColor = 'rgba(255, 255, 255, 0.1)'; - closeBtn.style.color = '#94a3b8'; - closeBtn.style.transform = 'scale(1)'; - }; closeBtn.onclick = (e) => { e.preventDefault(); @@ -744,25 +693,18 @@ document.addEventListener('alpine:init', () => { createTabSystem(data, element) { // Tab container const tabContainer = document.createElement('div'); - tabContainer.style.cssText = 'margin-bottom: 16px;'; + tabContainer.className = 'mageforge-tabs-container'; // Tab header const tabHeader = document.createElement('div'); - tabHeader.style.cssText = ` - display: flex; - gap: 4px; - margin-bottom: 16px; - border-bottom: 1px solid rgba(148, 163, 184, 0.15); - `; + tabHeader.className = 'mageforge-tabs-header'; // Define tabs const tabs = [ { id: 'structure', label: 'Structure', icon: '๐Ÿฐ' }, { id: 'accessibility', label: 'Accessibility', icon: 'โ™ฟ' }, - { id: 'coming-soon', label: 'Coming Soon', icon: '๐Ÿš€' } - // Future tabs can be added here: - // { id: 'performance', label: 'Performance', icon: 'โšก' }, - // { id: 'seo', label: 'SEO', icon: '๐Ÿ”' }, + { id: 'performance', label: 'Cache', icon: '๐Ÿ’พ' }, + { id: 'core-web-vitals', label: 'Core Web Vitals', icon: '๐ŸŒ' } ]; // Tab content container @@ -772,35 +714,21 @@ document.addEventListener('alpine:init', () => { tabs.forEach(tab => { const button = document.createElement('button'); button.type = 'button'; - button.textContent = tab.label; - button.style.cssText = ` - padding: 8px 12px; - background: ${this.activeTab === tab.id ? 'rgba(59, 130, 246, 0.15)' : 'transparent'}; - color: ${this.activeTab === tab.id ? '#60a5fa' : '#94a3b8'}; - border: none; - border-bottom: 2px solid ${this.activeTab === tab.id ? '#60a5fa' : 'transparent'}; - cursor: pointer; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.025em; - transition: all 0.2s ease; - border-radius: 6px 6px 0 0; - font-family: inherit; - `; - - button.onmouseenter = () => { - if (this.activeTab !== tab.id) { - button.style.background = 'rgba(255, 255, 255, 0.05)'; - button.style.color = '#cbd5e1'; - } - }; - - button.onmouseleave = () => { - if (this.activeTab !== tab.id) { - button.style.background = 'transparent'; - button.style.color = '#94a3b8'; - } - }; + button.className = 'mageforge-tab-button' + (this.activeTab === tab.id ? ' active' : ''); + + // Label text + const textSpan = document.createElement('span'); + textSpan.textContent = tab.label; + button.appendChild(textSpan); + + // Show "New" badge for Performance and Core Web Vitals if seen < 5 times + if (['performance', 'core-web-vitals'].includes(tab.id) && + (this.featureViews[tab.id] || 0) < this.MAX_NEW_BADGE_VIEWS) { + const badge = document.createElement('span'); + badge.className = 'mageforge-badge-new'; + badge.textContent = 'NEW'; + button.appendChild(badge); + } button.onclick = (e) => { e.preventDefault(); @@ -842,8 +770,10 @@ document.addEventListener('alpine:init', () => { this.renderStructureTab(data, container, element); } else if (tabId === 'accessibility') { this.renderAccessibilityTab(container, element); - } else if (tabId === 'coming-soon') { - this.renderComingSoonTab(container); + } else if (tabId === 'performance') { + this.renderPerformanceTab(container, element); + } else if (tabId === 'core-web-vitals') { + this.renderBrowserMetricsTab(container, element); } }, @@ -905,18 +835,7 @@ document.addEventListener('alpine:init', () => { // Inheritance note const inheritanceNote = document.createElement('div'); - inheritanceNote.style.cssText = ` - background: rgba(251, 191, 36, 0.1); - border: 1px solid rgba(251, 191, 36, 0.3); - border-radius: 8px; - padding: 10px 12px; - margin-bottom: 16px; - font-size: 11px; - color: #fbbf24; - display: flex; - align-items: center; - gap: 8px; - `; + inheritanceNote.className = 'mageforge-inheritance-note'; inheritanceNote.innerHTML = ` โฌ†๏ธ
@@ -934,18 +853,12 @@ document.addEventListener('alpine:init', () => { */ renderNoTemplateData(container, element) { const noDataDiv = document.createElement('div'); - noDataDiv.style.cssText = ` - text-align: center; - padding: 24px 16px; - color: #94a3b8; - font-size: 12px; - line-height: 1.6; - `; + noDataDiv.className = 'mageforge-no-data'; noDataDiv.innerHTML = ` -
๐Ÿ“‹
-
No Template Data
-
This element is not inside a Magento template block
-
Element: <${element.tagName.toLowerCase()}>
+
๐Ÿ“‹
+
No Template Data
+
This element is not inside a Magento template block
+
Element: <${element.tagName.toLowerCase()}>
`; container.appendChild(noDataDiv); }, @@ -1062,61 +975,760 @@ document.addEventListener('alpine:init', () => { }, /** - * Render Coming Soon tab content + * Render Browser Metrics tab content (element-specific) + * + * @param {HTMLElement} container - Tab content container + * @param {HTMLElement|null} element - Inspected element + * @return {void} */ - renderComingSoonTab(container) { - // Coming Soon content - const comingSoonDiv = document.createElement('div'); - comingSoonDiv.style.cssText = ` - text-align: center; - padding: 24px 16px; - color: #94a3b8; - font-size: 12px; - line-height: 1.6; - `; - comingSoonDiv.innerHTML = ` -
๐Ÿš€
-
Coming Soon
-
MageForge is currently building something wonderful for you.
- `; + renderBrowserMetricsTab(container, element) { + if (!element) { + this.renderNoBrowserMetrics(container); + return; + } - // Feature Request Button - const featureButton = document.createElement('a'); - featureButton.href = 'https://github.com/OpenForgeProject/mageforge/issues/new?template=feature_request.md'; - featureButton.target = '_blank'; - featureButton.rel = 'noopener noreferrer'; - featureButton.style.cssText = ` - display: inline-flex; - align-items: center; - gap: 8px; - padding: 10px 16px; - background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); - color: white; - text-decoration: none; - border-radius: 8px; - font-size: 12px; - font-weight: 600; - transition: all 0.2s ease; - cursor: pointer; - border: 1px solid rgba(59, 130, 246, 0.3); - `; - featureButton.innerHTML = ` - ๐Ÿ’ก - Request a Feature + let hasMetrics = false; + + // Render Time (from block metadata) + const blockData = this.getBlockMetaData(element); + if (blockData && blockData.performance) { + const renderTime = parseFloat(blockData.performance.renderTime); + const color = this.getRenderTimeColor(renderTime); + const formattedTime = `${blockData.performance.renderTime} ms`; + container.appendChild(this.createInfoSection('โฑ๏ธ Render Time', formattedTime, color)); + hasMetrics = true; + } + + // LCP - only if this element IS or CONTAINS the LCP element + if (this.webVitals.lcp && this.webVitals.lcp.element) { + const isLCP = this.webVitals.lcp.element === element || element.contains(this.webVitals.lcp.element); + if (isLCP) { + const lcpValue = this.webVitals.lcp.value.toFixed(0); + const lcpColor = lcpValue < 2500 ? '#34d399' : (lcpValue < 4000 ? '#f59e0b' : '#ef4444'); + container.appendChild( + this.createInfoSection('๐ŸŽฏ LCP (Largest Contentful Paint)', `${lcpValue} ms`, lcpColor) + ); + container.appendChild( + this.createInfoSection('โšก LCP Element', 'โœ… This element is critical for LCP!', '#ef4444') + ); + hasMetrics = true; + } + } + + // CLS - only shifts affecting this element + const elementCLS = this.getElementCLS(element); + if (elementCLS > 0) { + const clsColor = elementCLS < 0.1 ? '#34d399' : (elementCLS < 0.25 ? '#f59e0b' : '#ef4444'); + container.appendChild( + this.createInfoSection('๐Ÿ“ CLS (Layout Shift)', elementCLS.toFixed(3), clsColor) + ); + // Add Layout Stability Score (inverse of CLS) + const stabilityScore = Math.max(0, (1 - elementCLS * 4)).toFixed(2); // 0.25 CLS = 0 stability + const stabilityColor = stabilityScore > 0.75 ? '#34d399' : (stabilityScore > 0.5 ? '#f59e0b' : '#ef4444'); + container.appendChild( + this.createInfoSection('โš–๏ธ Layout Stability Score', stabilityScore, stabilityColor) + ); + hasMetrics = true; + } + + // INP - only for interactive elements + const isInteractive = this.checkIfInteractive(element, element.tagName.toLowerCase(), element.getAttribute('role')); + if (isInteractive && this.webVitals.inp) { + const inpValue = this.webVitals.inp.duration.toFixed(0); + const inpColor = inpValue < 200 ? '#34d399' : (inpValue < 500 ? '#f59e0b' : '#ef4444'); + container.appendChild( + this.createInfoSection('โŒจ๏ธ INP (Interaction)', `${inpValue} ms`, inpColor) + ); + hasMetrics = true; + } + + // Element Timing - for elements with elementtiming attribute + const elementTiming = this.getElementTiming(element); + if (elementTiming) { + const timingValue = (elementTiming.renderTime || elementTiming.loadTime).toFixed(0); + const timingColor = timingValue < 2500 ? '#34d399' : (timingValue < 4000 ? '#f59e0b' : '#ef4444'); + container.appendChild( + this.createInfoSection('โฐ Element Timing', `${timingValue} ms (${elementTiming.identifier})`, timingColor) + ); + hasMetrics = true; + } + + // Image Optimization Analysis + const imageAnalysis = this.analyzeImageOptimization(element); + if (imageAnalysis) { + // Modern formats score + const modernScore = imageAnalysis.totalImages > 0 + ? (imageAnalysis.modernFormats / imageAnalysis.totalImages * 100).toFixed(0) + : 0; + const modernColor = modernScore > 75 ? '#34d399' : (modernScore > 25 ? '#f59e0b' : '#ef4444'); + container.appendChild( + this.createInfoSection('๐Ÿ–ผ๏ธ Modern Image Formats', `${modernScore}% (${imageAnalysis.modernFormats}/${imageAnalysis.totalImages})`, modernColor) + ); + + // Responsive images + const responsiveScore = imageAnalysis.totalImages > 0 + ? (imageAnalysis.hasResponsive / imageAnalysis.totalImages * 100).toFixed(0) + : 0; + const responsiveColor = responsiveScore > 75 ? '#34d399' : (responsiveScore > 25 ? '#f59e0b' : '#ef4444'); + const responsiveText = `${imageAnalysis.hasResponsive} of ${imageAnalysis.totalImages} ${imageAnalysis.totalImages === 1 ? 'image uses' : 'images use'} srcset`; + container.appendChild( + this.createInfoSection('๐Ÿ“ฑ Adaptive Images (srcset)', responsiveText, responsiveColor) + ); + + // Oversized images warning + if (imageAnalysis.oversized > 0) { + container.appendChild( + this.createInfoSection('โš ๏ธ Oversized Images', `${imageAnalysis.oversized} oversized`, '#ef4444') + ); + } + + // Show first 3 issues + if (imageAnalysis.issues.length > 0) { + const issuesText = imageAnalysis.issues.slice(0, 3).join(' โ€ข '); + const moreText = imageAnalysis.issues.length > 3 ? ` (+${imageAnalysis.issues.length - 3} more)` : ''; + container.appendChild( + this.createInfoSection('๐Ÿ’ก Optimization Tips', issuesText + moreText, '#f59e0b') + ); + } + + hasMetrics = true; + } + + // Resources loaded by this element + const elementResources = this.getElementResources(element); + if (elementResources.count > 0) { + this.renderElementResourceMetrics(container, elementResources); + hasMetrics = true; + } + + // If no element-specific metrics + if (!hasMetrics) { + this.renderNoBrowserMetrics(container); + } + }, + + /** + * Render no browser metrics message + */ + renderNoBrowserMetrics(container) { + const noDataDiv = document.createElement('div'); + noDataDiv.className = 'mageforge-no-data'; + noDataDiv.innerHTML = ` +
๐ŸŒ
+
No Element-Specific Metrics
+
This element has no measurable browser performance impact
`; + container.appendChild(noDataDiv); + }, + + /** + * Get CLS (Cumulative Layout Shift) for specific element + * + * @param {HTMLElement} element + * @return {number} + */ + getElementCLS(element) { + if (!this.webVitals.cls || this.webVitals.cls.length === 0) { + return 0; + } - featureButton.onmouseenter = () => { - featureButton.style.transform = 'translateY(-2px)'; - featureButton.style.boxShadow = '0 8px 16px rgba(59, 130, 246, 0.4)'; + let totalCLS = 0; + this.webVitals.cls.forEach(shift => { + if (shift.sources) { + shift.sources.forEach(source => { + if (source.node === element || element.contains(source.node) || source.node.contains(element)) { + totalCLS += shift.value; + } + }); + } + }); + + return totalCLS; + }, + + /** + * Get Element Timing for specific element + * + * @param {HTMLElement} element + * @return {object|null} + */ + getElementTiming(element) { + if (!this.webVitals.elementTimings || this.webVitals.elementTimings.length === 0) { + return null; + } + + // Check if this element or any child has element timing + const timing = this.webVitals.elementTimings.find(et => + et.element === element || element.contains(et.element) + ); + + return timing || null; + }, + + /** + * Get resources loaded by element (images, scripts, stylesheets) + * + * @param {HTMLElement} element + * @return {{count: number, size: number, byType: object, items: array}} + */ + getElementResources(element) { + const result = { + count: 0, + size: 0, + byType: { script: 0, css: 0, img: 0, font: 0, other: 0 }, + items: [] }; - featureButton.onmouseleave = () => { - featureButton.style.transform = 'translateY(0)'; - featureButton.style.boxShadow = 'none'; + // Get all resource URLs from element and children + const resourceUrls = new Set(); + + // Images + const images = [element, ...element.querySelectorAll('img')]; + images.forEach(img => { + if (img.tagName === 'IMG' && img.src) { + resourceUrls.add(img.src); + } + }); + + // Scripts + const scripts = element.querySelectorAll('script[src]'); + scripts.forEach(script => { + if (script.src) { + resourceUrls.add(script.src); + } + }); + + // Stylesheets + const links = element.querySelectorAll('link[rel="stylesheet"]'); + links.forEach(link => { + if (link.href) { + resourceUrls.add(link.href); + } + }); + + // Videos + const videos = element.querySelectorAll('video[src], source[src]'); + videos.forEach(video => { + if (video.src) { + resourceUrls.add(video.src); + } + }); + + // Get performance entries for these resources + const allResources = performance.getEntriesByType('resource'); + resourceUrls.forEach(url => { + const resource = allResources.find(r => r.name === url); + if (resource) { + result.count++; + result.size += resource.transferSize || 0; + result.items.push(resource); + + // Categorize + if (resource.name.match(/\.(js|mjs)$/)) result.byType.script++; + else if (resource.name.includes('.css')) result.byType.css++; + else if (resource.name.match(/\.(jpg|jpeg|png|gif|webp|svg|avif)$/i)) result.byType.img++; + else if (resource.name.match(/\.(woff2?|ttf|otf|eot)$/i)) result.byType.font++; + else result.byType.other++; + } + }); + + return result; + }, + + /** + * Render resource metrics for specific element + * + * @param {HTMLElement} container + * @param {object} resourceData + */ + renderElementResourceMetrics(container, resourceData) { + // Format size + let sizeText = ''; + if (resourceData.size < 1024) { + sizeText = `${resourceData.size} B`; + } else if (resourceData.size < 1024 * 1024) { + sizeText = `${(resourceData.size / 1024).toFixed(1)} KB`; + } else { + sizeText = `${(resourceData.size / (1024 * 1024)).toFixed(2)} MB`; + } + + // Determine resource type label (smart singular/plural) + let resourceLabel = ''; + const hasImages = resourceData.byType.img > 0; + const hasScripts = resourceData.byType.script > 0; + const hasCss = resourceData.byType.css > 0; + const hasFonts = resourceData.byType.font > 0; + const hasOther = resourceData.byType.other > 0; + const typeCount = (hasImages ? 1 : 0) + (hasScripts ? 1 : 0) + (hasCss ? 1 : 0) + (hasFonts ? 1 : 0) + (hasOther ? 1 : 0); + + if (typeCount === 1) { + // Only one type of resource - use specific label + if (hasImages) { + resourceLabel = resourceData.byType.img === 1 ? 'Image' : 'Images'; + } else if (hasScripts) { + resourceLabel = resourceData.byType.script === 1 ? 'Script' : 'Scripts'; + } else if (hasCss) { + resourceLabel = resourceData.byType.css === 1 ? 'Stylesheet' : 'Stylesheets'; + } else if (hasFonts) { + resourceLabel = resourceData.byType.font === 1 ? 'Font' : 'Fonts'; + } else if (hasOther) { + resourceLabel = resourceData.byType.other === 1 ? 'Resource' : 'Resources'; + } + } else { + // Multiple types - use generic label + resourceLabel = resourceData.count === 1 ? 'Resource' : 'Resources'; + } + + container.appendChild( + this.createInfoSection('๐Ÿ“ฆ Element Resources', `${resourceData.count} ${resourceLabel} (${sizeText})`, '#60a5fa') + ); + + // Show breakdown only if multiple different types + if (typeCount > 1) { + const types = []; + if (resourceData.byType.img > 0) types.push(`Images: ${resourceData.byType.img}`); + if (resourceData.byType.script > 0) types.push(`JS: ${resourceData.byType.script}`); + if (resourceData.byType.css > 0) types.push(`CSS: ${resourceData.byType.css}`); + if (resourceData.byType.font > 0) types.push(`Fonts: ${resourceData.byType.font}`); + if (resourceData.byType.other > 0) types.push(`Other: ${resourceData.byType.other}`); + + container.appendChild( + this.createInfoSection('๐Ÿ“‘ Resource Types', types.join(', '), '#a78bfa') + ); + } + }, + + /** + * Render Performance tab content + * + * @param {HTMLElement} container - Tab content container + * @param {HTMLElement|null} element - Inspected element + * @return {void} + */ + renderPerformanceTab(container, element) { + // Guard: No element + if (!element) { + this.renderNoPerformanceData(container); + return; + } + + // Get block metadata (may be null) + const blockData = this.getBlockMetaData(element); + + // Guard: No block data or missing cache data + if (!blockData || !blockData.cache) { + this.renderNoPerformanceData(container); + return; + } + + // Render cache section only + this.renderCacheSection(container, blockData.cache); + }, + + /** + * Render "No Performance Data" message + */ + renderNoPerformanceData(container) { + const noDataDiv = document.createElement('div'); + noDataDiv.className = 'mageforge-no-data'; + noDataDiv.innerHTML = ` +
โšก
+
No Performance Data
+
This element is not inside a Magento template block
+ `; + container.appendChild(noDataDiv); + }, + + /** + * Render render time section + */ + renderRenderTimeSection(container, performanceData) { + const renderTime = parseFloat(performanceData.renderTime); + const color = this.getRenderTimeColor(renderTime); + const formattedTime = `${performanceData.renderTime} ms`; + + container.appendChild(this.createInfoSection('โฑ๏ธ Render Time', formattedTime, color)); + }, + + /** + * Render cache section + */ + renderCacheSection(container, cacheData) { + // Page-level cache warning (if page is not cacheable) + if (cacheData.pageCacheable === false) { + const warningDiv = document.createElement('div'); + warningDiv.className = 'mageforge-warning-box'; + warningDiv.innerHTML = ` + โš ๏ธ +
+
Page Not Cacheable
+
This entire page cannot be cached (layout XML: cacheable="false")
+
Block settings below are overridden by page-level config
+
+ `; + container.appendChild(warningDiv); + } + + // Block-level cacheable status + const cacheableText = cacheData.cacheable ? 'โœ… Yes' : 'โŒ No'; + const cacheableColor = cacheData.cacheable ? '#34d399' : '#94a3b8'; + const cacheableLabel = cacheData.pageCacheable === false ? '๐Ÿ’พ Block Cacheable (ignored)' : '๐Ÿ’พ Block Cacheable'; + container.appendChild(this.createInfoSection(cacheableLabel, cacheableText, cacheableColor)); + + // Cache lifetime (show for all cacheable blocks) + if (cacheData.cacheable) { + const lifetimeText = (cacheData.lifetime === null || cacheData.lifetime === 0) + ? 'Unlimited' + : `${cacheData.lifetime}s`; + container.appendChild(this.createInfoSection('โณ Cache Lifetime', lifetimeText, '#60a5fa')); + } + + // Cache key + if (cacheData.key && cacheData.key !== '') { + container.appendChild(this.createInfoSection('๐Ÿ”‘ Cache Key', cacheData.key, '#a78bfa')); + } + + // Cache tags + if (cacheData.tags && cacheData.tags.length > 0) { + const tagsText = cacheData.tags.join(', '); + container.appendChild(this.createInfoSection('๐Ÿท๏ธ Cache Tags', tagsText, '#22d3ee')); + } + }, + + /** + * Render DOM complexity section + */ + renderDOMComplexitySection(container, element) { + const complexity = this.calculateDOMComplexity(element); + const rating = this.getComplexityRating(complexity); + + // Child count + container.appendChild( + this.createInfoSection('๐Ÿ“Š Child Nodes', complexity.childCount.toString(), '#60a5fa') + ); + + // Tree depth + const depthColor = complexity.depth > this.PERF_DOM_DEPTH_WARNING ? '#f59e0b' : '#34d399'; + container.appendChild( + this.createInfoSection('๐ŸŒณ Tree Depth', complexity.depth.toString(), depthColor) + ); + + // Total nodes + const totalColor = rating === 'high' ? '#ef4444' : (rating === 'medium' ? '#f59e0b' : '#34d399'); + container.appendChild( + this.createInfoSection('๐Ÿ”ข Total Nodes', complexity.totalNodes.toString(), totalColor) + ); + + // Complexity rating + const ratingEmoji = rating === 'low' ? 'โœ…' : (rating === 'medium' ? 'โš ๏ธ' : 'โŒ'); + const ratingText = `${ratingEmoji} ${rating.toUpperCase()}`; + container.appendChild( + this.createInfoSection('๐Ÿ“ˆ Complexity', ratingText, totalColor) + ); + }, + + /** + * Render Web Vitals section + */ + renderWebVitalsSection(container, element) { + const vitalsInfo = this.getWebVitalsForElement(element); + + if (vitalsInfo.isLCP) { + container.appendChild( + this.createInfoSection('๐ŸŽฏ LCP Element', 'โœ… Yes - Performance Critical!', '#ef4444') + ); + } + + if (vitalsInfo.contributesCLS && vitalsInfo.contributesCLS > 0) { + container.appendChild( + this.createInfoSection('๐Ÿ“ CLS Impact', `${vitalsInfo.contributesCLS.toFixed(3)}`, '#f59e0b') + ); + } + + if (!vitalsInfo.isLCP && !vitalsInfo.contributesCLS) { + container.appendChild( + this.createInfoSection('โœจ Web Vitals', 'Not Critical', '#94a3b8') + ); + } + }, + + /** + * Render page timings section + */ + renderPageTimingsSection(container) { + if (!this.pageTimings) { + return; + } + + container.appendChild( + this.createInfoSection('๐Ÿ“„ DOMContentLoaded', `${this.pageTimings.domContentLoaded} ms`, '#60a5fa') + ); + + container.appendChild( + this.createInfoSection('๐ŸŒ Page Load', `${this.pageTimings.loadComplete} ms`, '#a78bfa') + ); + }, + + // ============================================================================ + // Performance Analysis Utilities + // ============================================================================ + + /** + * Initialize Web Vitals tracking + */ + initWebVitalsTracking() { + // Check if PerformanceObserver is supported + if (!('PerformanceObserver' in window)) { + console.warn('[MageForge Inspector] PerformanceObserver not supported'); + return; + } + + try { + // Largest Contentful Paint (LCP) + const lcpObserver = new PerformanceObserver((list) => { + const entries = list.getEntries(); + const lastEntry = entries[entries.length - 1]; + this.webVitals.lcp = { + element: lastEntry.element, + value: lastEntry.renderTime || lastEntry.loadTime, + time: lastEntry.startTime + }; + }); + lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true }); + + // Cumulative Layout Shift (CLS) + const clsObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (!entry.hadRecentInput) { + this.webVitals.cls.push({ + value: entry.value, + time: entry.startTime, + sources: entry.sources || [] + }); + } + } + }); + clsObserver.observe({ type: 'layout-shift', buffered: true }); + + // Interaction to Next Paint (INP) - via first-input as fallback + const inpObserver = new PerformanceObserver((list) => { + const entries = list.getEntries(); + if (entries.length > 0) { + const firstEntry = entries[0]; + this.webVitals.inp = { + delay: firstEntry.processingStart - firstEntry.startTime, + duration: firstEntry.duration, + time: firstEntry.startTime + }; + } + }); + inpObserver.observe({ type: 'first-input', buffered: true }); + + // First Contentful Paint (FCP) + const paintObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (entry.name === 'first-contentful-paint') { + this.webVitals.fcp = { + value: entry.startTime, + time: entry.startTime + }; + } + } + }); + paintObserver.observe({ type: 'paint', buffered: true }); + + // Long Tasks (>50ms) + const longTaskObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + this.longTasks.push({ + duration: entry.duration, + startTime: entry.startTime, + attribution: entry.attribution || [] + }); + } + }); + longTaskObserver.observe({ type: 'longtask', buffered: true }); + + // Element Timing API - for elements with elementtiming attribute + const elementTimingObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + this.webVitals.elementTimings.push({ + element: entry.element, + identifier: entry.identifier, + renderTime: entry.renderTime, + loadTime: entry.loadTime, + startTime: entry.startTime + }); + } + }); + elementTimingObserver.observe({ type: 'element', buffered: true }); + } catch (e) { + console.warn('[MageForge Inspector] Performance tracking failed:', e); + } + }, + + /** + * Cache page timing metrics + */ + cachePageTimings() { + // Try modern Navigation Timing API first + const navEntries = performance.getEntriesByType('navigation'); + if (navEntries && navEntries.length > 0) { + const nav = navEntries[0]; + this.pageTimings = { + domContentLoaded: Math.round(nav.domContentLoadedEventEnd - nav.domContentLoadedEventStart), + loadComplete: Math.round(nav.loadEventEnd - nav.fetchStart) + }; + } else if (performance.timing) { + // Fallback to older API + const timing = performance.timing; + this.pageTimings = { + domContentLoaded: Math.round(timing.domContentLoadedEventEnd - timing.navigationStart), + loadComplete: Math.round(timing.loadEventEnd - timing.navigationStart) + }; + } + }, + + /** + * Calculate DOM complexity metrics + * + * @param {HTMLElement} element - The element to analyze + * @return {{childCount: number, depth: number, totalNodes: number}} + */ + calculateDOMComplexity(element) { + if (!element || !(element instanceof HTMLElement)) { + return { childCount: 0, depth: 0, totalNodes: 0 }; + } + + const childCount = element.childElementCount; + const totalNodes = element.querySelectorAll('*').length; + const depth = this.getMaxDepth(element); + + return { childCount, depth, totalNodes }; + }, + + /** + * Get maximum depth of element tree + * + * @param {HTMLElement} element + * @param {number} currentDepth + * @return {number} + * @private + */ + getMaxDepth(element, currentDepth = 0) { + if (!element.children.length) { + return currentDepth; + } + + let maxChildDepth = currentDepth; + for (const child of element.children) { + const depth = this.getMaxDepth(child, currentDepth + 1); + maxChildDepth = Math.max(maxChildDepth, depth); + } + + return maxChildDepth; + }, + + /** + * Get complexity rating based on total nodes + * + * @param {{childCount: number, depth: number, totalNodes: number}} complexity + * @return {string} 'low' | 'medium' | 'high' + */ + getComplexityRating(complexity) { + if (complexity.totalNodes < this.PERF_DOM_COMPLEXITY_LOW) { + return 'low'; + } else if (complexity.totalNodes < this.PERF_DOM_COMPLEXITY_HIGH) { + return 'medium'; + } else { + return 'high'; + } + }, + + /** + * Get Web Vitals information for specific element + * + * @param {HTMLElement} element + * @return {{isLCP: boolean, contributesCLS: number, isInteractive: boolean}} + */ + getWebVitalsForElement(element) { + const result = { + isLCP: false, + contributesCLS: 0, + isInteractive: false }; - comingSoonDiv.appendChild(featureButton); - container.appendChild(comingSoonDiv); + // Check if element is LCP candidate + if (this.webVitals.lcp && this.webVitals.lcp.element) { + result.isLCP = this.webVitals.lcp.element === element || + element.contains(this.webVitals.lcp.element); + } + + // Calculate CLS contribution + if (this.webVitals.cls && this.webVitals.cls.length > 0) { + this.webVitals.cls.forEach(shift => { + if (shift.sources) { + shift.sources.forEach(source => { + if (source.node === element || element.contains(source.node)) { + result.contributesCLS += shift.value; + } + }); + } + }); + } + + return result; + }, + + /** + * Get color for render time based on thresholds + * + * @param {number} renderTimeMs + * @return {string} Color hex code + */ + getRenderTimeColor(renderTimeMs) { + if (renderTimeMs < this.PERF_RENDER_TIME_GOOD) { + return '#34d399'; // Green + } else if (renderTimeMs < this.PERF_RENDER_TIME_WARNING) { + return '#f59e0b'; // Orange/Yellow + } else { + return '#ef4444'; // Red + } + }, + + /** + * Get block metadata with performance data for element + * + * @param {HTMLElement} element + * @return {Object|null} Block data with performance and cache info + */ + getBlockMetaData(element) { + const block = this.findBlockForElement(element); + if (!block || !block.data) { + return null; + } + + const data = block.data; + + // Type validation for performance data + const hasPerformanceData = + data.performance && + typeof data.performance.renderTime === 'string' && + typeof data.performance.timestamp === 'number'; + + // Type validation for cache data + const hasCacheData = + data.cache && + typeof data.cache.cacheable === 'boolean' && + (data.cache.lifetime === null || typeof data.cache.lifetime === 'number') && + typeof data.cache.key === 'string' && + Array.isArray(data.cache.tags); + + if (!hasPerformanceData || !hasCacheData) { + return null; + } + + return data; }, /** @@ -1266,22 +1878,81 @@ document.addEventListener('alpine:init', () => { return false; }, + /** + * Analyze image optimization for element + * + * @param {HTMLElement} element + * @return {object|null} Image optimization metrics + */ + analyzeImageOptimization(element) { + // Find all images in/on element + const images = element.tagName === 'IMG' ? [element] : Array.from(element.querySelectorAll('img')); + if (images.length === 0) return null; + + const analysis = { + totalImages: images.length, + modernFormats: 0, + hasResponsive: 0, + oversized: 0, + issues: [] + }; + + images.forEach((img, idx) => { + const src = img.currentSrc || img.src; + if (!src) return; + + // Check modern formats (WebP, AVIF) + if (src.match(/\.(webp|avif)$/i)) { + analysis.modernFormats++; + } else if (src.match(/\.(jpg|jpeg|png|gif)$/i)) { + analysis.issues.push(`Image ${idx + 1}: Consider WebP/AVIF format`); + } + + // Check responsive images + if (img.hasAttribute('srcset') || img.hasAttribute('sizes')) { + analysis.hasResponsive++; + } else if (img.width > 400) { + analysis.issues.push(`Image ${idx + 1}: Missing srcset for responsive optimization`); + } + + // Check oversizing (rendered size vs natural size) + if (img.naturalWidth && img.width) { + const oversizeRatio = img.naturalWidth / img.width; + if (oversizeRatio > 1.5) { + analysis.oversized++; + const wastedPercent = Math.round((1 - 1/oversizeRatio) * 100); + analysis.issues.push(`Image ${idx + 1}: ${wastedPercent}% oversized (${img.naturalWidth}px served, ${img.width}px displayed)`); + } + } + }); + + return analysis; + }, + /** * Create branding footer */ createBrandingFooter() { const brandingDiv = document.createElement('div'); - brandingDiv.style.cssText = ` - margin-top: 16px; - padding-top: 12px; - border-top: 1px solid rgba(148, 163, 184, 0.12); - text-align: center; - font-size: 10px; - color: #94a3b8; - font-weight: 500; - letter-spacing: 0.025em; - `; - brandingDiv.innerHTML = 'Made with ๐Ÿงก by MageForge'; + brandingDiv.className = 'mageforge-branding-footer'; + + const madeWithDiv = document.createElement('div'); + madeWithDiv.innerHTML = 'Made with ๐Ÿงก by MageForge'; + brandingDiv.appendChild(madeWithDiv); + + const featureLinkDiv = document.createElement('div'); + featureLinkDiv.className = 'mageforge-feature-link-container'; + + const featureLink = document.createElement('a'); + featureLink.href = 'https://github.com/OpenForgeProject/mageforge/issues/new?template=feature_request.md'; + featureLink.target = '_blank'; + featureLink.rel = 'noopener noreferrer'; + featureLink.innerHTML = 'You miss a Feature?'; + featureLink.className = 'mageforge-feature-link'; + + featureLinkDiv.appendChild(featureLink); + brandingDiv.appendChild(featureLinkDiv); + return brandingDiv; }, @@ -1375,49 +2046,38 @@ document.addEventListener('alpine:init', () => { */ createInfoSection(title, text, titleColor) { const container = document.createElement('div'); - container.style.cssText = 'margin-bottom: 12px;'; + container.className = 'mageforge-info-section'; const titleDiv = document.createElement('div'); - titleDiv.style.cssText = `color: ${titleColor}; font-weight: 600; margin-bottom: 6px; font-size: 11px; letter-spacing: 0.025em; text-transform: uppercase; opacity: 0.9;`; + // Map common hex colors to classes, fallback to class + const colorMap = { + '#60a5fa': 'mageforge-text-blue', + '#a78bfa': 'mageforge-text-purple', + '#34d399': 'mageforge-text-green', + '#fb923c': 'mageforge-text-orange', + '#22d3ee': 'mageforge-text-cyan', + '#fbbf24': 'mageforge-text-yellow', + '#ef4444': 'mageforge-text-red', + '#f59e0b': 'mageforge-text-amber', + '#94a3b8': 'mageforge-text-gray' + }; + const colorClass = colorMap[titleColor] || 'mageforge-text-gray'; + + titleDiv.className = `mageforge-info-title ${colorClass}`; titleDiv.textContent = title; + // Handle custom color if not in map (e.g. from dynamic score) + if (!colorMap[titleColor] && titleColor && titleColor.startsWith('#')) { + titleDiv.style.color = titleColor; + } + const textSpan = document.createElement('span'); - textSpan.style.cssText = ` - color: #f1f5f9; - font-size: 12px; - word-break: break-all; - cursor: pointer; - display: inline-block; - transition: all 0.2s ease; - padding: 6px 10px; - background: rgba(255, 255, 255, 0.03); - border-radius: 6px; - border: 1px solid rgba(255, 255, 255, 0.08); - width: 100%; - box-sizing: border-box; - font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; - pointer-events: auto; - `; + textSpan.className = 'mageforge-info-value'; textSpan.textContent = text; textSpan.title = 'Click to copy'; const originalText = text; - // Hover effect - textSpan.onmouseenter = () => { - textSpan.style.background = 'rgba(255, 255, 255, 0.06)'; - textSpan.style.borderColor = 'rgba(255, 255, 255, 0.15)'; - textSpan.style.transform = 'translateY(-1px)'; - }; - - textSpan.onmouseleave = () => { - if (textSpan.textContent !== 'copied!') { - textSpan.style.background = 'rgba(255, 255, 255, 0.03)'; - textSpan.style.borderColor = 'rgba(255, 255, 255, 0.08)'; - textSpan.style.transform = 'translateY(0)'; - } - }; - // Click to copy textSpan.onclick = (e) => { e.preventDefault(); @@ -1427,16 +2087,10 @@ document.addEventListener('alpine:init', () => { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(originalText).then(() => { textSpan.textContent = 'copied!'; - textSpan.style.color = '#10b981'; - textSpan.style.background = 'rgba(16, 185, 129, 0.1)'; - textSpan.style.borderColor = 'rgba(16, 185, 129, 0.3)'; - textSpan.style.fontWeight = '600'; + textSpan.classList.add('copied'); setTimeout(() => { textSpan.textContent = originalText; - textSpan.style.color = '#f1f5f9'; - textSpan.style.background = 'rgba(255, 255, 255, 0.03)'; - textSpan.style.borderColor = 'rgba(255, 255, 255, 0.08)'; - textSpan.style.fontWeight = 'normal'; + textSpan.classList.remove('copied'); }, 1500); }).catch(() => { this.legacyCopy(originalText, textSpan); @@ -1469,36 +2123,193 @@ document.addEventListener('alpine:init', () => { const success = document.execCommand('copy'); if (success) { element.textContent = 'copied!'; - element.style.color = '#10b981'; - element.style.background = 'rgba(16, 185, 129, 0.1)'; - element.style.borderColor = 'rgba(16, 185, 129, 0.3)'; - element.style.fontWeight = '600'; + element.classList.add('copied'); setTimeout(() => { element.textContent = originalText; - element.style.color = '#f1f5f9'; - element.style.background = 'rgba(255, 255, 255, 0.03)'; - element.style.borderColor = 'rgba(255, 255, 255, 0.08)'; - element.style.fontWeight = 'normal'; + element.classList.remove('copied'); }, 1500); } else { throw new Error('Copy failed'); } } catch (err) { element.textContent = 'failed'; - element.style.color = '#ef4444'; - element.style.background = 'rgba(239, 68, 68, 0.1)'; - element.style.borderColor = 'rgba(239, 68, 68, 0.3)'; + element.classList.add('copy-failed'); setTimeout(() => { element.textContent = originalText; - element.style.color = '#f1f5f9'; - element.style.background = 'rgba(255, 255, 255, 0.03)'; - element.style.borderColor = 'rgba(255, 255, 255, 0.08)'; + element.classList.remove('copy-failed'); }, 1500); } document.body.removeChild(textarea); }, + // ============================================================================ + // Draggable & Connector Logic + // ============================================================================ + + /** + * Enable dragging for the badge + */ + setupDraggable() { + if (!this.infoBadge) return; + + this.infoBadge.classList.add('draggable'); + + // Bind handlers + this.dragStartHandler = (e) => this.handleDragStart(e); + this.dragHandler = (e) => this.handleDrag(e); + this.dragEndHandler = (e) => this.handleDragEnd(e); + + this.infoBadge.addEventListener('mousedown', this.dragStartHandler); + }, + + /** + * Disable dragging + */ + removeDraggable() { + if (!this.infoBadge) return; + + this.infoBadge.classList.remove('draggable'); + this.infoBadge.removeEventListener('mousedown', this.dragStartHandler); + document.removeEventListener('mousemove', this.dragHandler); + document.removeEventListener('mouseup', this.dragEndHandler); + + this.removeConnector(); + }, + + /** + * Handle drag start + */ + handleDragStart(e) { + // Ignore clicks on close button or content internal interactive elements + if (e.target.closest('button') || e.target.closest('.mageforge-info-value')) { + return; + } + + this.isDragging = true; + this.dragStartX = e.clientX; + this.dragStartY = e.clientY; + + const rect = this.infoBadge.getBoundingClientRect(); + this.initialBadgeX = rect.left; + this.initialBadgeY = rect.top; + + // Remove static arrow + const arrow = this.infoBadge.querySelector('.mageforge-inspector-arrow'); + if (arrow) arrow.style.display = 'none'; + + // Create connector + this.createConnector(); + + // Bind global move/up handlers + document.addEventListener('mousemove', this.dragHandler); + document.addEventListener('mouseup', this.dragEndHandler); + }, + + /** + * Handle dragging movement + */ + handleDrag(e) { + if (!this.isDragging) return; + + const deltaX = e.clientX - this.dragStartX; + const deltaY = e.clientY - this.dragStartY; + + const newX = this.initialBadgeX + deltaX; + const newY = this.initialBadgeY + deltaY; + + this.infoBadge.style.left = `${newX}px`; + this.infoBadge.style.top = `${newY}px`; + this.infoBadge.style.transform = 'none'; // reset any potential transform + + this.updateConnector(); + }, + + /** + * Handle drag end + */ + handleDragEnd() { + this.isDragging = false; + document.removeEventListener('mousemove', this.dragHandler); + document.removeEventListener('mouseup', this.dragEndHandler); + }, + + /** + * Create SVG connector + */ + createConnector() { + if (this.connectorSvg) return; + + const ns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(ns, 'svg'); + svg.classList.add('mageforge-connector-svg'); + + // Line + const line = document.createElementNS(ns, 'line'); + line.classList.add('mageforge-connector-line'); + svg.appendChild(line); + + // Dot on element + const dot = document.createElementNS(ns, 'circle'); + dot.classList.add('mageforge-connector-dot'); + dot.setAttribute('r', '4'); + svg.appendChild(dot); + + document.body.appendChild(svg); + this.connectorSvg = svg; + this.updateConnector(); + }, + + /** + * Remove connector + */ + removeConnector() { + if (this.connectorSvg) { + this.connectorSvg.remove(); + this.connectorSvg = null; + } + // Restore static arrow if it exists (for next time) + if (this.infoBadge) { + const arrow = this.infoBadge.querySelector('.mageforge-inspector-arrow'); + if (arrow) arrow.style.display = ''; + } + }, + + /** + * Update connector position + */ + updateConnector() { + if (!this.connectorSvg || !this.selectedElement || !this.infoBadge) return; + + // Get badge center + const badgeRect = this.infoBadge.getBoundingClientRect(); + const badgeX = badgeRect.left + badgeRect.width / 2; + const badgeY = badgeRect.top + badgeRect.height / 2; + + // Get element center (using highlight box as proxy if available, or selectedElement) + let targetRect; + if (this.highlightBox && this.highlightBox.style.display !== 'none') { + targetRect = this.highlightBox.getBoundingClientRect(); + } else { + targetRect = this.getElementRect(this.selectedElement); + } + + const targetX = targetRect.left + targetRect.width / 2; + const targetY = targetRect.top + targetRect.height / 2; + + // Update line + const line = this.connectorSvg.querySelector('line'); + line.setAttribute('x1', badgeX); + line.setAttribute('y1', badgeY); + line.setAttribute('x2', targetX); + line.setAttribute('y2', targetY); + + // Update dot + const dot = this.connectorSvg.querySelector('circle'); + dot.setAttribute('cx', targetX); + dot.setAttribute('cy', targetY); + }, + /** * Update panel with element data */ From 11b214954669c6365f871af1c0bb798b559715ea Mon Sep 17 00:00:00 2001 From: Mathias Elle Date: Tue, 10 Feb 2026 00:48:09 +0100 Subject: [PATCH 2/3] refactor: improve cache info retrieval and metrics rendering logic --- src/Service/Cache/BlockCacheCollector.php | 181 ------------------ .../Inspector/Cache/BlockCacheCollector.php | 142 +++++++++----- src/view/frontend/web/js/inspector.js | 127 ++++++------ 3 files changed, 169 insertions(+), 281 deletions(-) delete mode 100644 src/Service/Cache/BlockCacheCollector.php diff --git a/src/Service/Cache/BlockCacheCollector.php b/src/Service/Cache/BlockCacheCollector.php deleted file mode 100644 index 7ee2020..0000000 --- a/src/Service/Cache/BlockCacheCollector.php +++ /dev/null @@ -1,181 +0,0 @@ -, pageCacheable: bool} - */ - public function getCacheInfo(BlockInterface $block): array - { - $lifetime = null; - $cacheKey = ''; - $cacheTags = []; - $cacheable = false; - - // Type guard: Check if method exists before calling - if (method_exists($block, 'getCacheLifetime')) { - $lifetimeRaw = $block->getCacheLifetime(); - - // In Magento: - // - false = not cacheable - // - null = unlimited cache (cacheable!) - // - int = specific cache lifetime in seconds (cacheable!) - if ($lifetimeRaw !== false) { - $cacheable = true; - // Convert to int or null for type safety - if (is_int($lifetimeRaw)) { - $lifetime = $lifetimeRaw; - } elseif ($lifetimeRaw === null) { - // null = unlimited cache - $lifetime = null; - } elseif (is_numeric($lifetimeRaw) && (int)$lifetimeRaw === 0) { - // 0 = unlimited cache - $lifetime = null; - } - } - } - - // Check if block is private/customer-specific (not cacheable) - // Private blocks (like checkout, customer account) should not be cached - if ($cacheable && method_exists($block, 'isScopePrivate')) { - if ($block->isScopePrivate()) { - $cacheable = false; - $lifetime = null; - } - } - - // Additional fallback: Check protected property via reflection if available - if ($cacheable && property_exists($block, '_isScopePrivate')) { - try { - $reflection = new \ReflectionProperty($block, '_isScopePrivate'); - $reflection->setAccessible(true); - $isScopePrivate = $reflection->getValue($block); - if ($isScopePrivate === true) { - $cacheable = false; - $lifetime = null; - } - } catch (\ReflectionException $e) { - // If reflection fails, keep current cacheable value - } - } - - if (method_exists($block, 'getCacheKey')) { - $keyRaw = $block->getCacheKey(); - $cacheKey = is_string($keyRaw) && $keyRaw !== '' ? $keyRaw : ''; - } - - if (method_exists($block, 'getCacheTags')) { - $tagsRaw = $block->getCacheTags(); - // Ensure string array (PHPStan strict) - if (is_array($tagsRaw)) { - foreach ($tagsRaw as $tag) { - if (is_string($tag)) { - $cacheTags[] = $tag; - } - } - } - } - - // Check if page itself is cacheable - $pageCacheable = $this->isPageCacheable(); - - return [ - 'cacheable' => $cacheable, - 'lifetime' => $lifetime, - 'cacheKey' => $cacheKey, - 'cacheTags' => $cacheTags, - 'pageCacheable' => $pageCacheable, - ]; - } - - /** - * Check if current page is cacheable - * - * Checks layout configuration to determine if page has cacheable="false" attribute. - * If ANY block on the page is marked as non-cacheable in layout XML, the entire page is non-cacheable. - * - * @return bool True if page is cacheable, false otherwise - */ - private function isPageCacheable(): bool - { - try { - // Get all blocks from layout - $allBlocks = $this->layout->getAllBlocks(); - - foreach ($allBlocks as $block) { - // Check if block has isCacheable method (added by layout processor) - if (method_exists($block, 'isCacheable')) { - // @phpstan-ignore-next-line - if (!$block->isCacheable()) { - return false; - } - } - - // Check data key 'cacheable' set by layout XML - if (method_exists($block, 'getData')) { - // @phpstan-ignore-next-line - $cacheableData = $block->getData('cacheable'); - if ($cacheableData === false || $cacheableData === 'false') { - return false; - } - } - } - - return true; - } catch (\Exception $e) { - // If we can't determine, assume cacheable to avoid false alarms - return true; - } - } - - /** - * Format metrics for JSON export to frontend - * - * @param array{renderTimeMs: float, startTime: int, endTime: int} $renderMetrics - * @param array{cacheable: bool, lifetime: int|null, cacheKey: string, cacheTags: array, pageCacheable: bool} $cacheMetrics - * @return array{performance: array{renderTime: string, timestamp: int}, cache: array{cacheable: bool, lifetime: int|null, key: string, tags: array, pageCacheable: bool}} - */ - public function formatMetricsForJson(array $renderMetrics, array $cacheMetrics): array - { - return [ - 'performance' => [ - 'renderTime' => number_format($renderMetrics['renderTimeMs'], 2), - 'timestamp' => (int)($renderMetrics['startTime'] / 1_000_000_000), // Convert ns to seconds - ], - 'cache' => [ - 'cacheable' => $cacheMetrics['cacheable'], - 'lifetime' => $cacheMetrics['lifetime'], - 'key' => $cacheMetrics['cacheKey'], - 'tags' => $cacheMetrics['cacheTags'], - 'pageCacheable' => $cacheMetrics['pageCacheable'], - ], - ]; - } -} diff --git a/src/Service/Inspector/Cache/BlockCacheCollector.php b/src/Service/Inspector/Cache/BlockCacheCollector.php index 101b662..4bc223b 100644 --- a/src/Service/Inspector/Cache/BlockCacheCollector.php +++ b/src/Service/Inspector/Cache/BlockCacheCollector.php @@ -34,63 +34,123 @@ public function __construct( */ public function getCacheInfo(BlockInterface $block): array { - $lifetime = null; - $cacheKey = ''; - $cacheTags = []; - $cacheable = false; - - // Type guard: Check if method exists before calling - if (method_exists($block, 'getCacheLifetime')) { - $lifetimeRaw = $block->getCacheLifetime(); - - // In Magento: - // - false = not cacheable - // - null = unlimited cache (cacheable!) - // - int = specific cache lifetime in seconds (cacheable!) - if ($lifetimeRaw !== false) { - $cacheable = true; - // Convert to int or null for type safety - if (is_int($lifetimeRaw)) { - $lifetime = $lifetimeRaw; - } elseif ($lifetimeRaw === null) { - // null = unlimited cache - $lifetime = null; - } elseif (is_numeric($lifetimeRaw) && (int)$lifetimeRaw === 0) { - // 0 = unlimited cache - $lifetime = null; - } - } + $lifetime = $this->resolveCacheLifetime($block); + $cacheable = $lifetime !== false; + + if ($cacheable && $this->isBlockScopePrivate($block)) { + $cacheable = false; + $lifetime = null; + } + + $cacheKey = $this->resolveCacheKey($block); + $cacheTags = $this->resolveCacheTags($block); + + // Check if page itself is cacheable + $pageCacheable = $this->isPageCacheable(); + + return [ + 'cacheable' => $cacheable, + 'lifetime' => $lifetime === false ? null : $lifetime, + 'cacheKey' => $cacheKey, + 'cacheTags' => $cacheTags, + 'pageCacheable' => $pageCacheable, + ]; + } + + /** + * Resolve cache lifetime from block + * + * @param BlockInterface $block + * @return int|null|false False if not cacheable, null for unlimited, int for specific lifetime + */ + private function resolveCacheLifetime(BlockInterface $block): int|null|false + { + if (!method_exists($block, 'getCacheLifetime')) { + return false; + } + + $lifetimeRaw = $block->getCacheLifetime(); + + // In Magento: + // - false = not cacheable + // - null = unlimited cache (cacheable!) + // - int = specific cache lifetime in seconds (cacheable!) + + if ($lifetimeRaw === false) { + return false; + } + + if (is_int($lifetimeRaw)) { + return $lifetimeRaw; + } + + if ($lifetimeRaw === null) { + return null; // Unlimited + } + + if (is_numeric($lifetimeRaw) && (int)$lifetimeRaw === 0) { + return null; // Unlimited } - // Check if block is private/customer-specific (not cacheable) + return false; // Default fallback + } + + /** + * Check if block is private (customer specific) + * + * @param BlockInterface $block + * @return bool + */ + private function isBlockScopePrivate(BlockInterface $block): bool + { // Private blocks (like checkout, customer account) should not be cached - if ($cacheable && method_exists($block, 'isScopePrivate')) { + if (method_exists($block, 'isScopePrivate')) { if ($block->isScopePrivate()) { - $cacheable = false; - $lifetime = null; + return true; } } // Additional fallback: Check protected property via reflection if available - if ($cacheable && property_exists($block, '_isScopePrivate')) { + if (property_exists($block, '_isScopePrivate')) { try { $reflection = new \ReflectionProperty($block, '_isScopePrivate'); $reflection->setAccessible(true); $isScopePrivate = $reflection->getValue($block); if ($isScopePrivate === true) { - $cacheable = false; - $lifetime = null; + return true; } } catch (\ReflectionException $e) { - // If reflection fails, keep current cacheable value + // If reflection fails, assume not private } } + return false; + } + + /** + * Resolve cache key from block + * + * @param BlockInterface $block + * @return string + */ + private function resolveCacheKey(BlockInterface $block): string + { if (method_exists($block, 'getCacheKey')) { $keyRaw = $block->getCacheKey(); - $cacheKey = is_string($keyRaw) && $keyRaw !== '' ? $keyRaw : ''; + return is_string($keyRaw) && $keyRaw !== '' ? $keyRaw : ''; } + return ''; + } + /** + * Resolve cache tags from block + * + * @param BlockInterface $block + * @return array + */ + private function resolveCacheTags(BlockInterface $block): array + { + $cacheTags = []; if (method_exists($block, 'getCacheTags')) { $tagsRaw = $block->getCacheTags(); // Ensure string array (PHPStan strict) @@ -102,17 +162,7 @@ public function getCacheInfo(BlockInterface $block): array } } } - - // Check if page itself is cacheable - $pageCacheable = $this->isPageCacheable(); - - return [ - 'cacheable' => $cacheable, - 'lifetime' => $lifetime, - 'cacheKey' => $cacheKey, - 'cacheTags' => $cacheTags, - 'pageCacheable' => $pageCacheable, - ]; + return $cacheTags; } /** diff --git a/src/view/frontend/web/js/inspector.js b/src/view/frontend/web/js/inspector.js index 6f4c4f4..4061c70 100644 --- a/src/view/frontend/web/js/inspector.js +++ b/src/view/frontend/web/js/inspector.js @@ -989,17 +989,32 @@ document.addEventListener('alpine:init', () => { let hasMetrics = false; - // Render Time (from block metadata) + if (this.renderRenderTimeMetric(container, element)) hasMetrics = true; + if (this.renderLCPMetric(container, element)) hasMetrics = true; + if (this.renderCLSMetric(container, element)) hasMetrics = true; + if (this.renderINPMetric(container, element)) hasMetrics = true; + if (this.renderElementTimingMetric(container, element)) hasMetrics = true; + if (this.renderImageOptimizationMetric(container, element)) hasMetrics = true; + if (this.renderResourceMetric(container, element)) hasMetrics = true; + + if (!hasMetrics) { + this.renderNoBrowserMetrics(container); + } + }, + + renderRenderTimeMetric(container, element) { const blockData = this.getBlockMetaData(element); if (blockData && blockData.performance) { const renderTime = parseFloat(blockData.performance.renderTime); const color = this.getRenderTimeColor(renderTime); const formattedTime = `${blockData.performance.renderTime} ms`; container.appendChild(this.createInfoSection('โฑ๏ธ Render Time', formattedTime, color)); - hasMetrics = true; + return true; } + return false; + }, - // LCP - only if this element IS or CONTAINS the LCP element + renderLCPMetric(container, element) { if (this.webVitals.lcp && this.webVitals.lcp.element) { const isLCP = this.webVitals.lcp.element === element || element.contains(this.webVitals.lcp.element); if (isLCP) { @@ -1011,27 +1026,30 @@ document.addEventListener('alpine:init', () => { container.appendChild( this.createInfoSection('โšก LCP Element', 'โœ… This element is critical for LCP!', '#ef4444') ); - hasMetrics = true; + return true; } } + return false; + }, - // CLS - only shifts affecting this element + renderCLSMetric(container, element) { const elementCLS = this.getElementCLS(element); if (elementCLS > 0) { const clsColor = elementCLS < 0.1 ? '#34d399' : (elementCLS < 0.25 ? '#f59e0b' : '#ef4444'); container.appendChild( this.createInfoSection('๐Ÿ“ CLS (Layout Shift)', elementCLS.toFixed(3), clsColor) ); - // Add Layout Stability Score (inverse of CLS) - const stabilityScore = Math.max(0, (1 - elementCLS * 4)).toFixed(2); // 0.25 CLS = 0 stability + const stabilityScore = Math.max(0, (1 - elementCLS * 4)).toFixed(2); const stabilityColor = stabilityScore > 0.75 ? '#34d399' : (stabilityScore > 0.5 ? '#f59e0b' : '#ef4444'); container.appendChild( this.createInfoSection('โš–๏ธ Layout Stability Score', stabilityScore, stabilityColor) ); - hasMetrics = true; + return true; } + return false; + }, - // INP - only for interactive elements + renderINPMetric(container, element) { const isInteractive = this.checkIfInteractive(element, element.tagName.toLowerCase(), element.getAttribute('role')); if (isInteractive && this.webVitals.inp) { const inpValue = this.webVitals.inp.duration.toFixed(0); @@ -1039,10 +1057,12 @@ document.addEventListener('alpine:init', () => { container.appendChild( this.createInfoSection('โŒจ๏ธ INP (Interaction)', `${inpValue} ms`, inpColor) ); - hasMetrics = true; + return true; } + return false; + }, - // Element Timing - for elements with elementtiming attribute + renderElementTimingMetric(container, element) { const elementTiming = this.getElementTiming(element); if (elementTiming) { const timingValue = (elementTiming.renderTime || elementTiming.loadTime).toFixed(0); @@ -1050,13 +1070,14 @@ document.addEventListener('alpine:init', () => { container.appendChild( this.createInfoSection('โฐ Element Timing', `${timingValue} ms (${elementTiming.identifier})`, timingColor) ); - hasMetrics = true; + return true; } + return false; + }, - // Image Optimization Analysis + renderImageOptimizationMetric(container, element) { const imageAnalysis = this.analyzeImageOptimization(element); if (imageAnalysis) { - // Modern formats score const modernScore = imageAnalysis.totalImages > 0 ? (imageAnalysis.modernFormats / imageAnalysis.totalImages * 100).toFixed(0) : 0; @@ -1065,7 +1086,6 @@ document.addEventListener('alpine:init', () => { this.createInfoSection('๐Ÿ–ผ๏ธ Modern Image Formats', `${modernScore}% (${imageAnalysis.modernFormats}/${imageAnalysis.totalImages})`, modernColor) ); - // Responsive images const responsiveScore = imageAnalysis.totalImages > 0 ? (imageAnalysis.hasResponsive / imageAnalysis.totalImages * 100).toFixed(0) : 0; @@ -1075,14 +1095,12 @@ document.addEventListener('alpine:init', () => { this.createInfoSection('๐Ÿ“ฑ Adaptive Images (srcset)', responsiveText, responsiveColor) ); - // Oversized images warning if (imageAnalysis.oversized > 0) { container.appendChild( this.createInfoSection('โš ๏ธ Oversized Images', `${imageAnalysis.oversized} oversized`, '#ef4444') ); } - // Show first 3 issues if (imageAnalysis.issues.length > 0) { const issuesText = imageAnalysis.issues.slice(0, 3).join(' โ€ข '); const moreText = imageAnalysis.issues.length > 3 ? ` (+${imageAnalysis.issues.length - 3} more)` : ''; @@ -1090,21 +1108,18 @@ document.addEventListener('alpine:init', () => { this.createInfoSection('๐Ÿ’ก Optimization Tips', issuesText + moreText, '#f59e0b') ); } - - hasMetrics = true; + return true; } + return false; + }, - // Resources loaded by this element + renderResourceMetric(container, element) { const elementResources = this.getElementResources(element); if (elementResources.count > 0) { this.renderElementResourceMetrics(container, elementResources); - hasMetrics = true; - } - - // If no element-specific metrics - if (!hasMetrics) { - this.renderNoBrowserMetrics(container); + return true; } + return false; }, /** @@ -1242,18 +1257,27 @@ document.addEventListener('alpine:init', () => { * @param {object} resourceData */ renderElementResourceMetrics(container, resourceData) { - // Format size - let sizeText = ''; - if (resourceData.size < 1024) { - sizeText = `${resourceData.size} B`; - } else if (resourceData.size < 1024 * 1024) { - sizeText = `${(resourceData.size / 1024).toFixed(1)} KB`; + const sizeText = this.formatResourceSize(resourceData.size); + const resourceLabel = this.determineResourceLabel(resourceData); + + container.appendChild( + this.createInfoSection('๐Ÿ“ฆ Element Resources', `${resourceData.count} ${resourceLabel} (${sizeText})`, '#60a5fa') + ); + + this.renderResourceBreakdown(container, resourceData); + }, + + formatResourceSize(size) { + if (size < 1024) { + return `${size} B`; + } else if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(1)} KB`; } else { - sizeText = `${(resourceData.size / (1024 * 1024)).toFixed(2)} MB`; + return `${(size / (1024 * 1024)).toFixed(2)} MB`; } + }, - // Determine resource type label (smart singular/plural) - let resourceLabel = ''; + determineResourceLabel(resourceData) { const hasImages = resourceData.byType.img > 0; const hasScripts = resourceData.byType.script > 0; const hasCss = resourceData.byType.css > 0; @@ -1262,28 +1286,23 @@ document.addEventListener('alpine:init', () => { const typeCount = (hasImages ? 1 : 0) + (hasScripts ? 1 : 0) + (hasCss ? 1 : 0) + (hasFonts ? 1 : 0) + (hasOther ? 1 : 0); if (typeCount === 1) { - // Only one type of resource - use specific label - if (hasImages) { - resourceLabel = resourceData.byType.img === 1 ? 'Image' : 'Images'; - } else if (hasScripts) { - resourceLabel = resourceData.byType.script === 1 ? 'Script' : 'Scripts'; - } else if (hasCss) { - resourceLabel = resourceData.byType.css === 1 ? 'Stylesheet' : 'Stylesheets'; - } else if (hasFonts) { - resourceLabel = resourceData.byType.font === 1 ? 'Font' : 'Fonts'; - } else if (hasOther) { - resourceLabel = resourceData.byType.other === 1 ? 'Resource' : 'Resources'; - } - } else { - // Multiple types - use generic label - resourceLabel = resourceData.count === 1 ? 'Resource' : 'Resources'; + if (hasImages) return resourceData.byType.img === 1 ? 'Image' : 'Images'; + if (hasScripts) return resourceData.byType.script === 1 ? 'Script' : 'Scripts'; + if (hasCss) return resourceData.byType.css === 1 ? 'Stylesheet' : 'Stylesheets'; + if (hasFonts) return resourceData.byType.font === 1 ? 'Font' : 'Fonts'; + if (hasOther) return resourceData.byType.other === 1 ? 'Resource' : 'Resources'; } + return resourceData.count === 1 ? 'Resource' : 'Resources'; + }, - container.appendChild( - this.createInfoSection('๐Ÿ“ฆ Element Resources', `${resourceData.count} ${resourceLabel} (${sizeText})`, '#60a5fa') - ); + renderResourceBreakdown(container, resourceData) { + const hasImages = resourceData.byType.img > 0; + const hasScripts = resourceData.byType.script > 0; + const hasCss = resourceData.byType.css > 0; + const hasFonts = resourceData.byType.font > 0; + const hasOther = resourceData.byType.other > 0; + const typeCount = (hasImages ? 1 : 0) + (hasScripts ? 1 : 0) + (hasCss ? 1 : 0) + (hasFonts ? 1 : 0) + (hasOther ? 1 : 0); - // Show breakdown only if multiple different types if (typeCount > 1) { const types = []; if (resourceData.byType.img > 0) types.push(`Images: ${resourceData.byType.img}`); From 94a8a98aa05292bc923cf525a076eeed214e7b4f Mon Sep 17 00:00:00 2001 From: Mathias Elle Date: Tue, 10 Feb 2026 00:50:46 +0100 Subject: [PATCH 3/3] feat: add resource type stats calculation and improve rendering logic --- src/view/frontend/web/js/inspector.js | 54 +++++++++++++++------------ 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/src/view/frontend/web/js/inspector.js b/src/view/frontend/web/js/inspector.js index 4061c70..0afced1 100644 --- a/src/view/frontend/web/js/inspector.js +++ b/src/view/frontend/web/js/inspector.js @@ -1277,42 +1277,48 @@ document.addEventListener('alpine:init', () => { } }, + /** + * Get stats about active resource types + */ + getResourceTypeStats(resourceData) { + const definitions = [ + { key: 'img', label: 'Image', plural: 'Images' }, + { key: 'script', label: 'Script', plural: 'Scripts' }, + { key: 'css', label: 'Stylesheet', plural: 'Stylesheets' }, + { key: 'font', label: 'Font', plural: 'Fonts' }, + { key: 'other', label: 'Resource', plural: 'Resources' } + ]; + + const activeTypes = definitions + .map(def => ({ ...def, count: resourceData.byType[def.key] })) + .filter(item => item.count > 0); + + return { + activeTypes, + typeCount: activeTypes.length + }; + }, + determineResourceLabel(resourceData) { - const hasImages = resourceData.byType.img > 0; - const hasScripts = resourceData.byType.script > 0; - const hasCss = resourceData.byType.css > 0; - const hasFonts = resourceData.byType.font > 0; - const hasOther = resourceData.byType.other > 0; - const typeCount = (hasImages ? 1 : 0) + (hasScripts ? 1 : 0) + (hasCss ? 1 : 0) + (hasFonts ? 1 : 0) + (hasOther ? 1 : 0); + const { activeTypes, typeCount } = this.getResourceTypeStats(resourceData); if (typeCount === 1) { - if (hasImages) return resourceData.byType.img === 1 ? 'Image' : 'Images'; - if (hasScripts) return resourceData.byType.script === 1 ? 'Script' : 'Scripts'; - if (hasCss) return resourceData.byType.css === 1 ? 'Stylesheet' : 'Stylesheets'; - if (hasFonts) return resourceData.byType.font === 1 ? 'Font' : 'Fonts'; - if (hasOther) return resourceData.byType.other === 1 ? 'Resource' : 'Resources'; + const type = activeTypes[0]; + return type.count === 1 ? type.label : type.plural; } return resourceData.count === 1 ? 'Resource' : 'Resources'; }, renderResourceBreakdown(container, resourceData) { - const hasImages = resourceData.byType.img > 0; - const hasScripts = resourceData.byType.script > 0; - const hasCss = resourceData.byType.css > 0; - const hasFonts = resourceData.byType.font > 0; - const hasOther = resourceData.byType.other > 0; - const typeCount = (hasImages ? 1 : 0) + (hasScripts ? 1 : 0) + (hasCss ? 1 : 0) + (hasFonts ? 1 : 0) + (hasOther ? 1 : 0); + const { activeTypes, typeCount } = this.getResourceTypeStats(resourceData); if (typeCount > 1) { - const types = []; - if (resourceData.byType.img > 0) types.push(`Images: ${resourceData.byType.img}`); - if (resourceData.byType.script > 0) types.push(`JS: ${resourceData.byType.script}`); - if (resourceData.byType.css > 0) types.push(`CSS: ${resourceData.byType.css}`); - if (resourceData.byType.font > 0) types.push(`Fonts: ${resourceData.byType.font}`); - if (resourceData.byType.other > 0) types.push(`Other: ${resourceData.byType.other}`); + const typesText = activeTypes + .map(t => `${t.plural}: ${t.count}`) + .join(', '); container.appendChild( - this.createInfoSection('๐Ÿ“‘ Resource Types', types.join(', '), '#a78bfa') + this.createInfoSection('๐Ÿ“‘ Resource Types', typesText, '#a78bfa') ); } },