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 = () => `
How it runs
Hooks watch interface changes and surface problems before they spread.
- On each edit
- Checks the changed UI.
- At session end
- Runs a full pass on touched UI files.
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}
`;
}).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();
})();