Skip to content
51 changes: 45 additions & 6 deletions src/view/frontend/web/css/toolbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
--mageforge-color-orange: #fb923c;
--mageforge-color-pink: #C850C0;
--mageforge-color-amber: #edb04d;
--mageforge-color-amber-alpha-15: rgba(237, 176, 77, 0.15);
--mageforge-color-amber-alpha-35: rgba(237, 176, 77, 0.35);
--mageforge-bg-dark: rgba(15, 23, 42, 0.98);
--mageforge-bg-dark-alt: rgba(30, 41, 59, 0.98);
--mageforge-border-color: rgba(148, 163, 184, 0.15);
Expand Down Expand Up @@ -146,12 +148,14 @@
bottom: calc(100% + 8px);
left: 0;
background: linear-gradient(135deg, var(--mageforge-bg-dark) 0%, var(--mageforge-bg-dark-alt) 100%);
backdrop-filter: blur(12px);
border: 1px solid var(--mageforge-border-color);
border-radius: 10px;
box-shadow: 0 -8px 24px var(--mageforge-shadow-lg), 0 4px 8px var(--mageforge-shadow-sm);
padding: 6px;
box-shadow: 0 -8px 24px var(--mageforge-shadow-lg), 0 6px 10px var(--mageforge-shadow-sm);
padding: 0 6px 6px;
min-width: 350px;
max-height: 90vh;
overflow-y: auto;
overflow-x: hidden;
font-family: var(--mageforge-font-family);
display: none;
opacity: 0;
Expand All @@ -177,11 +181,16 @@

.mageforge-toolbar-menu-title {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding: 4px 8px 2px;
padding: 10px 8px 2px;
border-bottom: 1px solid var(--mageforge-border-color);
margin-bottom: 4px;
position: sticky;
top: 0;
z-index: 99999;
background: linear-gradient(135deg, var(--mageforge-bg-dark) 0%, var(--mageforge-bg-dark-alt) 100%);
}

.mageforge-toolbar-menu-title-text {
Expand All @@ -194,11 +203,12 @@
background-image: var(--gradient-brand);
background-clip: text;
-webkit-background-clip: text;
display: block;
}

.mageforge-toolbar-menu-close {
background: none;
border: none;
border: 1px solid var(--mageforge-border-color);
cursor: pointer;
color: var(--mageforge-color-slate-400);
padding: 6px;
Expand Down Expand Up @@ -254,6 +264,15 @@
color: var(--mageforge-color-red);
}

.mageforge-toolbar-menu-item.mageforge-active--warning {
background: var(--mageforge-color-amber-alpha-15);
border-color: var(--mageforge-color-amber-alpha-35);
}

.mageforge-toolbar-menu-item.mageforge-active--warning .mageforge-toolbar-menu-label {
color: var(--mageforge-color-amber);
}

.mageforge-toolbar-menu-icon {
font-size: 16px;
flex-shrink: 0;
Expand Down Expand Up @@ -309,9 +328,17 @@
}

.mageforge-toolbar-menu-desc {
font-size: 10px;
color: var(--mageforge-color-slate-400);
font-size: 11px;
line-height: 1.3;
user-select: text;
cursor: text;
}

.mageforge-toolbar-menu-desc.mageforge-active {
color: var(--mageforge-color-orange);
font-size: 12px;
user-select: text;
}

.mageforge-toolbar-menu-label-row {
Expand Down Expand Up @@ -351,6 +378,12 @@
border: 1px solid var(--mageforge-color-red-alpha-35);
}

.mageforge-toolbar-menu-status--warning {
color: var(--mageforge-color-amber);
background: var(--mageforge-color-amber-alpha-15);
border: 1px solid var(--mageforge-color-amber-alpha-35);
}

/* ============================================================================
Menu Groups
========================================================================== */
Expand Down Expand Up @@ -421,6 +454,12 @@
z-index: 9999997;
}

.mageforge-audit-overlay--warning {
background-color: var(--mageforge-color-amber-alpha-35);
outline-color: var(--mageforge-color-amber);
outline-style: dashed;
}

/* ============================================================================
Feedback Toast
========================================================================== */
Expand Down
23 changes: 22 additions & 1 deletion src/view/frontend/web/js/toolbar/audits.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,26 @@ export const auditMethods = {
}
},

