diff --git a/src/view/frontend/web/css/toolbar.css b/src/view/frontend/web/css/toolbar.css
index f1aae65..394412a 100644
--- a/src/view/frontend/web/css/toolbar.css
+++ b/src/view/frontend/web/css/toolbar.css
@@ -403,6 +403,23 @@
.mageforge-audit-low-contrast {
outline: 3px solid var(--mageforge-color-red) !important;
outline-offset: 2px;
+ box-shadow: inset 0 0 0 3px var(--mageforge-color-red) !important;
+}
+
+/* Fixed-position overlay injected over highlighted
elements */
+.mageforge-audit-img-overlay {
+ position: fixed;
+ pointer-events: none;
+ background-color: rgba(239, 68, 68, 0.5);
+ outline: 3px solid var(--mageforge-color-red);
+ outline-offset: 0;
+ z-index: 9999997;
+}
+
+/* Non-image elements (inputs, text) get a colour filter for visibility */
+.mageforge-audit-inputs-without-label,
+.mageforge-audit-low-contrast {
+ filter: brightness(0.5) sepia(1) saturate(12) hue-rotate(330deg) !important;
}
/* ============================================================================
diff --git a/src/view/frontend/web/js/toolbar/audits/highlight.js b/src/view/frontend/web/js/toolbar/audits/highlight.js
new file mode 100644
index 0000000..9b5d523
--- /dev/null
+++ b/src/view/frontend/web/js/toolbar/audits/highlight.js
@@ -0,0 +1,154 @@
+/**
+ * MageForge Toolbar – Shared highlight helpers
+ *
+ * Audits that mark elements by adding a CSS class use these two helpers
+ * instead of duplicating the same logic. The CSS class is derived from the
+ * audit key: `mageforge-audit-`.
+ */
+
+const OVERLAY_CLASS = 'mageforge-audit-img-overlay';
+
+/**
+ * Module-level registry: tracks one overlay cleanup function per
+ * element and the set of audit keys currently relying on that overlay.
+ * Using a WeakMap means entries are automatically eligible for GC once
+ * the image node itself is collected.
+ *
+ * @type {WeakMap }>}
+ */
+const imgOverlayRegistry = new WeakMap();
+
+/**
+ * Shared update machinery – a single ResizeObserver and capturing scroll
+ * listener serve all active overlays, throttled via requestAnimationFrame.
+ * Attached on the first overlay, torn down when the last one is removed.
+ *
+ * @type {Map}
+ */
+const activeOverlays = new Map();
+let rafPending = false;
+let sharedRo = null;
+
+function scheduleUpdate() {
+ if (rafPending) return;
+ rafPending = true;
+ requestAnimationFrame(() => {
+ rafPending = false;
+ // Snapshot before iterating: update() calls may delete entries
+ // (image disconnected → cleanup) while we are looping.
+ for (const updateFn of [...activeOverlays.values()]) {
+ updateFn();
+ }
+ });
+}
+
+/**
+ * Creates a fixed-position overlay that tracks an
element's
+ * position in the viewport. Shares a single RAF-throttled scroll/resize
+ * handler across all active overlays instead of creating one per image.
+ * Returns a cleanup function that removes the overlay and deregisters it.
+ *
+ * @param {HTMLImageElement} img
+ * @returns {function} cleanup
+ */
+function createImgOverlay(img) {
+ const overlay = document.createElement('span');
+ overlay.className = OVERLAY_CLASS;
+ document.body.appendChild(overlay);
+
+ function update() {
+ if (!img.isConnected) {
+ cleanup();
+ imgOverlayRegistry.delete(img);
+ return;
+ }
+ const rect = img.getBoundingClientRect();
+ overlay.style.top = `${rect.top}px`;
+ overlay.style.left = `${rect.left}px`;
+ overlay.style.width = `${rect.width}px`;
+ overlay.style.height = `${rect.height}px`;
+ }
+
+ update();
+
+ activeOverlays.set(overlay, update);
+
+ // Attach shared listeners only when the first overlay is created.
+ if (activeOverlays.size === 1) {
+ sharedRo = new ResizeObserver(scheduleUpdate);
+ sharedRo.observe(document.documentElement);
+ window.addEventListener('scroll', scheduleUpdate, { passive: true, capture: true });
+ }
+
+ // Named so update() can reference it before its var declaration (hoisting).
+ function cleanup() {
+ activeOverlays.delete(overlay);
+ // Tear down shared listeners once no overlays remain.
+ if (activeOverlays.size === 0) {
+ sharedRo?.disconnect();
+ sharedRo = null;
+ window.removeEventListener('scroll', scheduleUpdate, { capture: true });
+ }
+ overlay.remove();
+ }
+
+ return cleanup;
+}
+
+/**
+ * Removes the highlight class from all previously marked elements and
+ * destroys any associated image overlays.
+ *
+ * @param {string} key - Audit key (e.g. 'images-without-alt')
+ */
+export function clearHighlight(key) {
+ const cls = `mageforge-audit-${key}`;
+ document.querySelectorAll(`.${cls}`).forEach(el => {
+ el.classList.remove(cls);
+ if (el.tagName === 'IMG') {
+ const entry = imgOverlayRegistry.get(el);
+ if (entry) {
+ entry.keys.delete(key);
+ if (entry.keys.size === 0) {
+ entry.cleanup();
+ imgOverlayRegistry.delete(el);
+ }
+ }
+ }
+ });
+}
+
+/**
+ * Highlights a set of elements by adding the audit CSS class, scrolls to the
+ * first result, and updates the counter badge on the toolbar menu item.
+ *
+ * For
elements a fixed-position overlay is injected so the red
+ * background is visible regardless of parent overflow or border-radius.
+ *
+ * @param {Element[]} elements - Elements to mark
+ * @param {string} key - Audit key (e.g. 'images-without-alt')
+ * @param {object} context - Alpine toolbar component instance
+ */
+export function applyHighlight(elements, key, context) {
+ if (elements.length === 0) {
+ context.setAuditCounterBadge(key, '0', 'success');
+ return;
+ }
+ const cls = `mageforge-audit-${key}`;
+ elements.forEach(el => {
+ el.classList.add(cls);
+ if (el.tagName === 'IMG') {
+ const existing = imgOverlayRegistry.get(el);
+ if (existing) {
+ existing.keys.add(key);
+ } else {
+ imgOverlayRegistry.set(el, {
+ cleanup: createImgOverlay(el),
+ keys: new Set([key]),
+ });
+ }
+ }
+ });
+ elements[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
+ context.setAuditCounterBadge(key, `${elements.length}`, 'error');
+}
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 07d10c9..ba7c1e2 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
@@ -2,7 +2,7 @@
* MageForge Toolbar Audit – Images without ALT
*/
-const HIGHLIGHT_CLASS = 'mageforge-audit-images-without-alt';
+import { applyHighlight, clearHighlight } from './highlight.js';
/** @type {import('./index.js').AuditDefinition} */
export default {
@@ -17,7 +17,7 @@ export default {
*/
run(context, active) {
if (!active) {
- document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS));
+ clearHighlight(this.key);
return;
}
@@ -28,13 +28,6 @@ export default {
return !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(HIGHLIGHT_CLASS));
- images[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
- context.setAuditCounterBadge('images-without-alt', `${images.length}`, 'error');
+ applyHighlight(images, this.key, context);
},
};
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
index 2898ed2..40e1388 100644
--- a/src/view/frontend/web/js/toolbar/audits/images-without-dimensions.js
+++ b/src/view/frontend/web/js/toolbar/audits/images-without-dimensions.js
@@ -4,7 +4,7 @@
* Images missing explicit width and height attributes cause Cumulative Layout Shift (CLS).
*/
-const HIGHLIGHT_CLASS = 'mageforge-audit-images-without-dimensions';
+import { applyHighlight, clearHighlight } from './highlight.js';
/** @type {import('./index.js').AuditDefinition} */
export default {
@@ -19,7 +19,7 @@ export default {
*/
run(context, active) {
if (!active) {
- document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS));
+ clearHighlight(this.key);
return;
}
@@ -30,13 +30,6 @@ export default {
return !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');
+ applyHighlight(images, this.key, context);
},
};
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
index c6e3029..967ed2c 100644
--- 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
@@ -5,7 +5,7 @@
* wasting bandwidth and slowing initial page load.
*/
-const HIGHLIGHT_CLASS = 'mageforge-audit-images-without-lazy-load';
+import { applyHighlight, clearHighlight } from './highlight.js';
/** @type {import('./index.js').AuditDefinition} */
export default {
@@ -20,7 +20,7 @@ export default {
*/
run(context, active) {
if (!active) {
- document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS));
+ clearHighlight(this.key);
return;
}
@@ -35,13 +35,6 @@ export default {
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');
+ applyHighlight(images, this.key, context);
},
};
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 8ccd562..4edab9a 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
@@ -5,7 +5,7 @@
* to screen reader users.
*/
-const HIGHLIGHT_CLASS = 'mageforge-audit-inputs-without-label';
+import { applyHighlight, clearHighlight } from './highlight.js';
/** @type {import('./index.js').AuditDefinition} */
export default {
@@ -20,7 +20,7 @@ export default {
*/
run(context, active) {
if (!active) {
- document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS));
+ clearHighlight(this.key);
return;
}
@@ -39,13 +39,6 @@ export default {
return true;
});
- if (inputs.length === 0) {
- context.setAuditCounterBadge('inputs-without-label', '0', 'success');
- return;
- }
-
- inputs.forEach(el => el.classList.add(HIGHLIGHT_CLASS));
- inputs[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
- context.setAuditCounterBadge('inputs-without-label', `${inputs.length}`, 'error');
+ applyHighlight(inputs, this.key, context);
},
};
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 ede5db7..7d028a8 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
@@ -6,7 +6,7 @@
* - 3:1 for large text (>=18pt or >=14pt bold)
*/
-const HIGHLIGHT_CLASS = 'mageforge-audit-low-contrast';
+import { applyHighlight, clearHighlight } from './highlight.js';
const _colorCanvas = document.createElement('canvas');
_colorCanvas.width = _colorCanvas.height = 1;
@@ -164,7 +164,7 @@ export default {
*/
run(context, active) {
if (!active) {
- document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS));
+ clearHighlight(this.key);
return;
}
@@ -189,13 +189,6 @@ export default {
return ratio < threshold;
});
- if (failing.length === 0) {
- context.setAuditCounterBadge('low-contrast-text', '0', 'success');
- return;
- }
-
- failing.forEach(el => el.classList.add(HIGHLIGHT_CLASS));
- failing[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
- context.setAuditCounterBadge('low-contrast-text', `${failing.length}`, 'error');
+ applyHighlight(failing, this.key, context);
},
};