import RULES from '../../data/hook-rules.json'; (() => { "use strict"; const STORAGE_KEY = "dcx-hooks-preview-v1"; const MOBILE_FAMILIES = window.matchMedia("(max-width: 560px)"); const REDUCED_MOTION = window.matchMedia("(prefers-reduced-motion: reduce)"); const FAMILY_META = { fingerprints: { label: "Fingerprints", description: "Recurring signatures found across generated interfaces.", }, slop: { label: "UI tells", description: "Common generated-UI habits that make a design feel interchangeable.", }, quality: { label: "Quality floor", description: "Measurable defects in legibility, hierarchy, overflow, and system consistency.", }, }; const DISCIPLINE_ORDER = [ "Visual Details", "Typography", "Color & Contrast", "Layout & Space", "Motion", "Imagery", "Copy", "Quality", ]; const initialState = () => ({ enabled: true, activeFamily: "fingerprints", disabled: ["em-dash-overuse"], custom: [], }); const loadState = () => { const fallback = initialState(); try { const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || "null"); if (!parsed || typeof parsed !== "object") return fallback; return { enabled: parsed.enabled !== false, activeFamily: FAMILY_META[parsed.activeFamily] ? parsed.activeFamily : fallback.activeFamily, disabled: Array.isArray(parsed.disabled) ? parsed.disabled.filter((id) => typeof id === "string") : fallback.disabled, custom: Array.isArray(parsed.custom) ? parsed.custom.filter((rule) => rule && typeof rule.id === "string" && typeof rule.name === "string") : [], }; } catch { return fallback; } }; const state = loadState(); const disabledRules = new Set(state.disabled); const disciplineAnimations = new WeakMap(); const customFormAnimations = new WeakMap(); let syncFrame = 0; const persist = () => { state.disabled = [...disabledRules]; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch { // The file:// preview may deny storage; the in-memory controls still work. } }; const escapeHtml = (value) => String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); const slugify = (value) => String(value) .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") || "custom-rule"; const compactDescription = (value) => { const text = String(value).trim(); const firstSentence = text.match(/^.*?[.!?](?:\s|$)/)?.[0]?.trim(); return firstSentence || text; }; const templateMarkup = () => `

Hooks

Checks that catch design regressions while you work.

How it runs

Hooks watch interface changes and surface problems before they spread.

Enable hooks

Preview only — project settings are unchanged.

On
On each edit
Checks the changed UI.
At session end
Runs a full pass on touched UI files.
Built-in rules

Custom rules

No custom rules.

Preview only — saved in this browser; custom rules do not run.

`; const renameInterfaceToHooks = () => { const tile = document.querySelector('.dcx-tile[data-category="interface"]'); if (tile) { tile.dataset.category = "hooks"; tile.dataset.name = "Hooks"; tile.setAttribute("aria-label", "Open Hooks"); const title = tile.querySelector(".dcx-tile-title"); if (title) title.textContent = "Hooks"; } const shellTemplate = document.querySelector("#dcx-shell-template"); const navItem = shellTemplate?.content.querySelector('li[data-category="interface"]'); const navLink = navItem?.querySelector(".dcx-nav-link"); if (navItem && navLink) { navItem.dataset.category = "hooks"; navLink.href = "#hooks"; navLink.dataset.dcxNav = "hooks"; navLink.textContent = "Hooks"; } }; const installTemplate = () => { if (document.querySelector("#dcx-detail-hooks")) return; const template = document.createElement("template"); template.id = "dcx-detail-hooks"; template.innerHTML = templateMarkup(); document.querySelector("#dcx-detail-interface")?.after(template); }; const familyRules = (family) => RULES.filter((rule) => rule.group === family); const isEnabled = (id) => !disabledRules.has(id); const revealSelectedFamily = (target) => { if (!MOBILE_FAMILIES.matches) return; const selected = target.querySelector('[data-hooks-family][aria-selected="true"]'); if (!selected) return; const viewport = target.getBoundingClientRect(); const item = selected.getBoundingClientRect(); let delta = 0; if (item.left < viewport.left + 3) delta = item.left - viewport.left - 3; else if (item.right > viewport.right - 3) delta = item.right - viewport.right + 3; if (Math.abs(delta) < 1) return; target.scrollTo({ left: Math.max(0, target.scrollLeft + delta), behavior: REDUCED_MOTION.matches ? "auto" : "smooth", }); }; const setDisciplineOpen = (details, expanded) => { const panel = details.querySelector(":scope > .dcx-hooks-disclosure"); const inner = panel?.querySelector(":scope > .dcx-hooks-disclosure-inner"); if (!panel || !inner) { details.open = expanded; return; } const previous = disciplineAnimations.get(details); const currentHeight = panel.getBoundingClientRect().height; const currentOpacity = Number.parseFloat(getComputedStyle(panel).opacity) || 0; previous?.cancel(); if (REDUCED_MOTION.matches) { disciplineAnimations.delete(details); details.classList.remove("is-closing"); details.open = expanded; panel.style.removeProperty("height"); panel.style.removeProperty("opacity"); return; } details.open = true; details.classList.toggle("is-closing", !expanded); const fromHeight = previous ? currentHeight : expanded ? 0 : currentHeight; const fromOpacity = previous ? currentOpacity : expanded ? 0 : 1; const toHeight = expanded ? inner.scrollHeight : 0; const toOpacity = expanded ? 1 : 0; const animation = panel.animate([ { height: `${fromHeight}px`, opacity: fromOpacity }, { height: `${toHeight}px`, opacity: toOpacity }, ], { duration: 360, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "both", }); disciplineAnimations.set(details, animation); animation.finished.then(() => { if (disciplineAnimations.get(details) !== animation) return; disciplineAnimations.delete(details); details.classList.remove("is-closing"); details.open = expanded; animation.cancel(); panel.style.removeProperty("height"); panel.style.removeProperty("opacity"); }).catch(() => {}); }; const setCustomFormOpen = (form, expanded) => { const previous = customFormAnimations.get(form); const currentHeight = form.hidden ? 0 : form.getBoundingClientRect().height; const currentOpacity = form.hidden ? 0 : Number.parseFloat(getComputedStyle(form).opacity) || 1; previous?.cancel(); if (REDUCED_MOTION.matches) { customFormAnimations.delete(form); form.hidden = !expanded; return; } if (expanded) form.hidden = false; const toHeight = expanded ? form.scrollHeight : 0; form.style.overflow = "clip"; const animation = form.animate([ { height: `${currentHeight}px`, opacity: currentOpacity }, { height: `${toHeight}px`, opacity: expanded ? 1 : 0 }, ], { duration: 320, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "both", }); customFormAnimations.set(form, animation); animation.finished.then(() => { if (customFormAnimations.get(form) !== animation) return; customFormAnimations.delete(form); form.hidden = !expanded; animation.cancel(); form.style.removeProperty("height"); form.style.removeProperty("opacity"); form.style.removeProperty("overflow"); }).catch(() => {}); }; const toggleDiscipline = (article, summary) => { const disclosure = summary.parentElement; const expanded = !disclosure.open || disclosure.classList.contains("is-closing"); const query = article.querySelector("[data-hooks-search]")?.value.trim(); if (expanded && !query) { disclosure.parentElement?.querySelectorAll(":scope > .dcx-hooks-discipline[open]").forEach((sibling) => { if (sibling !== disclosure) setDisciplineOpen(sibling, false); }); } setDisciplineOpen(disclosure, expanded); }; const renderFamilies = (article) => { const target = article.querySelector("[data-hooks-families]"); if (!target) return; target.innerHTML = Object.entries(FAMILY_META).map(([id, meta]) => { const rules = familyRules(id); const enabled = rules.filter((rule) => isEnabled(rule.id)).length; const selected = state.activeFamily === id; return ` `; }).join(""); const panel = article.querySelector("#dcx-hooks-rule-panel"); panel?.setAttribute("aria-labelledby", `dcx-hooks-family-${state.activeFamily}`); requestAnimationFrame(() => revealSelectedFamily(target)); }; const renderRules = (article) => { const target = article.querySelector("[data-hooks-rule-groups]"); const summary = article.querySelector("[data-hooks-summary]"); const search = article.querySelector("[data-hooks-search]"); if (!target || !summary) return; const query = (search?.value || "").trim().toLowerCase(); const rules = familyRules(state.activeFamily); const filtered = rules.filter((rule) => !query || `${rule.id} ${rule.name} ${rule.description} ${rule.discipline}`.toLowerCase().includes(query)); const enabled = rules.filter((rule) => isEnabled(rule.id)).length; summary.textContent = query ? `${filtered.length} matching ${filtered.length === 1 ? "rule" : "rules"}` : `${enabled} of ${rules.length} selected`; const groups = new Map(); filtered.forEach((rule) => { if (!groups.has(rule.discipline)) groups.set(rule.discipline, []); groups.get(rule.discipline).push(rule); }); const orderedGroups = [...groups.entries()].sort(([a], [b]) => { const ai = DISCIPLINE_ORDER.indexOf(a); const bi = DISCIPLINE_ORDER.indexOf(b); return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi) || a.localeCompare(b); }); if (!orderedGroups.length) { target.innerHTML = '

No rules match this search.

'; return; } target.innerHTML = orderedGroups.map(([discipline, entries], index) => { const disclosureId = `dcx-hooks-${state.activeFamily}-${slugify(discipline)}`; const summaryId = `${disclosureId}-summary`; return `
${escapeHtml(discipline)} ${entries.length}
    ${entries.map((rule) => `
  • ${escapeHtml(rule.name)}

    ${escapeHtml(compactDescription(rule.description))}

  • `).join("")}
`; }).join(""); }; const renderCustom = (article) => { const target = article.querySelector("[data-hooks-custom-list]"); const count = article.querySelector("[data-hooks-custom-count]"); if (!target) return; if (count) { count.textContent = state.custom.length ? `${state.custom.length} custom ${state.custom.length === 1 ? "rule" : "rules"}` : "No custom rules."; } if (!state.custom.length) { target.innerHTML = ""; return; } target.innerHTML = ""; state.custom.forEach((rule) => { const row = document.createElement("article"); row.className = "dcx-hooks-custom-rule"; const copy = document.createElement("div"); copy.className = "dcx-hooks-rule-copy"; const id = document.createElement("code"); id.textContent = rule.id; const name = document.createElement("strong"); name.textContent = rule.name; const description = document.createElement("p"); description.textContent = rule.description; const discipline = document.createElement("span"); discipline.className = "dcx-hooks-custom-discipline"; discipline.textContent = rule.discipline; copy.append(id, name, description, discipline); const controls = document.createElement("div"); controls.className = "dcx-hooks-custom-controls"; const toggle = document.createElement("label"); toggle.className = "dcx-hooks-switch"; const input = document.createElement("input"); input.type = "checkbox"; input.setAttribute("role", "switch"); input.setAttribute("aria-label", `Enable ${rule.name}`); input.dataset.hooksCustomRule = rule.id; input.checked = rule.enabled !== false; const track = document.createElement("span"); track.setAttribute("aria-hidden", "true"); toggle.append(input, track); const remove = document.createElement("button"); remove.className = "dcx-hooks-remove"; remove.type = "button"; remove.dataset.hooksRemove = rule.id; remove.setAttribute("aria-label", `Remove ${rule.name}`); remove.textContent = "Remove"; controls.append(toggle, remove); row.append(copy, controls); target.appendChild(row); }); }; const syncMaster = (article) => { const input = article.querySelector("[data-hooks-master]"); const status = article.querySelector("[data-hooks-status]"); const copy = article.querySelector("[data-hooks-master-copy]"); const detail = article.querySelector("[data-hooks-master-detail]"); const stateText = article.querySelector("[data-hooks-master-state]"); if (!input || !status || !copy || !detail || !stateText) return; input.checked = state.enabled; status.classList.toggle("is-paused", !state.enabled); copy.textContent = "Enable hooks"; detail.textContent = "Preview only — project settings are unchanged."; stateText.textContent = state.enabled ? "On" : "Off"; }; const renderArticle = (article) => { syncMaster(article); renderFamilies(article); renderRules(article); renderCustom(article); }; const initializeMountedArticles = () => { syncFrame = 0; document.querySelectorAll('.dcx-article[data-dcx-category="hooks"]').forEach((article) => { if (article.dataset.dcxHooksReady === "true") return; article.dataset.dcxHooksReady = "true"; renderArticle(article); }); }; const scheduleSync = () => { if (syncFrame) return; syncFrame = requestAnimationFrame(initializeMountedArticles); }; document.addEventListener("click", (event) => { const article = event.target.closest('.dcx-article[data-dcx-category="hooks"]'); if (!article) return; const summary = event.target.closest(".dcx-hooks-discipline > summary"); if (summary) { event.preventDefault(); toggleDiscipline(article, summary); return; } const family = event.target.closest("[data-hooks-family]"); if (family) { const restoreFocus = family === document.activeElement; state.activeFamily = family.dataset.hooksFamily; persist(); renderFamilies(article); renderRules(article); if (restoreFocus) { article.querySelector(`[data-hooks-family="${state.activeFamily}"]`)?.focus({ preventScroll: true }); } return; } const add = event.target.closest("[data-hooks-add]"); if (add) { const form = article.querySelector("[data-hooks-form]"); if (!form) return; setCustomFormOpen(form, true); add.setAttribute("aria-expanded", "true"); form.querySelector("input[name='name']")?.focus(); return; } const cancel = event.target.closest("[data-hooks-cancel]"); if (cancel) { const form = article.querySelector("[data-hooks-form]"); form?.reset(); if (form) setCustomFormOpen(form, false); const addButton = article.querySelector("[data-hooks-add]"); addButton?.setAttribute("aria-expanded", "false"); addButton?.focus({ preventScroll: true }); return; } const remove = event.target.closest("[data-hooks-remove]"); if (remove) { state.custom = state.custom.filter((rule) => rule.id !== remove.dataset.hooksRemove); persist(); renderCustom(article); (article.querySelector("[data-hooks-remove]") || article.querySelector("[data-hooks-add]")) ?.focus({ preventScroll: true }); } }); document.addEventListener("input", (event) => { if (!event.target.matches("[data-hooks-search]")) return; const article = event.target.closest('.dcx-article[data-dcx-category="hooks"]'); if (article) renderRules(article); }); document.addEventListener("keydown", (event) => { const summary = event.target.closest(".dcx-hooks-discipline > summary"); if (summary && ["Enter", " "].includes(event.key)) { event.preventDefault(); if (!event.repeat) { const article = summary.closest('.dcx-article[data-dcx-category="hooks"]'); if (article) toggleDiscipline(article, summary); } return; } const family = event.target.closest("[data-hooks-family]"); if (!family || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return; const article = family.closest('.dcx-article[data-dcx-category="hooks"]'); const buttons = [...article.querySelectorAll("[data-hooks-family]")]; const index = buttons.indexOf(family); if (index < 0) return; event.preventDefault(); const direction = ["ArrowRight", "ArrowDown"].includes(event.key) ? 1 : -1; const nextIndex = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index + direction + buttons.length) % buttons.length; buttons[nextIndex].click(); article.querySelector(`[data-hooks-family="${state.activeFamily}"]`)?.focus(); }); document.addEventListener("change", (event) => { const article = event.target.closest('.dcx-article[data-dcx-category="hooks"]'); if (!article) return; if (event.target.matches("[data-hooks-master]")) { state.enabled = event.target.checked; persist(); syncMaster(article); return; } if (event.target.matches("[data-hooks-rule]")) { if (event.target.checked) disabledRules.delete(event.target.dataset.hooksRule); else disabledRules.add(event.target.dataset.hooksRule); persist(); renderFamilies(article); const query = article.querySelector("[data-hooks-search]")?.value.trim(); const summary = article.querySelector("[data-hooks-summary]"); if (!query && summary) { const rules = familyRules(state.activeFamily); summary.textContent = `${rules.filter((rule) => isEnabled(rule.id)).length} of ${rules.length} selected`; } return; } if (event.target.matches("[data-hooks-custom-rule]")) { const rule = state.custom.find((entry) => entry.id === event.target.dataset.hooksCustomRule); if (rule) { rule.enabled = event.target.checked; persist(); } } }); document.addEventListener("submit", (event) => { const form = event.target.closest("[data-hooks-form]"); if (!form) return; event.preventDefault(); const article = form.closest('.dcx-article[data-dcx-category="hooks"]'); if (!article) return; const data = new FormData(form); const name = String(data.get("name") || "").trim(); const description = String(data.get("description") || "").trim(); const discipline = String(data.get("discipline") || "Visual Details"); if (!name || !description) return; const base = slugify(name); let id = base; let suffix = 2; const existing = new Set([...RULES.map((rule) => rule.id), ...state.custom.map((rule) => rule.id)]); while (existing.has(id)) { id = `${base}-${suffix}`; suffix += 1; } state.custom.push({ id, name, description, discipline, enabled: true }); persist(); form.reset(); setCustomFormOpen(form, false); const addButton = article.querySelector("[data-hooks-add]"); addButton?.setAttribute("aria-expanded", "false"); renderCustom(article); addButton?.focus({ preventScroll: true }); }); renameInterfaceToHooks(); installTemplate(); const observer = new MutationObserver(scheduleSync); observer.observe(document.body, { childList: true, subtree: true }); window.addEventListener("pageshow", scheduleSync); scheduleSync(); })();