/**
* Update the description text of an audit menu item.
* Useful for audits that want to surface detail (e.g. which IDs are duplicated).
*
* @param {string} key
* @param {string} text
*/
setAuditDescription(key, text) {
if (!this.menu) return;
const item = this.menu.querySelector(`[data-audit-key="${key}"]`);
if (!item) return;
const desc = item.querySelector('.mageforge-toolbar-menu-desc');
if (!desc) return;
const originalText = desc.dataset.originalText ?? desc.textContent;
if (!desc.dataset.originalText) desc.dataset.originalText = originalText;
const isChanged = text !== originalText;
desc.textContent = text;
desc.classList.toggle('mageforge-active', isChanged);
},

/**
* Set the inline counter badge of an audit menu item.
*
Expand All @@ -109,7 +129,8 @@ 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
// Reflect error/warning/success on the active item background
item.classList.toggle('mageforge-active--error', type === 'error');
item.classList.toggle('mageforge-active--warning', type === 'warning');
},
};
39 changes: 39 additions & 0 deletions src/view/frontend/web/js/toolbar/audits/buttons-without-type.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* MageForge Toolbar Audit – Buttons without type
*
* A <button> without an explicit type attribute defaults to type="submit",
* which can accidentally submit parent forms. Always set type="button",
* type="submit", or type="reset" explicitly.
*/

import { applyHighlight, clearHighlight } from './highlight.js';

/** @type {import('./index.js').AuditDefinition} */
export default {
key: 'buttons-without-type',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M8 13v-8.5a1.5 1.5 0 0 1 3 0v7.5"></path><path d="M11 11.5a1.5 1.5 0 0 1 3 0v1.5"></path><path d="M14 12a1.5 1.5 0 0 1 3 0v2"></path><path d="M17 13.5a1.5 1.5 0 0 1 3 0v3.5a6 6 0 0 1 -6 6h-2h.208a6 6 0 0 1 -5.012 -2.7l-.196 -.3c-.312 -.479 -1.407 -2.388 -3.286 -5.728a1.5 1.5 0 0 1 .536 -2.022a1.867 1.867 0 0 1 2.28 .28l1.47 1.47"></path></svg>',
label: 'Buttons without a type',
description: 'Highlight a button missing an explicit type attribute (defaults to submit)',

/**
* @param {object} context - Alpine toolbar component instance
* @param {boolean} active - true = activate, false = deactivate
*/
run(context, active) {
if (!active) {
clearHighlight(this.key);
return;
}

const buttons = Array.from(document.querySelectorAll('button')).filter(btn => {
const type = btn.getAttribute('type');
if (type !== null && type.trim() !== '') return false;
if (!btn.offsetParent && getComputedStyle(btn).position !== 'fixed') return false;
const style = getComputedStyle(btn);
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return false;
return true;
});

applyHighlight(buttons, this.key, context);
},
};
65 changes: 65 additions & 0 deletions src/view/frontend/web/js/toolbar/audits/duplicate-ids.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* MageForge Toolbar Audit – Duplicate IDs
*
* IDs must be unique per document. Duplicate IDs break label associations,
* aria-labelledby / aria-describedby references, fragment links, and cause
* unpredictable behaviour with JavaScript querySelector.
*
* Icon source: Tabler Icons (MIT)
*/

import { applyHighlight, clearHighlight } from './highlight.js';

/** @type {import('./index.js').AuditDefinition} */
export default {
key: 'duplicate-ids',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M7 8a4 4 0 0 1 4 4a4 4 0 0 1 4 -4"></path><path d="M7 16a4 4 0 0 0 4 -4a4 4 0 0 0 4 4"></path><path d="M9 12h6"></path><path d="M3 12h2"></path><path d="M19 12h2"></path></svg>',
label: 'Duplicate IDs',
description: 'Highlight elements sharing an ID with at least one other element',

/**
* @param {object} context - Alpine toolbar component instance
* @param {boolean} active - true = activate, false = deactivate
*/
run(context, active) {
if (!active) {
clearHighlight(this.key);
context.setAuditDescription(this.key, this.description);
return;
}

/** @type {Map<string, Element[]>} */
const idMap = new Map();

document.querySelectorAll('[id]').forEach(el => {
const id = el.id;
if (!id) return;
if (el.closest('.mageforge-toolbar')) return;
if (!idMap.has(id)) {
idMap.set(id, []);
}
idMap.get(id).push(el);
});

/** @type {string[]} */
const duplicateIdNames = [];
const duplicates = [];
idMap.forEach((els, id) => {
if (els.length > 1) {
duplicateIdNames.push(`#${id} (×${els.length})`);
els.forEach(el => duplicates.push(el));
}
});

if (duplicates.length > 0) {
context.setAuditDescription(
this.key,
`Duplicate: ${duplicateIdNames.join(', ')}`
);
Comment thread
dermatz marked this conversation as resolved.
} else {
context.setAuditDescription(this.key, this.description);
}

applyHighlight(duplicates, this.key, context);
},
};
52 changes: 52 additions & 0 deletions src/view/frontend/web/js/toolbar/audits/empty-interactive.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* MageForge Toolbar Audit – Empty Links & Buttons
*
* Links and buttons without an accessible name are unusable for screen
* reader and keyboard users (WCAG 2.1 SC 4.1.2, 2.4.6).
*/

