, , or [role="button"] that lacks both
+ * aria-hidden="true" and a child element is flagged as a warning.
+ */
+
+import { createAudit } from "./createAudit.js";
+
+/** @type {import('./index.js').AuditDefinition} */
+export default createAudit(
+ {
+ key: "svg-icons-aria-hidden",
+ icon: ' ',
+ label: "SVG Icons without aria-hidden",
+ description:
+ "Flags decorative SVGs missing aria-hidden inside interactive elements",
+ },
+ () => {
+ return Array.from(
+ document.querySelectorAll('button svg, a svg, [role="button"] svg'),
+ ).filter((svg) => {
+ // Skip toolbar's own SVGs
+ if (svg.closest(".mageforge-toolbar")) return false;
+ // Already hidden from AT
+ if (svg.getAttribute("aria-hidden") === "true") return false;
+ // Has a → informative, not decorative
+ if (svg.querySelector("title")) return false;
+ // Has aria-label or aria-labelledby → informative
+ if (svg.getAttribute("aria-label") || svg.getAttribute("aria-labelledby"))
+ return false;
+ return true;
+ });
+ },
+);
diff --git a/src/view/frontend/web/js/toolbar/audits/unsafe-blank-target.js b/src/view/frontend/web/js/toolbar/audits/unsafe-blank-target.js
index 794ed757..b8ef9d0c 100644
--- a/src/view/frontend/web/js/toolbar/audits/unsafe-blank-target.js
+++ b/src/view/frontend/web/js/toolbar/audits/unsafe-blank-target.js
@@ -9,43 +9,32 @@
* Icon source: Tabler Icons (MIT)
*/
-import { applyHighlight, clearHighlight } from "./highlight.js";
+import { createAudit } from "./createAudit.js";
-/** @type {import('./index.js').AuditDefinition} */
-export default {
- key: "unsafe-blank-target",
- icon: ' ',
- label: "Unsafe Blank Target",
- description:
- 'Highlight target="_blank" links with neither rel="noopener" nor rel="noreferrer"',
-
- /**
- * @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[target="_blank"]'),
- ).filter((el) => {
- if (!el.offsetParent && getComputedStyle(el).position !== "fixed")
- return false;
- const style = getComputedStyle(el);
- if (
- style.visibility === "hidden" ||
- style.display === "none" ||
- parseFloat(style.opacity) === 0
- )
- return false;
-
- const rel = (el.getAttribute("rel") || "").toLowerCase().split(/\s+/);
- return !rel.includes("noopener") && !rel.includes("noreferrer");
- });
+export default createAudit(
+ {
+ key: "unsafe-blank-target",
+ icon: ' ',
+ label: "Unsafe Blank Target",
+ description:
+ 'Highlight target="_blank" links with neither rel="noopener" nor rel="noreferrer"',
+ },
+ () => {
+ return Array.from(document.querySelectorAll('a[target="_blank"]')).filter(
+ (el) => {
+ if (!el.offsetParent && getComputedStyle(el).position !== "fixed")
+ return false;
+ const style = getComputedStyle(el);
+ if (
+ style.visibility === "hidden" ||
+ style.display === "none" ||
+ parseFloat(style.opacity) === 0
+ )
+ return false;
- applyHighlight(elements, this.key, context);
+ const rel = (el.getAttribute("rel") || "").toLowerCase().split(/\s+/);
+ return !rel.includes("noopener") && !rel.includes("noreferrer");
+ },
+ );
},
-};
+);
diff --git a/src/view/frontend/web/js/toolbar/ui.js b/src/view/frontend/web/js/toolbar/ui.js
index f5cd3a8c..2161f4f4 100644
--- a/src/view/frontend/web/js/toolbar/ui.js
+++ b/src/view/frontend/web/js/toolbar/ui.js
@@ -1,603 +1,11 @@
-/**
- * MageForge Toolbar - DOM construction and menu controls
- */
-
-const LOGO_SVG_PATH =
- "M176 0L0 101.614V297L176 398.614L352 297V101.614L176 0ZM39 275.5V124L76.2391 101.614L101.5 162L126.5 73.4393L164.5 51.5V346.939L126.5 325V188L108.5 239H95L76.2391 188V297L39 275.5ZM187.5 346.939V51.5L313 124V170H275.5V146.368L225.5 117.5V188H280V226.5H225.5V325L187.5 346.939Z";
-
-function createLogoSvg(fill) {
- return ` `;
-}
+import { buildMethods } from "./ui/build.js";
+import { scoreMethods } from "./ui/score.js";
+import { itemMethods } from "./ui/items.js";
+import { controls } from "./ui/controls.js";
export const uiMethods = {
- createToolbar() {
- const logoSvgOrange = createLogoSvg("#E5622A");
- const logoSvgWhite = createLogoSvg("white");
-
- 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"),
- );
- }
-
- if (this.$el && this.$el.hasAttribute("data-position")) {
- this.container.setAttribute(
- "data-position",
- this.$el.getAttribute("data-position"),
- );
- }
-
- if (this.$el && this.$el.getAttribute("data-show-labels") === "0") {
- this.container.classList.add("mageforge-toolbar--no-labels");
- }
-
- // Menu popup (before buttons so it sits correctly in stacking context)
- this.menu = document.createElement("div");
- this.menu.className = "mageforge-toolbar-menu";
-
- const menuTitle = document.createElement("div");
- menuTitle.className = "mageforge-toolbar-menu-title";
-
- menuTitle.innerHTML = `
-
-
-
- `;
- menuTitle.querySelector(".mageforge-toolbar-menu-close").onclick = (e) => {
- e.stopPropagation();
- this.deactivateAllAudits();
- this.closeMenu();
- };
- this.menu.appendChild(menuTitle);
-
- // Group audits by their group key; ungrouped items fall through
- const grouped = {};
- const ungrouped = [];
- this.getAudits().forEach((audit) => {
- if (audit.group) {
- (grouped[audit.group] = grouped[audit.group] || []).push(audit);
- } else {
- ungrouped.push(audit);
- }
- });
-
- // Render defined groups in order
- this.getAuditGroups().forEach((group) => {
- const items = grouped[group.key];
- if (!items?.length) return;
- this.menu.appendChild(
- this.createMenuGroup(group.key, group.label, items),
- );
- });
-
- // Render any ungrouped audits below
- ungrouped.forEach((audit) => {
- this.menu.appendChild(
- this.createMenuItem(
- audit.key,
- audit.icon,
- audit.label,
- audit.description,
- () => this.runAudit(audit.key),
- ),
- );
- });
-
- // Footer – Health Score Gauge + Run All Tests
- const menuFooter = document.createElement("div");
- menuFooter.className = "mageforge-toolbar-menu-footer";
-
- const showHealthScore =
- this.$el?.getAttribute("data-show-health-score") !== "0";
-
- if (showHealthScore) {
- const ARC_LENGTH = 157.08;
- const gradId = `mf-gauge-grad-${Math.random().toString(36).slice(2, 8)}`;
- const healthWrapper = document.createElement("div");
- healthWrapper.className = "mageforge-toolbar-health-wrapper";
-
- const gaugeSvg = document.createElementNS(
- "http://www.w3.org/2000/svg",
- "svg",
- );
- gaugeSvg.setAttribute("viewBox", "0 0 120 70");
- gaugeSvg.setAttribute("class", "mageforge-toolbar-health-gauge");
- gaugeSvg.setAttribute("aria-hidden", "true");
- gaugeSvg.innerHTML = `
-
-
-
-
-
-
-
-
-
-
-
- `;
- healthWrapper.appendChild(gaugeSvg);
-
- const scoreTextWrapper = document.createElement("div");
- scoreTextWrapper.className = "mageforge-toolbar-health-score-text";
- scoreTextWrapper.innerHTML = `
-
- -- /100
-
- Overall Health Score
- `;
- healthWrapper.appendChild(scoreTextWrapper);
- menuFooter.appendChild(healthWrapper);
-
- // Run All Tests + Reset button row (with score)
- const buttonRow = document.createElement("div");
- buttonRow.className = "mageforge-toolbar-menu-button-row";
-
- this.runAllButton = document.createElement("div");
- this.runAllButton.setAttribute("role", "button");
- this.runAllButton.setAttribute("tabindex", "0");
- this.runAllButton.className = "mageforge-toolbar-menu-run-all";
- this.runAllButton.innerHTML = `
-
- RUN ALL TESTS
- `;
- this.runAllButton.onclick = (e) => {
- e.stopPropagation();
- this.runAllAuditsForScore();
- };
- this.runAllButton.onkeydown = (e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- e.stopPropagation();
- this.runAllAuditsForScore();
- }
- if (e.key === " ") {
- e.preventDefault();
- }
- };
- this.runAllButton.onkeyup = (e) => {
- if (e.key === " ") {
- e.stopPropagation();
- this.runAllAuditsForScore();
- }
- };
- buttonRow.appendChild(this.runAllButton);
-
- this.resetButton = document.createElement("div");
- this.resetButton.setAttribute("role", "button");
- this.resetButton.setAttribute("tabindex", "0");
- this.resetButton.className = "mageforge-toolbar-menu-reset";
- this.resetButton.title = "Reset score and deactivate all audits";
- this.resetButton.setAttribute(
- "aria-label",
- "Reset score and deactivate all audits",
- );
- this.resetButton.innerHTML = ` `;
- this.resetButton.onclick = (e) => {
- e.stopPropagation();
- this.resetScore();
- };
- this.resetButton.onkeydown = (e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- e.stopPropagation();
- this.resetScore();
- }
- if (e.key === " ") {
- e.preventDefault();
- }
- };
- this.resetButton.onkeyup = (e) => {
- if (e.key === " ") {
- e.stopPropagation();
- this.resetScore();
- }
- };
- buttonRow.appendChild(this.resetButton);
-
- menuFooter.appendChild(buttonRow);
- } else {
- // No health score – button row with Run All + Reset
- const buttonRow = document.createElement("div");
- buttonRow.className = "mageforge-toolbar-menu-button-row";
-
- this.runAllButton = document.createElement("div");
- this.runAllButton.setAttribute("role", "button");
- this.runAllButton.setAttribute("tabindex", "0");
- this.runAllButton.className = "mageforge-toolbar-menu-run-all";
- this.runAllButton.innerHTML = `
-
- RUN ALL TESTS
- `;
- this.runAllButton.onclick = (e) => {
- e.stopPropagation();
- this.runAllAuditsForScore();
- };
- this.runAllButton.onkeydown = (e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- e.stopPropagation();
- this.runAllAuditsForScore();
- }
- if (e.key === " ") {
- e.preventDefault();
- }
- };
- this.runAllButton.onkeyup = (e) => {
- if (e.key === " ") {
- e.stopPropagation();
- this.runAllAuditsForScore();
- }
- };
- buttonRow.appendChild(this.runAllButton);
-
- this.resetButton = document.createElement("div");
- this.resetButton.setAttribute("role", "button");
- this.resetButton.setAttribute("tabindex", "0");
- this.resetButton.className = "mageforge-toolbar-menu-reset";
- this.resetButton.title = "Deactivate all audits";
- this.resetButton.setAttribute("aria-label", "Deactivate all audits");
- this.resetButton.innerHTML = ` `;
- this.resetButton.onclick = (e) => {
- e.stopPropagation();
- this.resetScore();
- };
- this.resetButton.onkeydown = (e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- e.stopPropagation();
- this.resetScore();
- }
- if (e.key === " ") {
- e.preventDefault();
- }
- };
- this.resetButton.onkeyup = (e) => {
- if (e.key === " ") {
- e.stopPropagation();
- this.resetScore();
- }
- };
- buttonRow.appendChild(this.resetButton);
-
- menuFooter.appendChild(buttonRow);
- }
-
- const credit = document.createElement("div");
- credit.className = "mageforge-toolbar-menu-credit";
- credit.innerHTML = `Built with by `;
- menuFooter.appendChild(credit);
- this.menu.appendChild(menuFooter);
-
- // Burger button (left) — div avoids Luma/theme button CSS overrides
- this.burgerButton = document.createElement("div");
- this.burgerButton.className = "mageforge-toolbar-burger";
- this.burgerButton.title = "Audit tools";
- this.burgerButton.setAttribute("role", "button");
- this.burgerButton.setAttribute("tabindex", "0");
- this.burgerButton.setAttribute("aria-label", "Open audit tools menu");
- this.burgerButton.setAttribute("aria-expanded", "false");
- this.burgerButton.innerHTML = `
- ${logoSvgWhite}
- MageForge
- `;
- this.burgerButton.onclick = (e) => {
- e.preventDefault();
- e.stopPropagation();
- this.toggleMenu();
- };
- this.burgerButton.onkeydown = (e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- e.stopPropagation();
- this.toggleMenu();
- }
- if (e.key === " ") {
- e.preventDefault(); // prevent page scroll
- }
- };
- this.burgerButton.onkeyup = (e) => {
- if (e.key === " ") {
- e.stopPropagation();
- this.toggleMenu();
- }
- };
-
- this.container.appendChild(this.menu);
- this.container.appendChild(this.burgerButton);
- // Note: inspector button is appended by the mageforgeInspector Alpine component via _appendInspectorButton()
-
- // Close menu when clicking outside the toolbar
- this._outsideClickHandler = (e) => {
- if (this.menuOpen && !this.container.contains(e.target)) {
- this.closeMenu();
- }
- };
- document.addEventListener("click", this._outsideClickHandler);
-
- document.body.appendChild(this.container);
- },
-
- /**
- * Create a collapsible group section containing audit menu items.
- *
- * @param {string} key - Group key
- * @param {string} label - Display label
- * @param {object[]} audits - Audit definitions belonging to this group
- * @return {HTMLDivElement}
- */
- createMenuGroup(key, label, audits) {
- const group = document.createElement("div");
- group.className = "mageforge-toolbar-menu-group";
- group.dataset.groupKey = key;
-
- const header = document.createElement("div");
- header.setAttribute("role", "button");
- header.setAttribute("tabindex", "0");
- header.className = "mageforge-toolbar-menu-group-header";
- header.setAttribute(
- "aria-expanded",
- String(!this.collapsedGroups.has(key)),
- );
- header.onclick = (e) => {
- e.preventDefault();
- e.stopPropagation();
- this.toggleGroup(key);
- };
- header.onkeydown = (e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- e.stopPropagation();
- this.toggleGroup(key);
- }
- if (e.key === " ") {
- e.preventDefault();
- }
- };
- header.onkeyup = (e) => {
- if (e.key === " ") {
- e.stopPropagation();
- this.toggleGroup(key);
- }
- };
-
- const headerLabel = document.createElement("span");
- headerLabel.className = "mageforge-toolbar-menu-group-label";
- headerLabel.textContent = label;
-
- const chevron = document.createElement("span");
- chevron.className = "mageforge-toolbar-menu-group-chevron";
- chevron.innerHTML = ` `;
-
- header.appendChild(headerLabel);
- header.appendChild(chevron);
- group.appendChild(header);
-
- group.classList.toggle(
- "mageforge-toolbar-menu-group--collapsed",
- this.collapsedGroups.has(key),
- );
-
- const items = document.createElement("div");
- items.className = "mageforge-toolbar-menu-group-items";
-
- audits.forEach((audit) => {
- items.appendChild(
- this.createMenuItem(
- audit.key,
- audit.icon,
- audit.label,
- audit.description,
- () => this.runAudit(audit.key),
- key,
- ),
- );
- });
-
- group.appendChild(items);
- return group;
- },
-
- /**
- * Create a single audit menu item button
- *
- * @param {string} key
- * @param {string} icon
- * @param {string} label
- * @param {string} description
- * @param {Function} callback
- * @param {?string} groupKey - Optional parent group key for the item
- * @return {HTMLDivElement}
- */
- createMenuItem(key, icon, label, description, callback, groupKey = null) {
- const item = document.createElement("div");
- item.setAttribute("role", "button");
- item.setAttribute("tabindex", "0");
- item.className = "mageforge-toolbar-menu-item";
- item.dataset.auditKey = key;
- if (groupKey) item.dataset.groupKey = groupKey;
- item.setAttribute("aria-pressed", "false");
-
- const iconEl = document.createElement("span");
- iconEl.className = "mageforge-toolbar-menu-icon";
- iconEl.innerHTML = icon;
-
- const labelEl = document.createElement("span");
- labelEl.className = "mageforge-toolbar-menu-label";
- labelEl.textContent = label;
-
- const statusEl = document.createElement("span");
- statusEl.className = "mageforge-toolbar-menu-status";
-
- const labelRowEl = document.createElement("span");
- labelRowEl.className = "mageforge-toolbar-menu-label-row";
- labelRowEl.appendChild(labelEl);
- labelRowEl.appendChild(statusEl);
-
- const descEl = document.createElement("span");
- descEl.className = "mageforge-toolbar-menu-desc";
- descEl.textContent = description;
- descEl.addEventListener("click", (e) => {
- if (descEl.classList.contains("mageforge-active")) e.stopPropagation();
- });
- descEl.addEventListener("mousedown", (e) => {
- if (descEl.classList.contains("mageforge-active")) e.stopPropagation();
- });
- const textEl = document.createElement("span");
- textEl.className = "mageforge-toolbar-menu-text";
- textEl.appendChild(labelRowEl);
- textEl.appendChild(descEl);
-
- const toggleEl = document.createElement("span");
- toggleEl.className = "mageforge-toolbar-menu-toggle";
-
- item.appendChild(iconEl);
- item.appendChild(textEl);
- item.appendChild(toggleEl);
-
- item.onclick = (e) => {
- e.preventDefault();
- e.stopPropagation();
- callback();
- };
- item.onkeydown = (e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- e.stopPropagation();
- callback();
- }
- if (e.key === " ") {
- e.preventDefault();
- }
- };
- item.onkeyup = (e) => {
- if (e.key === " ") {
- 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);
- item.setAttribute("aria-pressed", String(active));
- if (!active) {
- item.classList.remove("mageforge-active--error");
- const status = item.querySelector(".mageforge-toolbar-menu-status");
- if (status) {
- status.textContent = "";
- status.className = "mageforge-toolbar-menu-status";
- }
- }
- this.updateToggleAllButton();
- },
-
- /**
- * No-op – retained for compatibility; the Run All Tests button has no dynamic label.
- */
- updateToggleAllButton() {},
-
- /**
- * Reset the health score gauge and deactivate all audits.
- */
- resetScore() {
- this.deactivateAllAudits();
- if (!this.menu) return;
- const ARC_LENGTH = 157.08;
- const progress = this.menu.querySelector(
- ".mageforge-health-gauge-progress",
- );
- const needle = this.menu.querySelector(".mageforge-health-gauge-needle");
- const numberEl = this.menu.querySelector(
- ".mageforge-toolbar-health-score-number",
- );
- if (numberEl) numberEl.textContent = "--";
- if (progress) progress.setAttribute("stroke-dasharray", `0 ${ARC_LENGTH}`);
- if (needle) needle.setAttribute("opacity", "0");
- },
-
- /**
- * Update the health score gauge and numeric display (0–100).
- *
- * @param {number} score
- */
- updateHealthScore(score) {
- if (!this.menu) return;
- const ARC_LENGTH = 157.08;
- const progress = this.menu.querySelector(
- ".mageforge-health-gauge-progress",
- );
- const needle = this.menu.querySelector(".mageforge-health-gauge-needle");
- const numberEl = this.menu.querySelector(
- ".mageforge-toolbar-health-score-number",
- );
-
- if (numberEl) numberEl.textContent = score;
-
- if (progress) {
- const dash = ((score / 100) * ARC_LENGTH).toFixed(2);
- progress.setAttribute("stroke-dasharray", `${dash} ${ARC_LENGTH}`);
- }
-
- if (needle) {
- const rad = (1 - score / 100) * Math.PI;
- needle.setAttribute("x2", (60 + 45 * Math.cos(rad)).toFixed(1));
- needle.setAttribute("y2", (65 - 45 * Math.sin(rad)).toFixed(1));
- needle.setAttribute("opacity", "1");
- }
- },
-
- toggleMenu() {
- this.menuOpen ? this.closeMenu() : this.openMenu();
- },
-
- openMenu() {
- this.menuOpen = true;
- this.menu.classList.add("mageforge-menu-open");
- this.burgerButton.classList.add("mageforge-active");
- this.burgerButton.setAttribute("aria-expanded", "true");
- },
-
- closeMenu() {
- this.menuOpen = false;
- this.menu.classList.remove("mageforge-menu-open");
- this.burgerButton.classList.remove("mageforge-active");
- this.burgerButton.setAttribute("aria-expanded", "false");
- },
-
- destroyToolbar() {
- if (this._outsideClickHandler) {
- document.removeEventListener("click", this._outsideClickHandler);
- this._outsideClickHandler = null;
- }
- if (this.container && this.container.parentNode) {
- this.container.parentNode.removeChild(this.container);
- }
- this.container = null;
- this.menu = null;
- this.burgerButton = null;
- this.runAllButton = null;
- this.resetButton = null;
- this.menuOpen = false;
- },
+ ...buildMethods,
+ ...scoreMethods,
+ ...itemMethods,
+ ...controls,
};
diff --git a/src/view/frontend/web/js/toolbar/ui/build.js b/src/view/frontend/web/js/toolbar/ui/build.js
new file mode 100644
index 00000000..afd612ab
--- /dev/null
+++ b/src/view/frontend/web/js/toolbar/ui/build.js
@@ -0,0 +1,852 @@
+/**
+ * MageForge Toolbar – DOM construction
+ *
+ * Structure:
+ * createToolbar() – Entry point; assembles and injects the toolbar DOM
+ * _buildMenu() – Full menu popup container
+ * _buildMenuHeader() – Sticky title bar (logo + name + close button)
+ * _buildTabLayout() – Two-column tab container (nav | content)
+ * _buildTabNav() – Left-side navigation buttons + action bar at bottom
+ * _buildNavTab() – Single nav tab button
+ * _buildTabPanels() – All content panels
+ * _buildPanel() – Panel shell (role=tabpanel)
+ * _buildPanelHeader() – Panel title + compact score ring
+ * _buildScoreWidget() – Circular score ring (panel headers)
+ * _buildHomePanel() – Overview panel with half-arc gauge
+ * _buildSettingsPanel() – Settings placeholder
+ * _buildMenuFooter() – Credit line only (action bar is in nav)
+ * _buildBurgerButton() – Persistent trigger button
+ *
+ * switchTab() – Activate a tab and show its panel
+ */
+
+import {
+ createLogoSvg,
+ generateId,
+ ICON_HOME,
+ GROUP_ICONS,
+ GAUGE_ARC_LENGTH,
+ SCORE_RING_CIRCUMFERENCE,
+} from "./constants.js";
+
+export const buildMethods = {
+ // ────────────────────────────────────────────────────────────────────────
+ // Entry point
+ // ────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Build and inject the full toolbar into .
+ */
+ createToolbar() {
+ this.container = document.createElement("div");
+ this.container.className = "mageforge-toolbar";
+
+ if (this.$el?.hasAttribute("data-theme")) {
+ this.container.setAttribute(
+ "data-theme",
+ this.$el.getAttribute("data-theme"),
+ );
+ }
+ if (this.$el?.hasAttribute("data-position")) {
+ this.container.setAttribute(
+ "data-position",
+ this.$el.getAttribute("data-position"),
+ );
+ }
+ if (this.$el?.getAttribute("data-show-labels") === "0") {
+ this.container.classList.add("mageforge-toolbar--no-labels");
+ }
+
+ this.menu = this._buildMenu();
+ this.container.appendChild(this.menu);
+
+ this.burgerButton = this._buildBurgerButton();
+ this.container.appendChild(this.burgerButton);
+ // Note: Inspector button is appended externally via _appendInspectorButton()
+
+ this._outsideClickHandler = (e) => {
+ if (this.menuOpen && !this.container.contains(e.target)) this.closeMenu();
+ };
+ document.addEventListener("click", this._outsideClickHandler);
+ document.body.appendChild(this.container);
+ },
+
+ // ────────────────────────────────────────────────────────────────────────
+ // Menu sections
+ // ────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Assemble the full menu popup.
+ *
+ * @returns {HTMLDivElement}
+ */
+ _buildMenu() {
+ const menu = document.createElement("div");
+ menu.className = "mageforge-toolbar-menu";
+ menu.appendChild(this._buildMenuHeader());
+ menu.appendChild(this._buildTabLayout());
+ menu.appendChild(this._buildMenuFooter());
+ return menu;
+ },
+
+ /**
+ * Sticky title bar: logo + name + close button.
+ *
+ * @returns {HTMLDivElement}
+ */
+ _buildMenuHeader() {
+ const header = document.createElement("div");
+ header.className = "mageforge-toolbar-menu-title";
+ header.innerHTML = `
+
+
+ `;
+ header.querySelector(".mageforge-toolbar-menu-close").onclick = (e) => {
+ e.stopPropagation();
+ this.deactivateAllAudits();
+ this.closeMenu();
+ };
+ return header;
+ },
+
+ /**
+ * Two-column layout: tab nav (left) + content panels (right).
+ *
+ * @returns {HTMLDivElement}
+ */
+ _buildTabLayout() {
+ const layout = document.createElement("div");
+ layout.className = "mageforge-toolbar-tabs";
+ layout.appendChild(this._buildTabNav());
+ layout.appendChild(this._buildTabPanels());
+ return layout;
+ },
+
+ // ────────────────────────────────────────────────────────────────────────
+ // Tab navigation
+ // ────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Left-side navigation: Home at top, audit groups, Settings pinned to bottom.
+ *
+ * @returns {HTMLElement}
+ */
+ _buildTabNav() {
+ const nav = document.createElement("nav");
+ nav.className = "mageforge-toolbar-tab-nav";
+ nav.setAttribute("role", "tablist");
+ nav.setAttribute("aria-label", "Audit categories");
+
+ // Action bar (run/reset for the current tab).
+ // column-reverse means the first DOM child appears at the visual bottom.
+ this.footerActionBar = document.createElement("div");
+ this.footerActionBar.className = "mageforge-nav-action-bar";
+ nav.appendChild(this.footerActionBar);
+
+ this.getAuditGroups().forEach((group) => {
+ nav.appendChild(
+ this._buildNavTab(group.key, GROUP_ICONS[group.key] ?? "", group.label),
+ );
+ });
+
+ nav.appendChild(this._buildNavTab("home", ICON_HOME, "Dashboard", true));
+
+ return nav;
+ },
+
+ /**
+ * When audits from 2+ groups are active, relabel every group reset button
+ * to "Reset All" and wire it to reset everything; otherwise restore the
+ * per-group label and behaviour.
+ */
+ _updateResetAllButton() {
+ const isMulti = this._isMultiGroupActive();
+ const RESET_SVG =
+ ' ';
+ const label = isMulti ? "Reset All" : "Reset";
+ this.getAuditGroups().forEach((group) => {
+ const btn = this[`groupResetButton-${group.key}`];
+ if (!btn) return;
+ btn.innerHTML = `${RESET_SVG} ${label}`;
+ const ariaLabel = isMulti
+ ? "Reset all active audits"
+ : `Reset ${group.label} audits`;
+ btn.setAttribute("aria-label", ariaLabel);
+ btn.title = ariaLabel;
+ btn.classList.toggle("mageforge-group-reset-btn--all", isMulti);
+
+ const groupHasActive =
+ isMulti ||
+ this.getAudits().some(
+ (a) => a.group === group.key && this.activeAudits.has(a.key),
+ );
+ btn.classList.toggle(
+ "mageforge-group-reset-btn--disabled",
+ !groupHasActive,
+ );
+ btn.setAttribute("aria-disabled", String(!groupHasActive));
+ btn.setAttribute("tabindex", groupHasActive ? "0" : "-1");
+ });
+
+ // Home reset button: disabled when nothing is active at all
+ if (this.resetButton) {
+ const hasAny = this.activeAudits.size > 0;
+ this.resetButton.classList.toggle(
+ "mageforge-group-reset-btn--disabled",
+ !hasAny,
+ );
+ this.resetButton.setAttribute("aria-disabled", String(!hasAny));
+ this.resetButton.setAttribute("tabindex", hasAny ? "0" : "-1");
+ }
+ },
+
+ /** Returns true when audits from at least 2 different groups are active. */
+ _isMultiGroupActive() {
+ const activeGroups = new Set(
+ this.getAudits()
+ .filter((a) => a.group && this.activeAudits.has(a.key))
+ .map((a) => a.group),
+ );
+ return activeGroups.size >= 2;
+ },
+
+ /**
+ * Single tab button (icon stacked above abbreviated label).
+ *
+ * @param {string} key
+ * @param {string} icon – SVG string
+ * @param {string} label – Display label (first word used in the nav)
+ * @param {boolean} [isActive]
+ * @returns {HTMLButtonElement}
+ */
+ _buildNavTab(key, icon, label, isActive = false) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.setAttribute("role", "tab");
+ btn.setAttribute("aria-selected", String(isActive));
+ btn.setAttribute("aria-controls", `mf-tab-panel-${key}`);
+ btn.setAttribute("tabindex", isActive ? "0" : "-1");
+ btn.className = "mageforge-toolbar-tab-btn";
+ if (isActive) btn.classList.add("mageforge-tab-active");
+ btn.dataset.tab = key;
+ btn.innerHTML = `
+ ${icon}
+ ${label.split(" ")[0]}
+
+
+
+
+ `;
+ btn.onclick = (e) => {
+ e.stopPropagation();
+ this.switchTab(key);
+ };
+ btn.onkeydown = (e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ e.stopPropagation();
+ this.switchTab(key);
+ }
+ };
+ return btn;
+ },
+
+ // ────────────────────────────────────────────────────────────────────────
+ // Tab panels
+ // ────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Build all content panels wrapped in the scrollable content column.
+ *
+ * @returns {HTMLDivElement}
+ */
+ _buildTabPanels() {
+ const wrapper = document.createElement("div");
+ wrapper.className = "mageforge-toolbar-tab-content";
+
+ const grouped = {};
+ const ungrouped = [];
+ this.getAudits().forEach((audit) => {
+ audit.group
+ ? (grouped[audit.group] = grouped[audit.group] || []).push(audit)
+ : ungrouped.push(audit);
+ });
+
+ // Home panel – visible by default
+ const homePanel = this._buildPanel("home");
+ homePanel.classList.add("mageforge-tab-panel-active");
+ homePanel.appendChild(this._buildHomePanel());
+ wrapper.appendChild(homePanel);
+
+ // One panel per audit group
+ this.getAuditGroups().forEach((group) => {
+ const items = grouped[group.key];
+ if (!items?.length) return;
+
+ const panel = this._buildPanel(group.key);
+ panel.setAttribute("hidden", "");
+ panel.appendChild(this._buildPanelHeader(group.label, false, group.key));
+
+ const body = document.createElement("div");
+ body.className = "mageforge-tab-panel-body";
+
+ const groupLabel = group.label;
+
+ // Build run button – stored as ref, rendered in footer action bar
+ const groupBtn = document.createElement("button");
+ groupBtn.type = "button";
+ groupBtn.className = "mageforge-group-run-btn";
+ groupBtn.dataset.group = group.key;
+ this[`runGroupButton-${group.key}`] = groupBtn;
+ groupBtn.innerHTML = `
+
+ Run Check
+ `;
+ groupBtn.onclick = (e) => {
+ e.stopPropagation();
+ this.runGroupAuditsForScore(group.key);
+ };
+
+ // Build reset button – stored as ref, rendered in footer action bar
+ const groupResetBtn = document.createElement("button");
+ groupResetBtn.type = "button";
+ groupResetBtn.className = "mageforge-group-reset-btn";
+ groupResetBtn.setAttribute("aria-label", `Reset ${groupLabel} audits`);
+ groupResetBtn.title = `Reset ${groupLabel} audits`;
+ groupResetBtn.innerHTML =
+ ' Reset';
+ const handleGroupReset = () => {
+ const btn = this[`groupResetButton-${group.key}`];
+ if (btn?.classList.contains("mageforge-group-reset-btn--disabled"))
+ return;
+ if (this._isMultiGroupActive()) {
+ this.resetScore();
+ } else {
+ this.resetGroupAudits(group.key);
+ }
+ };
+ groupResetBtn.onclick = (e) => {
+ e.stopPropagation();
+ handleGroupReset();
+ };
+ this[`groupResetButton-${group.key}`] = groupResetBtn;
+
+ items.forEach((audit) => {
+ body.appendChild(
+ this.createMenuItem(
+ audit.key,
+ audit.icon,
+ audit.label,
+ audit.description,
+ () => this.runAudit(audit.key),
+ group.key,
+ ),
+ );
+ });
+
+ if (items.length < 6) {
+ const featureBtn = document.createElement("a");
+ featureBtn.href =
+ "https://github.com/OpenForgeProject/mageforge/issues/new?labels=enhancement&template=feature_request.yml&title=%5BAudit+Request%5D+";
+ featureBtn.target = "_blank";
+ featureBtn.rel = "noopener noreferrer";
+ featureBtn.className = "mageforge-feature-request-btn";
+ featureBtn.innerHTML = `
+
+ Suggest a Audit
+ `;
+ body.appendChild(featureBtn);
+ }
+
+ panel.appendChild(body);
+ wrapper.appendChild(panel);
+ });
+
+ // Ungrouped audits (no header)
+ if (ungrouped.length > 0) {
+ const panel = this._buildPanel("ungrouped");
+ panel.setAttribute("hidden", "");
+ ungrouped.forEach((audit) => {
+ panel.appendChild(
+ this.createMenuItem(
+ audit.key,
+ audit.icon,
+ audit.label,
+ audit.description,
+ () => this.runAudit(audit.key),
+ ),
+ );
+ });
+ wrapper.appendChild(panel);
+ }
+
+ return wrapper;
+ },
+
+ /**
+ * Create a bare panel shell.
+ *
+ * @param {string} key
+ * @returns {HTMLDivElement}
+ */
+ _buildPanel(key) {
+ const panel = document.createElement("div");
+ panel.className = "mageforge-toolbar-tab-panel";
+ panel.setAttribute("role", "tabpanel");
+ panel.setAttribute("id", `mf-tab-panel-${key}`);
+ panel.dataset.panel = key;
+ return panel;
+ },
+
+ /**
+ * Panel title bar: group name on the left, score ring on the right.
+ *
+ * @param {string} title
+ * @param {boolean} showScore
+ * @param {string} groupKey
+ * @returns {HTMLDivElement}
+ */
+ _buildPanelHeader(title, showScore, groupKey) {
+ const header = document.createElement("div");
+ header.className = "mageforge-tab-panel-header";
+ header.dataset.group = groupKey;
+
+ const titleEl = document.createElement("h2");
+ titleEl.className = "mageforge-tab-panel-title";
+ titleEl.textContent = title;
+ header.appendChild(titleEl);
+
+ if (showScore) {
+ header.appendChild(this._buildScoreWidget());
+ }
+
+ return header;
+ },
+
+ /**
+ * Compact circular score ring shown in every audit panel header.
+ *
+ * @returns {HTMLDivElement}
+ */
+ _buildScoreWidget() {
+ const gradId = generateId("sg");
+ const widget = document.createElement("div");
+ widget.className = "mageforge-score-widget";
+ widget.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -- /100
+
+
Health Score
+
+ `;
+ return widget;
+ },
+
+ /**
+ * Home panel: half-arc gauge overview + Check Health Score button.
+ *
+ * @returns {HTMLDivElement}
+ */
+ _buildHomePanel() {
+ const panel = document.createElement("div");
+ panel.className = "mageforge-home-panel";
+
+ const gradId = generateId("gauge");
+ panel.innerHTML = `
+
+ `;
+
+ // Check Health Score button + Reset side by side
+ const btnRow = document.createElement("div");
+ btnRow.className = "mageforge-home-btn-row";
+
+ this.runAllButton = document.createElement("button");
+ this.runAllButton.type = "button";
+ this.runAllButton.className = "mageforge-group-run-btn";
+ this.runAllButton.innerHTML = `
+
+ Perform Full Check
+ `;
+ this.runAllButton.onclick = (e) => {
+ e.stopPropagation();
+ this.runAllAuditsForScore();
+ };
+ btnRow.appendChild(this.runAllButton);
+
+ // Reset button next to Perform Full Check
+ this.resetButton = document.createElement("button");
+ this.resetButton.type = "button";
+ this.resetButton.className = "mageforge-group-reset-btn";
+ this.resetButton.title = "Reset score and deactivate all audits";
+ this.resetButton.setAttribute(
+ "aria-label",
+ "Reset score and deactivate all audits",
+ );
+ this.resetButton.innerHTML =
+ ' Reset';
+ this.resetButton.onclick = (e) => {
+ e.stopPropagation();
+ this.resetScore();
+ };
+ btnRow.appendChild(this.resetButton);
+ // btnRow held as ref; rendered in footer action bar via _updateFooterActions
+
+ panel.appendChild(this._buildPageContext());
+ panel.appendChild(this._buildQuickStats());
+
+ // Category score breakdown
+ const categories = document.createElement("div");
+ categories.className = "mageforge-dashboard-categories";
+ [...this.getAuditGroups()]
+ .sort((a, b) => a.label.localeCompare(b.label))
+ .forEach((group) => {
+ const card = document.createElement("div");
+ card.className = "mageforge-dashboard-category";
+ card.style.setProperty(
+ "--category-color",
+ `var(--mageforge-group-color-${group.key})`,
+ );
+ card.innerHTML = `
+ ${group.label}
+ --
+ `;
+ categories.appendChild(card);
+ });
+ panel.appendChild(categories);
+
+ // Issues list – populated by updateDashboardIssues()
+ this.dashboardIssuesEl = document.createElement("div");
+ this.dashboardIssuesEl.className = "mageforge-dashboard-issues";
+ panel.appendChild(this.dashboardIssuesEl);
+
+ panel.appendChild(
+ Object.assign(document.createElement("p"), {
+ className: "mageforge-home-hint",
+ textContent: "Select a category on the left for detailed checks.",
+ }),
+ );
+
+ return panel;
+ },
+
+ _buildPageContext() {
+ const classes = document.body.className;
+ let pageType = "Page";
+ if (classes.includes("catalog-product-view")) pageType = "Product";
+ else if (classes.includes("catalog-category-view")) pageType = "Category";
+ else if (classes.includes("checkout-index-index")) pageType = "Checkout";
+ else if (classes.includes("checkout-cart-index")) pageType = "Cart";
+ else if (classes.includes("catalogsearch-result-index"))
+ pageType = "Search";
+ else if (classes.includes("cms-index-index")) pageType = "Homepage";
+ else if (classes.includes("cms-page-view")) pageType = "CMS Page";
+ else if (classes.includes("customer-account")) pageType = "Account";
+
+ const rawTitle = document.title.split(" - ")[0].trim();
+ const title =
+ rawTitle.length > 36 ? rawTitle.slice(0, 34) + "\u2026" : rawTitle;
+ const path = location.pathname.replace(/\/+$/, "") || "/";
+ const displayPath = path.length > 34 ? "\u2026" + path.slice(-32) : path;
+
+ const el = document.createElement("div");
+ el.className = "mageforge-page-context";
+
+ const typeBadge = document.createElement("span");
+ typeBadge.className = "mageforge-page-context-type";
+ typeBadge.textContent = pageType;
+
+ const titleEl = document.createElement("span");
+ titleEl.className = "mageforge-page-context-title";
+ titleEl.textContent = title || "(no title)";
+ titleEl.title = document.title;
+
+ const urlEl = document.createElement("span");
+ urlEl.className = "mageforge-page-context-url";
+ urlEl.textContent = displayPath;
+ urlEl.title = path;
+
+ el.appendChild(typeBadge);
+ el.appendChild(titleEl);
+ if (pageType !== "Homepage" && path !== "/") el.appendChild(urlEl);
+ return el;
+ },
+
+ _buildQuickStats() {
+ const allImgs = [...document.querySelectorAll("img")].filter(
+ (img) => !this.container?.contains(img),
+ );
+ const imgNoAlt = allImgs.filter(
+ (img) => img.getAttribute("alt") === null,
+ ).length;
+ const imgNoLazy = allImgs.filter(
+ (img) => !img.getAttribute("loading"),
+ ).length;
+ const extScripts = document.querySelectorAll("script[src]").length;
+ const inlineScripts = document.querySelectorAll("script:not([src])").length;
+ const stylesheets = document.querySelectorAll(
+ 'link[rel="stylesheet"]',
+ ).length;
+
+ const el = document.createElement("div");
+ el.className = "mageforge-quick-stats";
+
+ const heading = document.createElement("p");
+ heading.className = "mageforge-section-heading";
+ heading.textContent = "Page Overview";
+ el.appendChild(heading);
+
+ const grid = document.createElement("div");
+ grid.className = "mageforge-quick-stats-grid";
+
+ const items = [
+ {
+ value: allImgs.length,
+ label: "Images",
+ sub:
+ imgNoAlt > 0
+ ? `${imgNoAlt} no alt`
+ : imgNoLazy > 0
+ ? `${imgNoLazy} no lazy`
+ : "all good",
+ warn: imgNoAlt > 0,
+ },
+ {
+ value: extScripts,
+ label: "JS Files",
+ sub: `${inlineScripts} inline`,
+ warn: false,
+ },
+ {
+ value: stylesheets,
+ label: "CSS Files",
+ sub: null,
+ warn: false,
+ },
+ ];
+
+ items.forEach(({ value, label, sub, warn }) => {
+ const item = document.createElement("div");
+ item.className = "mageforge-quick-stat";
+
+ const valueEl = document.createElement("span");
+ valueEl.className = `mageforge-quick-stat-value${
+ warn ? " mageforge-quick-stat-value--warn" : ""
+ }`;
+ valueEl.textContent = String(value);
+
+ const labelEl = document.createElement("span");
+ labelEl.className = "mageforge-quick-stat-label";
+ labelEl.textContent = label;
+
+ item.appendChild(valueEl);
+ item.appendChild(labelEl);
+
+ if (sub !== null) {
+ const subEl = document.createElement("span");
+ subEl.className = `mageforge-quick-stat-sub${
+ warn ? " mageforge-quick-stat-sub--warn" : ""
+ }`;
+ subEl.textContent = sub;
+ item.appendChild(subEl);
+ }
+
+ grid.appendChild(item);
+ });
+
+ el.appendChild(grid);
+ return el;
+ },
+
+ // ────────────────────────────────────────────────────────────────────────
+ // Footer
+ // ────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Footer row: export controls + credit line.
+ *
+ * @returns {HTMLDivElement}
+ */
+ _buildMenuFooter() {
+ const footer = document.createElement("div");
+ footer.className = "mageforge-toolbar-menu-footer";
+
+ // Export format row ──────────────────────────────────────────────────
+ const exportRow = document.createElement("div");
+ exportRow.className = "mageforge-footer-theme-row";
+ this._exportBtnRow = exportRow;
+ const exportGroup = document.createElement("div");
+ exportGroup.className = "mageforge-theme-toggle";
+ const exportLabel = document.createElement("span");
+ exportLabel.className = "mageforge-footer-theme-label";
+ exportLabel.textContent = "Export";
+ exportGroup.appendChild(exportLabel);
+ [
+ ["json", "JSON"],
+ ["md", "MD"],
+ ["txt", "TXT"],
+ ].forEach(([fmt, label]) => {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "mageforge-theme-btn mageforge-export-btn--disabled";
+ btn.dataset.exportFormat = fmt;
+ btn.textContent = label;
+ btn.disabled = true;
+ btn.setAttribute("aria-label", `Export findings as ${label}`);
+ btn.title = `Export findings as ${label}`;
+ btn.onclick = (e) => {
+ e.stopPropagation();
+ this.exportFindings(fmt);
+ };
+ exportGroup.appendChild(btn);
+ });
+ exportRow.appendChild(exportGroup);
+ footer.appendChild(exportRow);
+
+ // Credit line (left side of export row) ─────────────────────────────
+ const credit = document.createElement("div");
+ credit.className = "mageforge-toolbar-menu-credit";
+ credit.innerHTML =
+ 'Built with by ';
+ exportRow.insertBefore(credit, exportRow.firstChild);
+
+ // Populate nav action bar for the initially active tab (home).
+ // footerActionBar was already created in _buildTabNav().
+ this._updateFooterActions("home");
+ this._updateResetAllButton();
+
+ return footer;
+ },
+
+ /**
+ * Populate the footer action bar with the run/reset buttons for the given tab.
+ *
+ * @param {string} key – Tab key ("home" or a group key like "wcag")
+ */
+ _updateFooterActions(key) {
+ if (!this.footerActionBar) return;
+ this.footerActionBar.innerHTML = "";
+
+ const row = document.createElement("div");
+ row.className = "mageforge-footer-btn-row";
+
+ if (key === "home") {
+ if (!this.runAllButton) return;
+ row.appendChild(this.runAllButton);
+ row.appendChild(this.resetButton);
+ } else {
+ const runBtn = this[`runGroupButton-${key}`];
+ const resetBtn = this[`groupResetButton-${key}`];
+ if (!runBtn) return;
+ row.appendChild(runBtn);
+ if (resetBtn) row.appendChild(resetBtn);
+ }
+
+ this.footerActionBar.appendChild(row);
+ },
+
+ // ────────────────────────────────────────────────────────────────────────
+ // Burger / trigger button
+ // ────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Build the persistent trigger button (logo + label).
+ *
+ * @returns {HTMLDivElement}
+ */
+ _buildBurgerButton() {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "mageforge-toolbar-burger";
+ btn.title = "Audit tools";
+ btn.setAttribute("aria-label", "Open audit tools menu");
+ btn.setAttribute("aria-expanded", "false");
+ btn.innerHTML = `
+ ${createLogoSvg("white")}
+ MageForge
+ `;
+ btn.onclick = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this.toggleMenu();
+ };
+ return btn;
+ },
+
+ // ────────────────────────────────────────────────────────────────────────
+ // Tab switching
+ // ────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Activate a tab and show its panel; hide all others.
+ *
+ * @param {string} key
+ */
+ switchTab(key) {
+ this.activeTab = key;
+ if (!this.menu) return;
+
+ this.menu.querySelectorAll(".mageforge-toolbar-tab-btn").forEach((btn) => {
+ const active = btn.dataset.tab === key;
+ btn.classList.toggle("mageforge-tab-active", active);
+ btn.setAttribute("aria-selected", String(active));
+ btn.setAttribute("tabindex", active ? "0" : "-1");
+ });
+
+ this.menu
+ .querySelectorAll(".mageforge-toolbar-tab-panel")
+ .forEach((panel) => {
+ const active = panel.dataset.panel === key;
+ panel.classList.toggle("mageforge-tab-panel-active", active);
+ active
+ ? panel.removeAttribute("hidden")
+ : panel.setAttribute("hidden", "");
+ });
+
+ this._updateFooterActions(key);
+ },
+};
diff --git a/src/view/frontend/web/js/toolbar/ui/constants.js b/src/view/frontend/web/js/toolbar/ui/constants.js
new file mode 100644
index 00000000..a49fbfe9
--- /dev/null
+++ b/src/view/frontend/web/js/toolbar/ui/constants.js
@@ -0,0 +1,38 @@
+// ── Constants ──────────────────────────────────────────────────────────────
+
+export const LOGO_SVG_PATH =
+ "M176 0L0 101.614V297L176 398.614L352 297V101.614L176 0ZM39 275.5V124L76.2391 101.614L101.5 162L126.5 73.4393L164.5 51.5V346.939L126.5 325V188L108.5 239H95L76.2391 188V297L39 275.5ZM187.5 346.939V51.5L313 124V170H275.5V146.368L225.5 117.5V188H280V226.5H225.5V325L187.5 346.939Z";
+
+export const ICON_HOME =
+ ' ';
+
+export const GROUP_ICONS = {
+ wcag: ' ',
+ "html-quality":
+ ' ',
+ performance:
+ ' ',
+ seo: ' ',
+};
+
+export const GAUGE_ARC_LENGTH = 157.08; // π × r(50) — half-arc stroke length
+export const SCORE_RING_CIRCUMFERENCE = 113.1; // 2π × r(18) — ring stroke length
+
+export function createLogoSvg(fill) {
+ return ` `;
+}
+
+/**
+ * Generate a short random ID for use in SVG gradient/clip-path IDs.
+ * Falls back to Math.random() when crypto.randomUUID() is unavailable
+ * (e.g. non-secure contexts).
+ *
+ * @param {string} prefix
+ * @returns {string}
+ */
+export function generateId(prefix) {
+ const rand =
+ globalThis.crypto?.randomUUID?.()?.slice(0, 8) ??
+ Math.random().toString(36).slice(2, 10);
+ return `mf-${prefix}-${rand}`;
+}
diff --git a/src/view/frontend/web/js/toolbar/ui/controls.js b/src/view/frontend/web/js/toolbar/ui/controls.js
new file mode 100644
index 00000000..33838406
--- /dev/null
+++ b/src/view/frontend/web/js/toolbar/ui/controls.js
@@ -0,0 +1,114 @@
+/**
+ * MageForge Toolbar – Menu lifecycle and focus trap
+ *
+ * toggleMenu / openMenu / closeMenu / destroyToolbar
+ * _getFocusableEls / _trapFocus / _releaseFocusTrap / setTheme
+ */
+
+export const controls = {
+ toggleMenu() {
+ this.menuOpen ? this.closeMenu() : this.openMenu();
+ },
+
+ openMenu() {
+ this.menuOpen = true;
+ this.menu.classList.add("mageforge-menu-open");
+ this.burgerButton.classList.add("mageforge-active");
+ this.burgerButton.setAttribute("aria-expanded", "true");
+ this._trapFocus();
+ },
+
+ closeMenu() {
+ this.menuOpen = false;
+ this.menu.classList.remove("mageforge-menu-open");
+ this.burgerButton.classList.remove("mageforge-active");
+ this.burgerButton.setAttribute("aria-expanded", "false");
+ this._releaseFocusTrap();
+ this.burgerButton?.focus();
+ },
+
+ /**
+ * Returns all currently focusable elements within the open menu.
+ *
+ * @returns {HTMLElement[]}
+ */
+ _getFocusableEls() {
+ return Array.from(
+ this.menu.querySelectorAll(
+ 'button:not([disabled]):not([tabindex="-1"]), [href]:not([tabindex="-1"]), input:not([disabled]):not([tabindex="-1"]), [tabindex]:not([tabindex="-1"])',
+ ),
+ ).filter((el) => !el.closest("[hidden]"));
+ },
+
+ /**
+ * Trap keyboard focus inside the menu and close on Escape.
+ */
+ _trapFocus() {
+ this._focusTrapHandler = (e) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ this.closeMenu();
+ return;
+ }
+ if (e.key !== "Tab") return;
+ const focusable = this._getFocusableEls();
+ if (!focusable.length) return;
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (e.shiftKey) {
+ if (document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ }
+ } else {
+ if (document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ }
+ };
+ this.menu.addEventListener("keydown", this._focusTrapHandler);
+ const focusable = this._getFocusableEls();
+ if (focusable.length) focusable[0].focus();
+ },
+
+ /**
+ * Remove the focus trap listener and clean up.
+ */
+ _releaseFocusTrap() {
+ if (this._focusTrapHandler) {
+ this.menu?.removeEventListener("keydown", this._focusTrapHandler);
+ this._focusTrapHandler = null;
+ }
+ },
+
+ destroyToolbar() {
+ if (this._outsideClickHandler) {
+ document.removeEventListener("click", this._outsideClickHandler);
+ this._outsideClickHandler = null;
+ }
+ this._releaseFocusTrap();
+ if (this.container?.parentNode)
+ this.container.parentNode.removeChild(this.container);
+ this.container = null;
+ this.menu = null;
+ this.burgerButton = null;
+ this.runAllButton = null;
+ this.resetButton = null;
+ this._exportBtnRow = null;
+ this.menuOpen = false;
+ },
+
+ /**
+ * Apply a colour theme to the toolbar container.
+ *
+ * @param {'dark'|'auto'|'light'} theme
+ */
+ setTheme(theme) {
+ this.currentTheme = theme;
+ try {
+ localStorage.setItem("mageforge-theme", theme);
+ } catch (_) {}
+ if (this.container) this.container.setAttribute("data-theme", theme);
+ },
+};
diff --git a/src/view/frontend/web/js/toolbar/ui/items.js b/src/view/frontend/web/js/toolbar/ui/items.js
new file mode 100644
index 00000000..26f75577
--- /dev/null
+++ b/src/view/frontend/web/js/toolbar/ui/items.js
@@ -0,0 +1,408 @@
+/**
+ * MageForge Toolbar – Audit items, findings, and dashboard summaries
+ *
+ * createMenuItem / setAuditFindings / setAuditActive
+ * updateToggleAllButton / updateHomeSummary / updateDashboardIssues / updateExportButton
+ */
+
+import { getReadableSelector } from "../audits/highlight.js";
+import { GROUP_ICONS } from "./constants.js";
+
+export const itemMethods = {
+ /**
+ * Create a single audit row: icon | label + status badge | toggle | findings list.
+ *
+ * @param {string} key
+ * @param {string} icon
+ * @param {string} label
+ * @param {string} description
+ * @param {Function} callback
+ * @param {?string} groupKey
+ * @returns {HTMLDivElement}
+ */
+ createMenuItem(key, icon, label, description, callback, groupKey = null) {
+ const item = document.createElement("div");
+ item.setAttribute("role", "button");
+ item.setAttribute("tabindex", "0");
+ item.className = "mageforge-toolbar-menu-item";
+ item.dataset.auditKey = key;
+ if (groupKey) item.dataset.groupKey = groupKey;
+ item.setAttribute("aria-pressed", "false");
+
+ item.innerHTML = `
+
+
+
+ `;
+ item.querySelector(".mageforge-toolbar-menu-desc").textContent =
+ description;
+
+ // Findings list – populated by setAuditFindings(); events never bubble to the toggle
+ const findings = document.createElement("div");
+ findings.className = "mageforge-audit-findings";
+ findings.addEventListener("click", (e) => e.stopPropagation());
+ findings.addEventListener("keydown", (e) => e.stopPropagation());
+ findings.addEventListener("keyup", (e) => e.stopPropagation());
+ item.appendChild(findings);
+
+ item.onclick = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ callback();
+ };
+ item.onkeydown = (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ e.stopPropagation();
+ callback();
+ }
+ if (e.key === " ") {
+ e.preventDefault();
+ }
+ };
+ item.onkeyup = (e) => {
+ if (e.key === " ") {
+ e.stopPropagation();
+ callback();
+ }
+ };
+
+ return item;
+ },
+
+ /**
+ * Populate (or clear) the findings list beneath an audit item.
+ * Each row scrolls to and briefly highlights the element on click.
+ *
+ * @param {string} key
+ * @param {Array<{el: Element, selector?: string, severity?: 'error'|'warning', action?: string}>} findings
+ */
+ setAuditFindings(key, findings) {
+ if (!this.menu) return;
+ const item = this.menu.querySelector(`[data-audit-key="${key}"]`);
+ if (!item) return;
+ const container = item.querySelector(".mageforge-audit-findings");
+ if (!container) return;
+
+ // Store finding counts on the item for badge aggregation
+ let errorCount = 0;
+ let warningCount = 0;
+ findings?.forEach((f) => {
+ if (f.severity === "warning") warningCount++;
+ else errorCount++;
+ });
+ item.dataset.findingErrors = String(errorCount);
+ item.dataset.findingWarnings = String(warningCount);
+
+ container.innerHTML = "";
+ container.classList.remove("mageforge-findings-open");
+
+ if (!findings?.length) {
+ container.classList.remove("mageforge-has-findings");
+ return;
+ }
+
+ container.classList.add("mageforge-has-findings");
+
+ const toggleBtn = document.createElement("button");
+ toggleBtn.type = "button";
+ toggleBtn.className = "mageforge-findings-toggle";
+ toggleBtn.setAttribute("aria-expanded", "false");
+ toggleBtn.innerHTML = `
+
+ Show affected elements (${findings.length})
+ `;
+ toggleBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const isOpen = container.classList.toggle("mageforge-findings-open");
+ toggleBtn.setAttribute("aria-expanded", String(isOpen));
+ const textEl = toggleBtn.querySelector(".mageforge-findings-toggle-text");
+ if (textEl)
+ textEl.textContent = isOpen
+ ? `Hide affected elements (${findings.length})`
+ : `Show affected elements (${findings.length})`;
+ });
+ container.appendChild(toggleBtn);
+
+ const list = document.createElement("div");
+ list.className = "mageforge-findings-list";
+
+ findings.forEach(
+ (
+ { el, selector, severity = "error", action = "Show Element" },
+ index,
+ ) => {
+ const selectorStr = selector ?? getReadableSelector(el);
+ const isLast = index === findings.length - 1;
+
+ const row = document.createElement("div");
+ row.className = `mageforge-audit-finding mageforge-audit-finding--${severity}`;
+
+ const treeEl = document.createElement("span");
+ treeEl.className = "mageforge-finding-tree";
+ treeEl.setAttribute("aria-hidden", "true");
+ treeEl.textContent = `${isLast ? "\u2514" : "\u251C"}\u2500`;
+
+ const selectorEl = document.createElement("span");
+ selectorEl.className = "mageforge-finding-selector";
+ selectorEl.setAttribute("title", selectorStr);
+ selectorEl.textContent = selectorStr;
+
+ const actionEl = document.createElement("span");
+ actionEl.className = "mageforge-finding-action";
+ actionEl.textContent = action;
+
+ row.appendChild(treeEl);
+ row.appendChild(selectorEl);
+ row.appendChild(actionEl);
+
+ row.addEventListener("click", (e) => {
+ e.stopPropagation();
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
+ el.classList.add("mageforge-finding-flash");
+ setTimeout(
+ () => el.classList.remove("mageforge-finding-flash"),
+ 1200,
+ );
+ });
+ list.appendChild(row);
+ },
+ );
+
+ container.appendChild(list);
+ },
+
+ /**
+ * Toggle the active visual state of an audit item.
+ * Clears findings and status badge on deactivation.
+ *
+ * @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);
+ item.setAttribute("aria-pressed", String(active));
+
+ if (!active) {
+ item.classList.remove(
+ "mageforge-active--error",
+ "mageforge-active--warning",
+ );
+ const status = item.querySelector(".mageforge-toolbar-menu-status");
+ if (status) {
+ status.textContent = "";
+ status.className = "mageforge-toolbar-menu-status";
+ }
+ this.setAuditFindings(key, []);
+ }
+
+ this.updateToggleAllButton();
+ this.updateHomeSummary();
+ this.updateExportButton();
+ this._updateResetAllButton();
+ },
+
+ /** No-op – retained for compatibility. */
+ updateToggleAllButton() {},
+
+ /**
+ * Rebuild the compact issues list on the Dashboard panel.
+ */
+ updateDashboardIssues() {
+ if (!this.dashboardIssuesEl) return;
+
+ /** @type {Array<{label: string, count: number, severity: string, groupKey: string|null}>} */
+ const rows = [];
+ this.menu?.querySelectorAll("[data-audit-key]").forEach((item) => {
+ let errors = parseInt(item.dataset.findingErrors || "0", 10);
+ let warnings = parseInt(item.dataset.findingWarnings || "0", 10);
+
+ // Badge-only audits: fall back to the status badge (same logic as updateHomeSummary)
+ if (!errors && !warnings) {
+ const status = item.querySelector(".mageforge-toolbar-menu-status");
+ const count = parseInt(status?.textContent || "0", 10) || 0;
+ if (count > 0) {
+ if (
+ status.classList.contains("mageforge-toolbar-menu-status--error")
+ ) {
+ errors = count;
+ } else if (
+ status.classList.contains("mageforge-toolbar-menu-status--warning")
+ ) {
+ warnings = count;
+ }
+ }
+ }
+
+ if (!errors && !warnings) return;
+ const label =
+ item.querySelector(".mageforge-toolbar-menu-label")?.textContent ?? "";
+ const groupKey = item.dataset.groupKey ?? null;
+ if (errors)
+ rows.push({ label, count: errors, severity: "error", groupKey });
+ if (warnings)
+ rows.push({ label, count: warnings, severity: "warning", groupKey });
+ });
+
+ this.dashboardIssuesEl.innerHTML = "";
+
+ if (!rows.length) return;
+
+ // Errors first, then warnings, each group sorted by count desc
+ rows.sort((a, b) => {
+ if (a.severity !== b.severity) return a.severity === "error" ? -1 : 1;
+ return b.count - a.count;
+ });
+
+ const heading = document.createElement("p");
+ heading.className = "mageforge-dashboard-issues-heading";
+ heading.textContent = "Issues found";
+ this.dashboardIssuesEl.appendChild(heading);
+
+ rows.forEach(({ label, count, severity, groupKey }) => {
+ const row = document.createElement("div");
+ row.className = `mageforge-dashboard-issue mageforge-dashboard-issue--${severity}`;
+
+ const countEl = document.createElement("span");
+ countEl.className = "mageforge-dashboard-issue-count";
+ countEl.textContent = String(count);
+
+ const labelEl = document.createElement("span");
+ labelEl.className = "mageforge-dashboard-issue-label";
+ labelEl.textContent = label;
+
+ row.appendChild(countEl);
+ row.appendChild(labelEl);
+
+ if (groupKey) {
+ const groupLabel =
+ this.getAuditGroups().find((g) => g.key === groupKey)?.label ??
+ groupKey;
+ const badge = document.createElement("button");
+ badge.type = "button";
+ badge.className = "mageforge-dashboard-issue-group";
+ badge.style.setProperty(
+ "--issue-group-color",
+ `var(--mageforge-group-color-${groupKey})`,
+ );
+ badge.innerHTML = GROUP_ICONS[groupKey] ?? "";
+ badge.title = `Jump to ${groupLabel}`;
+ badge.setAttribute("aria-label", `Jump to ${groupLabel}`);
+ badge.onclick = (e) => {
+ e.stopPropagation();
+ this.switchTab(groupKey);
+ };
+ row.appendChild(badge);
+ }
+
+ this.dashboardIssuesEl.appendChild(row);
+ });
+ },
+
+ /**
+ * Update error/warning badges on the left navigation tabs.
+ * Counts actual findings (affected elements), not just audits.
+ */
+ updateHomeSummary() {
+ if (!this.menu) return;
+
+ // Count actual findings (elements) per group
+ const groupCounts = {};
+ this.menu.querySelectorAll("[data-audit-key]").forEach((item) => {
+ let errors = parseInt(item.dataset.findingErrors || "0", 10);
+ let warnings = parseInt(item.dataset.findingWarnings || "0", 10);
+
+ // Badge-only audits (page-level checks) never call setAuditFindings, so
+ // findingErrors/findingWarnings stay at 0. Fall back to the status badge.
+ if (!errors && !warnings) {
+ const status = item.querySelector(".mageforge-toolbar-menu-status");
+ const count = parseInt(status?.textContent || "0", 10) || 0;
+ if (count > 0) {
+ if (
+ status.classList.contains("mageforge-toolbar-menu-status--error")
+ ) {
+ errors = count;
+ } else if (
+ status.classList.contains("mageforge-toolbar-menu-status--warning")
+ ) {
+ warnings = count;
+ }
+ }
+ }
+
+ if (!errors && !warnings) return;
+
+ const groupKey = item.dataset.groupKey;
+ if (!groupKey) return;
+
+ if (!groupCounts[groupKey]) {
+ groupCounts[groupKey] = { errors: 0, warnings: 0 };
+ }
+
+ groupCounts[groupKey].errors += errors;
+ groupCounts[groupKey].warnings += warnings;
+ });
+
+ // Reset ALL badges first, then populate only those with findings
+ this.menu.querySelectorAll("[data-tab-badges-for]").forEach((container) => {
+ const errorBadge = container.querySelector('[data-type="errors"]');
+ const warningBadge = container.querySelector('[data-type="warnings"]');
+ if (errorBadge) {
+ errorBadge.textContent = "";
+ errorBadge.style.display = "none";
+ }
+ if (warningBadge) {
+ warningBadge.textContent = "";
+ warningBadge.style.display = "none";
+ }
+ });
+
+ this.updateDashboardIssues();
+
+ // Update badges for each group tab with findings
+ Object.entries(groupCounts).forEach(([groupKey, counts]) => {
+ const container = this.menu.querySelector(
+ `[data-tab-badges-for="${groupKey}"]`,
+ );
+ if (!container) return;
+
+ const errorBadge = container.querySelector('[data-type="errors"]');
+ const warningBadge = container.querySelector('[data-type="warnings"]');
+
+ if (counts.errors > 0 && errorBadge) {
+ errorBadge.textContent = counts.errors;
+ errorBadge.style.display = "inline-flex";
+ }
+
+ if (counts.warnings > 0 && warningBadge) {
+ warningBadge.textContent = counts.warnings;
+ warningBadge.style.display = "inline-flex";
+ }
+ });
+ },
+
+ /**
+ * Enable or disable the Export JSON button based on whether any audits
+ * are currently active.
+ */
+ updateExportButton() {
+ if (!this._exportBtnRow) return;
+ const hasActive = this.activeAudits.size > 0;
+ this._exportBtnRow
+ .querySelectorAll("[data-export-format]")
+ .forEach((btn) => {
+ btn.disabled = !hasActive;
+ btn.classList.toggle("mageforge-export-btn--disabled", !hasActive);
+ });
+ },
+};
diff --git a/src/view/frontend/web/js/toolbar/ui/score.js b/src/view/frontend/web/js/toolbar/ui/score.js
new file mode 100644
index 00000000..b34ded85
--- /dev/null
+++ b/src/view/frontend/web/js/toolbar/ui/score.js
@@ -0,0 +1,127 @@
+/**
+ * MageForge Toolbar – Score animations
+ *
+ * updateHealthScore / updateGroupScore / resetScore
+ */
+
+import { GAUGE_ARC_LENGTH, SCORE_RING_CIRCUMFERENCE } from "./constants.js";
+
+export const scoreMethods = {
+ /**
+ * Animate all score gauges and rings to the given score (0-100).
+ *
+ * @param {number} score
+ */
+ updateHealthScore(score) {
+ if (!this.menu) return;
+
+ // Half-arc gauge in the Home panel
+ const progress = this.menu.querySelector(
+ ".mageforge-health-gauge-progress",
+ );
+ const needle = this.menu.querySelector(".mageforge-health-gauge-needle");
+ if (progress)
+ progress.setAttribute(
+ "stroke-dasharray",
+ `${((score / 100) * GAUGE_ARC_LENGTH).toFixed(2)} ${GAUGE_ARC_LENGTH}`,
+ );
+ if (needle) {
+ const rad = (1 - score / 100) * Math.PI;
+ needle.setAttribute("x2", (60 + 45 * Math.cos(rad)).toFixed(1));
+ needle.setAttribute("y2", (65 - 45 * Math.sin(rad)).toFixed(1));
+ needle.setAttribute("opacity", "1");
+ }
+ this.menu
+ .querySelectorAll(".mageforge-toolbar-health-score-number")
+ .forEach((el) => {
+ el.textContent = score;
+ });
+
+ // Circular rings in audit panel headers
+ this.menu.querySelectorAll(".mageforge-score-ring").forEach((ring) => {
+ ring.setAttribute(
+ "stroke-dasharray",
+ `${((score / 100) * SCORE_RING_CIRCUMFERENCE).toFixed(2)} ${SCORE_RING_CIRCUMFERENCE}`,
+ );
+ });
+ this.menu.querySelectorAll(".mageforge-score-number").forEach((el) => {
+ el.textContent = score;
+ });
+ },
+
+ /**
+ * Update the score ring in a specific group panel header.
+ *
+ * @param {string} groupKey
+ * @param {number} score
+ */
+ updateGroupScore(groupKey, score) {
+ if (!this.menu) return;
+
+ const panel = this.menu.querySelector(`[data-panel="${groupKey}"]`);
+ if (!panel) return;
+
+ const ring = panel.querySelector(".mageforge-score-ring");
+ if (ring) {
+ ring.setAttribute(
+ "stroke-dasharray",
+ `${((score / 100) * SCORE_RING_CIRCUMFERENCE).toFixed(2)} ${SCORE_RING_CIRCUMFERENCE}`,
+ );
+ }
+ const number = panel.querySelector(".mageforge-score-number");
+ if (number) {
+ number.textContent = score;
+ }
+
+ // Also update the dashboard category badge
+ const dashboardScore = this.menu.querySelector(
+ `[data-dashboard-group-score="${groupKey}"]`,
+ );
+ if (dashboardScore) {
+ dashboardScore.textContent = score;
+ dashboardScore.classList.toggle(
+ "mageforge-dashboard-category-score--active",
+ score > 0,
+ );
+ }
+ },
+
+ /**
+ * Reset all score displays and deactivate all audits.
+ */
+ resetScore() {
+ this.deactivateAllAudits();
+ if (!this.menu) return;
+
+ const progress = this.menu.querySelector(
+ ".mageforge-health-gauge-progress",
+ );
+ const needle = this.menu.querySelector(".mageforge-health-gauge-needle");
+ if (progress)
+ progress.setAttribute("stroke-dasharray", `0 ${GAUGE_ARC_LENGTH}`);
+ if (needle) needle.setAttribute("opacity", "0");
+ this.menu
+ .querySelectorAll(".mageforge-toolbar-health-score-number")
+ .forEach((el) => {
+ el.textContent = "--";
+ });
+ this.menu.querySelectorAll(".mageforge-score-ring").forEach((ring) => {
+ ring.setAttribute("stroke-dasharray", `0 ${SCORE_RING_CIRCUMFERENCE}`);
+ });
+ this.menu.querySelectorAll(".mageforge-score-number").forEach((el) => {
+ el.textContent = "--";
+ });
+
+ // Reset dashboard category badges
+ this.menu.querySelectorAll("[data-dashboard-group-score]").forEach((el) => {
+ el.textContent = "--";
+ el.classList.remove("mageforge-dashboard-category-score--active");
+ });
+
+ // Clear dashboard issues list
+ if (this.dashboardIssuesEl) this.dashboardIssuesEl.innerHTML = "";
+
+ // Reset all navigation badges
+ this.updateHomeSummary();
+ },
+};