From 9b22d8fe2303d1cfce754b011401ca437e3187fb Mon Sep 17 00:00:00 2001 From: Mathias Elle Date: Sun, 12 Apr 2026 00:09:54 +0200 Subject: [PATCH 01/30] feat: add MageForge Toolbar with basic audits --- src/Block/Inspector.php | 20 + src/view/frontend/templates/inspector.phtml | 7 + .../frontend/web/css/audits/tab-order.css | 60 +++ src/view/frontend/web/css/inspector.css | 20 +- src/view/frontend/web/css/toolbar.css | 397 ++++++++++++++++++ src/view/frontend/web/js/inspector.js | 11 +- src/view/frontend/web/js/inspector/ui.js | 47 +-- src/view/frontend/web/js/toolbar.js | 62 +++ src/view/frontend/web/js/toolbar/audits.js | 64 +++ .../js/toolbar/audits/images-without-alt.js | 35 ++ .../frontend/web/js/toolbar/audits/index.js | 23 + .../web/js/toolbar/audits/tab-order.js | 196 +++++++++ src/view/frontend/web/js/toolbar/ui.js | 182 ++++++++ 13 files changed, 1058 insertions(+), 66 deletions(-) create mode 100644 src/view/frontend/web/css/audits/tab-order.css create mode 100644 src/view/frontend/web/css/toolbar.css create mode 100644 src/view/frontend/web/js/toolbar.js create mode 100644 src/view/frontend/web/js/toolbar/audits.js create mode 100644 src/view/frontend/web/js/toolbar/audits/images-without-alt.js create mode 100644 src/view/frontend/web/js/toolbar/audits/index.js create mode 100644 src/view/frontend/web/js/toolbar/audits/tab-order.js create mode 100644 src/view/frontend/web/js/toolbar/ui.js diff --git a/src/Block/Inspector.php b/src/Block/Inspector.php index aa4d20a7..5ebf3711 100644 --- a/src/Block/Inspector.php +++ b/src/Block/Inspector.php @@ -72,6 +72,16 @@ public function getCssUrl(): string return $this->getViewFileUrl('OpenForgeProject_MageForge::css/inspector.css'); } + /** + * Get Toolbar CSS file URL + * + * @return string + */ + public function getToolbarCssUrl(): string + { + return $this->getViewFileUrl('OpenForgeProject_MageForge::css/toolbar.css'); + } + /** * Get JS file URL * @@ -82,6 +92,16 @@ public function getJsUrl(): string return $this->getViewFileUrl('OpenForgeProject_MageForge::js/inspector.js'); } + /** + * Get Toolbar JS file URL + * + * @return string + */ + public function getToolbarJsUrl(): string + { + return $this->getViewFileUrl('OpenForgeProject_MageForge::js/toolbar.js'); + } + /** * Get configured theme * diff --git a/src/view/frontend/templates/inspector.phtml b/src/view/frontend/templates/inspector.phtml index 3f61fae2..6127701e 100644 --- a/src/view/frontend/templates/inspector.phtml +++ b/src/view/frontend/templates/inspector.phtml @@ -12,6 +12,7 @@ */ ?> + @@ -60,8 +61,14 @@ JS; ?> renderTag('script', [], $alpineBootstrap, false) ?> + + +
+
{ + this.toggleInspector(); + }); + // Dispatch init event for Hyvä integration this.$dispatch('mageforge:inspector:init'); }, @@ -125,10 +128,6 @@ function _registerMageforgeInspector() { this.infoBadge.remove(); this.infoBadge = null; } - if (this.floatingButton) { - this.floatingButton.remove(); - this.floatingButton = null; - } if (this.connectorSvg) { this.connectorSvg.remove(); this.connectorSvg = null; diff --git a/src/view/frontend/web/js/inspector/ui.js b/src/view/frontend/web/js/inspector/ui.js index 96cff6f6..1daa1a67 100644 --- a/src/view/frontend/web/js/inspector/ui.js +++ b/src/view/frontend/web/js/inspector/ui.js @@ -45,51 +45,12 @@ export const uiMethods = { }, /** - * Create floating button for inspector activation - */ - createFloatingButton() { - this.floatingButton = document.createElement('button'); - this.floatingButton.className = 'mageforge-inspector mageforge-inspector-float-button'; - - // Propagate theme from root element to injected body element - if (this.$el && this.$el.hasAttribute('data-theme')) { - this.floatingButton.setAttribute('data-theme', this.$el.getAttribute('data-theme')); - } - - this.floatingButton.type = 'button'; - this.floatingButton.title = 'Activate Inspector (Ctrl+Shift+I)'; - this.floatingButton.innerHTML = ` - - - - - - - - MageForge Inspector - `; - - // Click to toggle inspector - this.floatingButton.onclick = (e) => { - e.preventDefault(); - e.stopPropagation(); - this.toggleInspector(); - }; - - document.body.appendChild(this.floatingButton); - }, - - /** - * Update floating button state + * Notify the toolbar about the current inspector open/closed state */ updateFloatingButton() { - if (!this.floatingButton) return; - - if (this.isOpen) { - this.floatingButton.classList.add('mageforge-active'); - } else { - this.floatingButton.classList.remove('mageforge-active'); - } + window.dispatchEvent(new CustomEvent('mageforge:toolbar:inspector-state', { + detail: { active: this.isOpen } + })); }, /** diff --git a/src/view/frontend/web/js/toolbar.js b/src/view/frontend/web/js/toolbar.js new file mode 100644 index 00000000..742c3e36 --- /dev/null +++ b/src/view/frontend/web/js/toolbar.js @@ -0,0 +1,62 @@ +/** + * MageForge Toolbar - Standalone audit toolbar. + */ + +import { uiMethods } from './toolbar/ui.js'; +import { auditMethods } from './toolbar/audits.js'; + +function _registerMageforgeToolbar() { + Alpine.data('mageforgeToolbar', () => ({ + // ==================================================================== + // State + // ==================================================================== + menuOpen: false, + + /** @type {Set} Keys of currently active audits */ + activeAudits: new Set(), + + /** @type {HTMLDivElement|null} */ + container: null, + + /** @type {HTMLButtonElement|null} */ + burgerButton: null, + + /** @type {HTMLDivElement|null} */ + menu: null, + + /** @type {HTMLButtonElement|null} */ + inspectorButton: null, + + // ==================================================================== + // Lifecycle + // ==================================================================== + + init() { + this.createToolbar(); + + window.addEventListener('mageforge:toolbar:inspector-state', (e) => { + this.setInspectorActive(e.detail.active); + }); + }, + + destroy() { + if (this.container) { + this.container.remove(); + this.container = null; + } + }, + + // ==================================================================== + // Mixins + // ==================================================================== + + ...uiMethods, + ...auditMethods, + })); +} + +if (typeof Alpine !== 'undefined') { + _registerMageforgeToolbar(); +} else { + document.addEventListener('alpine:init', _registerMageforgeToolbar); +} diff --git a/src/view/frontend/web/js/toolbar/audits.js b/src/view/frontend/web/js/toolbar/audits.js new file mode 100644 index 00000000..71670415 --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits.js @@ -0,0 +1,64 @@ +/** + * MageForge Toolbar - Audit dispatcher + */ + +import { audits } from './audits/index.js'; + +export const auditMethods = { + /** + * Toggles an audit on/off and updates the menu item state. + * + * @param {string} auditKey + */ + runAudit(auditKey) { + const audit = audits.find(a => a.key === auditKey); + if (!audit) return; + + const isActive = this.activeAudits.has(auditKey); + if (isActive) { + this.activeAudits.delete(auditKey); + } else { + this.activeAudits.add(auditKey); + } + audit.run(this); + this.setAuditActive(auditKey, !isActive); + }, + + /** + * Deactivates all currently active audits (called when closing the toolbar). + */ + deactivateAllAudits() { + this.activeAudits.forEach(key => { + const audit = audits.find(a => a.key === key); + if (audit) audit.run(this); + this.setAuditActive(key, false); + }); + this.activeAudits.clear(); + }, + + /** + * Returns all registered audits (used by UI to build menu items) + * + * @returns {import('./audits/index.js').AuditDefinition[]} + */ + getAudits() { + return audits; + }, + + /** + * Set the inline counter badge of an audit menu item. + * + * @param {string} key + * @param {string} message + * @param {'success'|'error'} type + */ + setAuditCounterBadge(key, message, type = 'success') { + if (!this.menu) return; + const item = this.menu.querySelector(`[data-audit-key="${key}"]`); + if (!item) return; + const status = item.querySelector('.mageforge-toolbar-menu-status'); + if (!status) return; + status.textContent = message; + status.className = `mageforge-toolbar-menu-status mageforge-toolbar-menu-status--${type}`; + }, +}; diff --git a/src/view/frontend/web/js/toolbar/audits/images-without-alt.js b/src/view/frontend/web/js/toolbar/audits/images-without-alt.js new file mode 100644 index 00000000..7e4db5d8 --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits/images-without-alt.js @@ -0,0 +1,35 @@ +/** + * MageForge Toolbar Audit – Images without ALT + */ + +/** @type {import('./index.js').AuditDefinition} */ +export default { + key: 'images-without-alt', + icon: '', + label: 'Images without ALT', + description: 'Highlighting images without alt attributes', + + /** + * @param {object} context - Alpine toolbar component instance + */ + run(context) { + const existing = document.querySelectorAll('.mageforge-toolbar-audit-highlight'); + if (existing.length > 0) { + existing.forEach(el => el.classList.remove('mageforge-toolbar-audit-highlight')); + return; + } + + const images = Array.from(document.querySelectorAll('img')).filter(img => + !img.hasAttribute('alt') || img.getAttribute('alt').trim() === '' + ); + + if (images.length === 0) { + context.setAuditCounterBadge('images-without-alt', '0', 'success'); + return; + } + + images.forEach(img => img.classList.add('mageforge-toolbar-audit-highlight')); + images[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); + context.setAuditCounterBadge('images-without-alt', `${images.length}`, 'error'); + }, +}; diff --git a/src/view/frontend/web/js/toolbar/audits/index.js b/src/view/frontend/web/js/toolbar/audits/index.js new file mode 100644 index 00000000..180bbd0e --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits/index.js @@ -0,0 +1,23 @@ +/** + * MageForge Toolbar – Audit Registry + * + * To add a new audit: + * 1. Create a file in this directory exporting a default AuditDefinition object + * 2. Import it here and add it to the `audits` array + * + * @typedef {object} AuditDefinition + * @property {string} key - Unique identifier + * @property {string} icon - Emoji or SVG string shown in menu + * @property {string} label - Short display name + * @property {string} description - Tooltip / subtitle text + * @property {function(object): void} run - Audit logic; receives Alpine component as context + */ + +import imagesWithoutAlt from './images-without-alt.js'; +import tabOrder from './tab-order.js'; + +/** @type {AuditDefinition[]} */ +export const audits = [ + imagesWithoutAlt, + tabOrder, +]; diff --git a/src/view/frontend/web/js/toolbar/audits/tab-order.js b/src/view/frontend/web/js/toolbar/audits/tab-order.js new file mode 100644 index 00000000..cba1b5b8 --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits/tab-order.js @@ -0,0 +1,196 @@ +/** + * MageForge Toolbar Audit – Tab Order + * + * Visualises the keyboard tab order of all focusable elements on the page. + * Renders numbered badges and connecting lines in the correct navigation order. + * Elements with tabindex > 0 are highlighted in red (A11y anti-pattern). + * Clicking the audit again removes the overlay (toggle). + */ + +const OVERLAY_ID = 'mageforge-tab-order-overlay'; +const CSS_ID = 'mageforge-tab-order-css'; +const CSS_URL = new URL('../../../css/audits/tab-order.css', import.meta.url).href; + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled]):not([type="hidden"])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + 'details > summary', +].join(', '); + +function injectCss() { + if (document.getElementById(CSS_ID)) { + return; + } + const link = document.createElement('link'); + link.id = CSS_ID; + link.rel = 'stylesheet'; + link.href = CSS_URL; + document.head.appendChild(link); +} + +function isVisible(el) { + if (el.offsetWidth === 0 || el.offsetHeight === 0) { + return false; + } + const style = getComputedStyle(el); + return style.visibility !== 'hidden' && style.display !== 'none'; +} + +function getTabIndex(el) { + const value = parseInt(el.getAttribute('tabindex'), 10); + return isNaN(value) ? 0 : value; +} + +/** + * Sort focusable elements into true tab order: + * 1. Elements with tabindex > 0, ascending by value, then DOM order + * 2. Elements with tabindex = 0 or no tabindex, in DOM order + */ +function sortByTabOrder(elements) { + const positive = elements.filter(el => getTabIndex(el) > 0) + .sort((a, b) => { + const diff = getTabIndex(a) - getTabIndex(b); + return diff !== 0 ? diff : ( + a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1 + ); + }); + + const natural = elements.filter(el => getTabIndex(el) <= 0); + + return [...positive, ...natural]; +} + +/** + * (Re-)renders the overlay: removes any existing overlay and redraws all + * badges and connecting SVG lines at their current viewport positions. + * + * @param {Element[]} sorted - focusable elements in tab order + */ +function renderOverlay(sorted) { + const existing = document.getElementById(OVERLAY_ID); + if (existing) { + existing.remove(); + } + + // Build overlay + const overlay = document.createElement('div'); + overlay.id = OVERLAY_ID; + overlay.className = 'mageforge-tab-order-overlay'; + + // SVG layer for connecting lines (rendered behind badges) + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.classList.add('mageforge-tab-order-svg'); + svg.setAttribute('aria-hidden', 'true'); + overlay.appendChild(svg); + + document.body.appendChild(overlay); + + // Place badges and record centre points + const centres = sorted.map((el, index) => { + const rect = el.getBoundingClientRect(); + const cx = Math.round(rect.left + rect.width / 2); + const cy = Math.round(rect.top); + + const badge = document.createElement('span'); + badge.className = 'mageforge-tab-order-badge' + + (getTabIndex(el) > 0 ? ' mageforge-tab-order-badge--negative' : ''); + badge.textContent = index + 1; + badge.style.left = cx + 'px'; + badge.style.top = cy + 'px'; + overlay.appendChild(badge); + + return { cx, cy, negative: getTabIndex(el) > 0 }; + }); + + // Draw connecting lines between consecutive badges + for (let i = 0; i < centres.length - 1; i++) { + const from = centres[i]; + const to = centres[i + 1]; + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.classList.add('mageforge-tab-order-line'); + if (from.negative || to.negative) { + line.classList.add('mageforge-tab-order-line--negative'); + } + line.setAttribute('x1', from.cx); + line.setAttribute('y1', from.cy); + line.setAttribute('x2', to.cx); + line.setAttribute('y2', to.cy); + svg.appendChild(line); + } +} + +/** @type {import('./index.js').AuditDefinition} */ +export default { + key: 'tab-order', + icon: '', + label: 'Show Tab Order', + description: 'Visualise keyboard tab order', + + /** + * @param {object} context - Alpine toolbar component instance + */ + run(context) { + injectCss(); + + // Toggle: remove overlay if already visible + const existing = document.getElementById(OVERLAY_ID); + if (existing) { + context._tabOrderObserver?.disconnect(); + context._tabOrderObserver = null; + if (context._tabOrderScrollHandler) { + document.removeEventListener('scroll', context._tabOrderScrollHandler, { capture: true }); + context._tabOrderScrollHandler = null; + } + existing.remove(); + return; + } + + // Clear other audit highlights + document.querySelectorAll('.mageforge-toolbar-audit-highlight').forEach(el => { + el.classList.remove('mageforge-toolbar-audit-highlight'); + }); + + const allFocusable = Array.from(document.querySelectorAll(FOCUSABLE_SELECTOR)) + .filter(isVisible); + + if (allFocusable.length === 0) { + context.setAuditCounterBadge('tab-order', '0', 'error'); + return; + } + + const sorted = sortByTabOrder(allFocusable); + const hasNegative = sorted.some(el => getTabIndex(el) > 0); + + renderOverlay(sorted); + + // Re-render on resize or scroll (e.g. DevTools panel, page scroll) + context._tabOrderObserver = new ResizeObserver(() => { + if (!document.getElementById(OVERLAY_ID)) { + context._tabOrderObserver?.disconnect(); + context._tabOrderObserver = null; + return; + } + renderOverlay(sorted); + }); + context._tabOrderObserver.observe(document.body); + + let scrollRaf = null; + context._tabOrderScrollHandler = () => { + if (scrollRaf) return; + scrollRaf = requestAnimationFrame(() => { + scrollRaf = null; + if (document.getElementById(OVERLAY_ID)) { + renderOverlay(sorted); + } + }); + }; + document.addEventListener('scroll', context._tabOrderScrollHandler, { capture: true, passive: true }); + + const type = hasNegative ? 'error' : 'success'; + context.setAuditCounterBadge('tab-order', `${sorted.length}`, type); + }, +}; diff --git a/src/view/frontend/web/js/toolbar/ui.js b/src/view/frontend/web/js/toolbar/ui.js new file mode 100644 index 00000000..3f7cfb1b --- /dev/null +++ b/src/view/frontend/web/js/toolbar/ui.js @@ -0,0 +1,182 @@ +/** + * MageForge Toolbar - DOM construction and menu controls + */ + +export const uiMethods = { + createToolbar() { + this.container = document.createElement('div'); + this.container.className = 'mageforge-toolbar'; + + if (this.$el && this.$el.hasAttribute('data-theme')) { + this.container.setAttribute('data-theme', this.$el.getAttribute('data-theme')); + } + + // Menu popup (before buttons so it sits correctly in stacking context) + this.menu = document.createElement('div'); + this.menu.className = 'mageforge-toolbar-menu'; + this.menu.style.display = 'none'; + + const menuTitle = document.createElement('div'); + menuTitle.className = 'mageforge-toolbar-menu-title'; + menuTitle.innerHTML = ` + MageForge Toolbar + + `; + menuTitle.querySelector('.mageforge-toolbar-menu-close').onclick = (e) => { + e.stopPropagation(); + this.deactivateAllAudits(); + this.closeMenu(); + }; + this.menu.appendChild(menuTitle); + + this.getAudits().forEach(audit => { + this.menu.appendChild(this.createMenuItem( + audit.key, + audit.icon, + audit.label, + audit.description, + () => this.runAudit(audit.key) + )); + }); + + // Burger button (left) + this.burgerButton = document.createElement('button'); + this.burgerButton.className = 'mageforge-toolbar-burger'; + this.burgerButton.type = 'button'; + this.burgerButton.title = 'Audit tools'; + this.burgerButton.innerHTML = ` + + + + + + Toolbar + `; + this.burgerButton.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + this.toggleMenu(); + }; + + // Inspector toggle button (right) – delegates to inspector via custom event + this.inspectorButton = document.createElement('button'); + this.inspectorButton.className = 'mageforge-inspector-float-button'; + this.inspectorButton.type = 'button'; + this.inspectorButton.title = 'Activate Inspector (Ctrl+Shift+I)'; + this.inspectorButton.innerHTML = ` + + + + + + + + MageForge Inspector + `; + this.inspectorButton.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + window.dispatchEvent(new CustomEvent('mageforge:toolbar:toggle-inspector')); + }; + + this.container.appendChild(this.menu); + this.container.appendChild(this.burgerButton); + this.container.appendChild(this.inspectorButton); + + // Close menu when clicking outside the toolbar + document.addEventListener('click', (e) => { + if (this.menuOpen && !this.container.contains(e.target)) { + this.closeMenu(); + } + }); + + document.body.appendChild(this.container); + }, + + /** + * Create a single audit menu item button + * + * @param {string} key + * @param {string} icon + * @param {string} label + * @param {string} description + * @param {Function} callback + * @return {HTMLButtonElement} + */ + createMenuItem(key, icon, label, description, callback) { + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'mageforge-toolbar-menu-item'; + item.dataset.auditKey = key; + item.innerHTML = ` + ${icon} + + + ${label} + + + ${description} + + + `; + item.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + callback(); + }; + return item; + }, + + /** + * Update the visual active state of an audit menu item. + * + * @param {string} key + * @param {boolean} active + */ + setAuditActive(key, active) { + if (!this.menu) return; + const item = this.menu.querySelector(`[data-audit-key="${key}"]`); + if (!item) return; + item.classList.toggle('mageforge-active', active); + if (!active) { + const status = item.querySelector('.mageforge-toolbar-menu-status'); + if (status) { + status.textContent = ''; + status.className = 'mageforge-toolbar-menu-status'; + } + } + }, + + /** + * Reflect inspector open/closed state on the inspector button + * + * @param {boolean} active + */ + setInspectorActive(active) { + if (!this.inspectorButton) return; + + if (active) { + this.inspectorButton.classList.add('mageforge-active'); + } else { + this.inspectorButton.classList.remove('mageforge-active'); + } + }, + + toggleMenu() { + this.menuOpen ? this.closeMenu() : this.openMenu(); + }, + + openMenu() { + this.menuOpen = true; + this.menu.style.display = 'block'; + this.burgerButton.classList.add('mageforge-active'); + }, + + closeMenu() { + this.menuOpen = false; + this.menu.style.display = 'none'; + this.burgerButton.classList.remove('mageforge-active'); + }, +}; From 2ad540d4bdc4a7be413de0d4b300b401df753332 Mon Sep 17 00:00:00 2001 From: Mathias Elle Date: Sun, 12 Apr 2026 00:46:55 +0200 Subject: [PATCH 02/30] feat: add new audits for images without dimensions, lazy load, and inputs without labels --- src/view/frontend/web/css/toolbar.css | 15 +- src/view/frontend/web/js/toolbar/audits.js | 2 + .../audits/images-without-dimensions.js | 39 ++++ .../audits/images-without-lazy-load.js | 44 ++++ .../frontend/web/js/toolbar/audits/index.js | 8 + .../js/toolbar/audits/inputs-without-label.js | 51 +++++ .../js/toolbar/audits/low-contrast-text.js | 202 ++++++++++++++++++ src/view/frontend/web/js/toolbar/ui.js | 3 + 8 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 src/view/frontend/web/js/toolbar/audits/images-without-dimensions.js create mode 100644 src/view/frontend/web/js/toolbar/audits/images-without-lazy-load.js create mode 100644 src/view/frontend/web/js/toolbar/audits/inputs-without-label.js create mode 100644 src/view/frontend/web/js/toolbar/audits/low-contrast-text.js diff --git a/src/view/frontend/web/css/toolbar.css b/src/view/frontend/web/css/toolbar.css index 44b85dc7..de2f4909 100644 --- a/src/view/frontend/web/css/toolbar.css +++ b/src/view/frontend/web/css/toolbar.css @@ -216,6 +216,15 @@ color: var(--mageforge-color-green); } +.mageforge-toolbar-menu-item.mageforge-active--error { + background: var(--mageforge-color-red-alpha-15); + border-color: var(--mageforge-color-red-alpha-35); +} + +.mageforge-toolbar-menu-item.mageforge-active--error .mageforge-toolbar-menu-label { + color: var(--mageforge-color-red); +} + .mageforge-toolbar-menu-icon { font-size: 16px; flex-shrink: 0; @@ -317,7 +326,11 @@ Audit Highlights ========================================================================== */ -.mageforge-toolbar-audit-highlight { +.mageforge-toolbar-audit-highlight, +.mageforge-audit-images-without-dimensions, +.mageforge-audit-images-without-lazy-load, +.mageforge-audit-inputs-without-label, +.mageforge-audit-low-contrast { outline: 3px solid var(--mageforge-color-red) !important; outline-offset: 2px; } diff --git a/src/view/frontend/web/js/toolbar/audits.js b/src/view/frontend/web/js/toolbar/audits.js index 71670415..ffd5922d 100644 --- a/src/view/frontend/web/js/toolbar/audits.js +++ b/src/view/frontend/web/js/toolbar/audits.js @@ -60,5 +60,7 @@ export const auditMethods = { if (!status) return; status.textContent = message; status.className = `mageforge-toolbar-menu-status mageforge-toolbar-menu-status--${type}`; + // Reflect error/success on the active item background + item.classList.toggle('mageforge-active--error', type === 'error'); }, }; diff --git a/src/view/frontend/web/js/toolbar/audits/images-without-dimensions.js b/src/view/frontend/web/js/toolbar/audits/images-without-dimensions.js new file mode 100644 index 00000000..9e8903e7 --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits/images-without-dimensions.js @@ -0,0 +1,39 @@ +/** + * MageForge Toolbar Audit – Images without width/height + * + * Images missing explicit width and height attributes cause Cumulative Layout Shift (CLS). + */ + +const HIGHLIGHT_CLASS = 'mageforge-audit-images-without-dimensions'; + +/** @type {import('./index.js').AuditDefinition} */ +export default { + key: 'images-without-dimensions', + icon: '', + label: 'Images without Dimensions', + description: 'Highlight images missing width/height', + + /** + * @param {object} context - Alpine toolbar component instance + */ + run(context) { + const existing = document.querySelectorAll(`.${HIGHLIGHT_CLASS}`); + if (existing.length > 0) { + existing.forEach(el => el.classList.remove(HIGHLIGHT_CLASS)); + return; + } + + const images = Array.from(document.querySelectorAll('img')).filter(img => + !img.hasAttribute('width') || !img.hasAttribute('height') + ); + + if (images.length === 0) { + context.setAuditCounterBadge('images-without-dimensions', '0', 'success'); + return; + } + + images.forEach(img => img.classList.add(HIGHLIGHT_CLASS)); + images[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); + context.setAuditCounterBadge('images-without-dimensions', `${images.length}`, 'error'); + }, +}; diff --git a/src/view/frontend/web/js/toolbar/audits/images-without-lazy-load.js b/src/view/frontend/web/js/toolbar/audits/images-without-lazy-load.js new file mode 100644 index 00000000..58026be2 --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits/images-without-lazy-load.js @@ -0,0 +1,44 @@ +/** + * MageForge Toolbar Audit – Images without lazy loading + * + * Images below the fold that lack loading="lazy" are loaded eagerly, + * wasting bandwidth and slowing initial page load. + */ + +const HIGHLIGHT_CLASS = 'mageforge-audit-images-without-lazy-load'; + +/** @type {import('./index.js').AuditDefinition} */ +export default { + key: 'images-without-lazy-load', + icon: '', + label: 'Images without Lazy Load', + description: 'Highlight off-screen images missing loading="lazy"', + + /** + * @param {object} context - Alpine toolbar component instance + */ + run(context) { + const existing = document.querySelectorAll(`.${HIGHLIGHT_CLASS}`); + if (existing.length > 0) { + existing.forEach(el => el.classList.remove(HIGHLIGHT_CLASS)); + return; + } + + const viewportBottom = window.innerHeight; + + const images = Array.from(document.querySelectorAll('img')).filter(img => { + if (img.getAttribute('loading') === 'lazy') return false; + const rect = img.getBoundingClientRect(); + return rect.top > viewportBottom; + }); + + if (images.length === 0) { + context.setAuditCounterBadge('images-without-lazy-load', '0', 'success'); + return; + } + + images.forEach(img => img.classList.add(HIGHLIGHT_CLASS)); + images[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); + context.setAuditCounterBadge('images-without-lazy-load', `${images.length}`, 'error'); + }, +}; diff --git a/src/view/frontend/web/js/toolbar/audits/index.js b/src/view/frontend/web/js/toolbar/audits/index.js index 180bbd0e..c47a8aaf 100644 --- a/src/view/frontend/web/js/toolbar/audits/index.js +++ b/src/view/frontend/web/js/toolbar/audits/index.js @@ -14,10 +14,18 @@ */ import imagesWithoutAlt from './images-without-alt.js'; +import imagesWithoutDimensions from './images-without-dimensions.js'; +import imagesWithoutLazyLoad from './images-without-lazy-load.js'; +import inputsWithoutLabel from './inputs-without-label.js'; +import lowContrastText from './low-contrast-text.js'; import tabOrder from './tab-order.js'; /** @type {AuditDefinition[]} */ export const audits = [ imagesWithoutAlt, + imagesWithoutDimensions, + imagesWithoutLazyLoad, + inputsWithoutLabel, + lowContrastText, tabOrder, ]; diff --git a/src/view/frontend/web/js/toolbar/audits/inputs-without-label.js b/src/view/frontend/web/js/toolbar/audits/inputs-without-label.js new file mode 100644 index 00000000..3b2b6833 --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits/inputs-without-label.js @@ -0,0 +1,51 @@ +/** + * MageForge Toolbar Audit – Inputs without label + * + * Form inputs without an associated label or aria-label are inaccessible + * to screen reader users. + */ + +const HIGHLIGHT_CLASS = 'mageforge-audit-inputs-without-label'; + +/** @type {import('./index.js').AuditDefinition} */ +export default { + key: 'inputs-without-label', + icon: '', + label: 'Inputs without Label', + description: 'Highlight form inputs missing a label or aria-label', + + /** + * @param {object} context - Alpine toolbar component instance + */ + run(context) { + const existing = document.querySelectorAll(`.${HIGHLIGHT_CLASS}`); + if (existing.length > 0) { + existing.forEach(el => el.classList.remove(HIGHLIGHT_CLASS)); + return; + } + + const inputs = Array.from(document.querySelectorAll( + 'input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]):not([type="image"]), select, textarea' + )).filter(input => { + // aria-label or aria-labelledby present + if (input.hasAttribute('aria-label') && input.getAttribute('aria-label').trim()) return false; + if (input.hasAttribute('aria-labelledby') && document.getElementById(input.getAttribute('aria-labelledby'))) return false; + // title as fallback label + if (input.hasAttribute('title') && input.getAttribute('title').trim()) return false; + //
Date: Sun, 12 Apr 2026 13:31:17 +0200 Subject: [PATCH 18/30] style: update description for images without alt attributes --- src/view/frontend/web/js/toolbar/audits/images-without-alt.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/frontend/web/js/toolbar/audits/images-without-alt.js b/src/view/frontend/web/js/toolbar/audits/images-without-alt.js index 695c58b3..07d10c9d 100644 --- a/src/view/frontend/web/js/toolbar/audits/images-without-alt.js +++ b/src/view/frontend/web/js/toolbar/audits/images-without-alt.js @@ -9,7 +9,7 @@ export default { key: 'images-without-alt', icon: '', label: 'Images without ALT', - description: 'Highlighting images without alt attributes', + description: 'Highlight images without alt attributes', /** * @param {object} context - Alpine toolbar component instance From b5380ed3b186943cd60f639aea998d37ff5b1414 Mon Sep 17 00:00:00 2001 From: Mathias Elle Date: Sun, 12 Apr 2026 13:42:57 +0200 Subject: [PATCH 19/30] fix: update color parsing to use a sentinel value for transparency check --- src/view/frontend/web/js/toolbar/audits/low-contrast-text.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/view/frontend/web/js/toolbar/audits/low-contrast-text.js b/src/view/frontend/web/js/toolbar/audits/low-contrast-text.js index f6ca7701..3e42d0f2 100644 --- a/src/view/frontend/web/js/toolbar/audits/low-contrast-text.js +++ b/src/view/frontend/web/js/toolbar/audits/low-contrast-text.js @@ -22,10 +22,11 @@ const _colorCtx = _colorCanvas.getContext('2d', { willReadFrequently: true }); function parseColor(color) { if (!color || color === 'transparent') return null; _colorCtx.clearRect(0, 0, 1, 1); - _colorCtx.fillStyle = '#ffffff'; // reset to white + _colorCtx.fillStyle = '#fe01fe'; // sentinel: vivid pink never used as real text color + const sentinel = _colorCtx.fillStyle; // read back canonical form _colorCtx.fillStyle = color; // If fillStyle is unchanged, the browser could not parse the color - if (_colorCtx.fillStyle === '#ffffff') return null; + if (_colorCtx.fillStyle === sentinel) return null; _colorCtx.fillRect(0, 0, 1, 1); const d = _colorCtx.getImageData(0, 0, 1, 1).data; return [d[0], d[1], d[2], d[3] / 255]; From b8d8ab985e43175d33f11e919a9d940b75b54247 Mon Sep 17 00:00:00 2001 From: Mathias Elle Date: Sun, 12 Apr 2026 13:45:17 +0200 Subject: [PATCH 20/30] fix: improve aria-labelledby check for inputs without labels --- src/view/frontend/web/js/toolbar/audits/inputs-without-label.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/frontend/web/js/toolbar/audits/inputs-without-label.js b/src/view/frontend/web/js/toolbar/audits/inputs-without-label.js index 4591e88b..8ccd5625 100644 --- a/src/view/frontend/web/js/toolbar/audits/inputs-without-label.js +++ b/src/view/frontend/web/js/toolbar/audits/inputs-without-label.js @@ -29,7 +29,7 @@ export default { )).filter(input => { // aria-label or aria-labelledby present if (input.hasAttribute('aria-label') && input.getAttribute('aria-label').trim()) return false; - if (input.hasAttribute('aria-labelledby') && document.getElementById(input.getAttribute('aria-labelledby'))) return false; + if (input.hasAttribute('aria-labelledby') && input.getAttribute('aria-labelledby').trim().split(/\s+/).some(id => document.getElementById(id))) return false; // title as fallback label if (input.hasAttribute('title') && input.getAttribute('title').trim()) return false; //