import { applyHighlight, clearHighlight } from './highlight.js';

/** @type {import('./index.js').AuditDefinition} */
export default {
key: 'empty-interactive',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M17 12h-14"></path><path d="M6 9l-3 3l3 3"></path><path d="M20 6v.01"></path><path d="M20 12v.01"></path><path d="M20 18v.01"></path></svg>',
label: 'Empty Links & Buttons',
description: 'Highlight links and buttons missing an accessible name',

/**
* @param {object} context - Alpine toolbar component instance
* @param {boolean} active - true = activate, false = deactivate
*/
run(context, active) {
if (!active) {
clearHighlight(this.key);
return;
}

const elements = Array.from(document.querySelectorAll('a[href], button')).filter(el => {
// Visibility check
if (!el.offsetParent && getComputedStyle(el).position !== 'fixed') return false;
const style = getComputedStyle(el);
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return false;

// Accessible name sources
if (el.getAttribute('aria-label')?.trim()) return false;
if (el.getAttribute('title')?.trim()) return false;
if (el.getAttribute('aria-labelledby')?.trim().split(/\s+/).some(id => document.getElementById(id)?.textContent.trim())) return false;

// Text content (excluding whitespace-only)
if (el.textContent.trim()) return false;

// Child <img> with non-empty alt (trimmed)
if (Array.from(el.querySelectorAll('img[alt]')).some(img => img.getAttribute('alt')?.trim())) return false;

// Child <svg> with a <title> element
if (el.querySelector('svg title')?.textContent.trim()) return false;

return true;
});

applyHighlight(elements, this.key, context);
},
};
31 changes: 22 additions & 9 deletions src/view/frontend/web/js/toolbar/audits/highlight.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,13 @@ function scheduleUpdate() {
* Returns a cleanup function that removes the overlay and deregisters it.
*
* @param {Element} el
* @param {'error'|'warning'} [severity='error']
* @returns {function} cleanup
*/
function createOverlay(el) {
function createOverlay(el, severity = 'error') {
const overlay = document.createElement('span');
overlay.className = AUDIT_OVERLAY_CLASS;
if (severity === 'warning') overlay.classList.add('mageforge-audit-overlay--warning');
document.body.appendChild(overlay);

function update() {
Expand Down Expand Up @@ -122,13 +124,22 @@ export function clearHighlight(key) {
* the first result, and updates the counter badge on the toolbar menu item.
* Works for any element type – no special casing required in audit code.
*
* @param {Element[]} elements - Elements to mark
* @param {string} key - Audit key (e.g. 'images-without-alt')
* @param {object} context - Alpine toolbar component instance
* @param {Element[]} elements - Elements to mark
* @param {string} key - Audit key (e.g. 'images-without-alt')
* @param {object} context - Alpine toolbar component instance
* @param {object} [options={}] - Options
* @param {'error'|'warning'} [options.severity='error'] - Visual severity level
* @param {boolean} [options.skipBadge=false] - Skip badge + scroll update
*/
export function applyHighlight(elements, key, context) {
export function applyHighlight(elements, key, context, options = {}) {
const severity = options.severity ?? 'error';
const skipBadge = options.skipBadge ?? false;

// Never flag elements that are part of the MageForge Toolbar itself
elements = elements.filter(el => !el.closest('.mageforge-toolbar'));

if (elements.length === 0) {
context.setAuditCounterBadge(key, '0', 'success');
if (!skipBadge) context.setAuditCounterBadge(key, '0', 'success');
return;
}
const cls = `mageforge-audit-${key}`;
Expand All @@ -139,11 +150,13 @@ export function applyHighlight(elements, key, context) {
existing.keys.add(key);
} else {
overlayRegistry.set(el, {
cleanup: createOverlay(el),
cleanup: createOverlay(el, severity),
keys: new Set([key]),
});
}
});
elements[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
context.setAuditCounterBadge(key, `${elements.length}`, 'error');
if (!skipBadge) {
elements[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
context.setAuditCounterBadge(key, `${elements.length}`, severity);
}
}
Loading
Loading