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 EXCEPTION_KINDS = { value: { label: "Silence one value", hint: "One rule stops flagging one exact value, everywhere." }, "rule-in-files": { label: "Silence a rule in files", hint: "One rule goes quiet in matching files and stays live everywhere else." }, file: { label: "Skip a file entirely", hint: "Every rule skips matching files. The widest exception; prefer the two above." }, }; /* Only the view preference persists in the browser. The state that matters lives in the project's .impeccable/config.json and arrives from the doc session, so the page shows what the hook will actually do, not a preview. */ const loadView = () => { try { const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || "null"); return { activeFamily: parsed && FAMILY_META[parsed.activeFamily] ? parsed.activeFamily : "fingerprints" }; } catch { return { activeFamily: "fingerprints" }; } }; const view = loadView(); const persistView = () => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ activeFamily: view.activeFamily })); } catch { // The file:// preview may deny storage; the in-memory controls still work. } }; /* live is the last state the project confirmed; draft is what the controls show. Apply sends the whole draft and the server's echo becomes the new live, so the page and .impeccable/config.json can only disagree while the Apply bar is visible and says so. */ let live = null; let draft = null; let liveError = ""; let applying = false; let appliedFlash = false; let appliedFlashTimer = 0; let fetchStarted = false; const docSession = () => window.dcxDocSession || null; const hooksUrl = () => { const session = docSession(); if (!session?.base || !session?.token) return ""; return `${session.base}/doc/hooks?token=${encodeURIComponent(session.token)}`; }; const cloneState = (state) => JSON.parse(JSON.stringify(state)); const entryKey = (entry) => { const files = Array.isArray(entry.files) && entry.files.length > 0 ? [...entry.files].sort().join("\u001f") : ""; return `${entry.rule}\u0000${entry.value}\u0000${files}`; }; const changeCount = () => { if (!live || !draft) return 0; let count = draft.enabled !== live.enabled ? 1 : 0; const liveRules = new Set(live.ignoreRules); const draftRules = new Set(draft.ignoreRules); for (const id of draftRules) if (!liveRules.has(id)) count += 1; for (const id of liveRules) if (!draftRules.has(id)) count += 1; const liveFiles = new Set(live.ignoreFiles); const draftFiles = new Set(draft.ignoreFiles); for (const glob of draftFiles) if (!liveFiles.has(glob)) count += 1; for (const glob of liveFiles) if (!draftFiles.has(glob)) count += 1; const liveValues = new Map(live.ignoreValues.map((entry) => [entryKey(entry), entry])); const draftValues = new Map(draft.ignoreValues.map((entry) => [entryKey(entry), entry])); for (const key of draftValues.keys()) if (!liveValues.has(key)) count += 1; for (const key of liveValues.keys()) if (!draftValues.has(key)) count += 1; return count; }; /* Three-way rebase for an apply that lost the race: keep every edit the visitor made (draft against the state the page read) and land it on what the project now holds, so nothing another writer added is dropped. */ const rebaseDraft = (oldLive, oldDraft, newLive) => { const next = cloneState(newLive); if (oldDraft.enabled !== oldLive.enabled) next.enabled = oldDraft.enabled; for (const key of ["ignoreRules", "ignoreFiles"]) { const removed = new Set(oldLive[key].filter((item) => !oldDraft[key].includes(item))); const added = oldDraft[key].filter((item) => !oldLive[key].includes(item)); next[key] = next[key].filter((item) => !removed.has(item)); for (const item of added) if (!next[key].includes(item)) next[key].push(item); } const oldKeys = new Set(oldLive.ignoreValues.map(entryKey)); const draftKeys = new Set(oldDraft.ignoreValues.map(entryKey)); const removedKeys = new Set([...oldKeys].filter((key) => !draftKeys.has(key))); next.ignoreValues = next.ignoreValues.filter((entry) => !removedKeys.has(entryKey(entry))); const presentKeys = new Set(next.ignoreValues.map(entryKey)); for (const entry of oldDraft.ignoreValues) { const key = entryKey(entry); if (!oldKeys.has(key) && !presentKeys.has(key)) { next.ignoreValues.push(cloneState(entry)); presentKeys.add(key); } } return next; }; 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 ruleName = (id) => RULES.find((rule) => rule.id === id)?.name || id; 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

Reading this project’s settings…

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

Exceptions

No exceptions.

Exceptions apply to the design hook and to npx impeccable detect in this project.

`; 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) => !(draft ? draft.ignoreRules.includes(id) : false); const interactive = () => Boolean(live && draft && !applying); 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 disciplineAnimations = new WeakMap(); const customFormAnimations = new WeakMap(); let syncFrame = 0; 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 = view.activeFamily === id; return ` `; }).join(""); const panel = article.querySelector("#dcx-hooks-rule-panel"); panel?.setAttribute("aria-labelledby", `dcx-hooks-family-${view.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(view.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; } const disabledUi = interactive() ? "" : "disabled"; target.innerHTML = orderedGroups.map(([discipline, entries], index) => { const disclosureId = `dcx-hooks-${view.activeFamily}-${slugify(discipline)}`; const summaryId = `${disclosureId}-summary`; return `
${escapeHtml(discipline)} ${entries.length}
    ${entries.map((rule) => `
  • ${escapeHtml(rule.name)}

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

  • `).join("")}
`; }).join(""); }; /* One row per exception the project holds, whatever wrote it: entries added here, by an agent's triage, or by npx impeccable ignores all render the same, and Remove queues a real removal for Apply. */ const renderExceptions = (article) => { const target = article.querySelector("[data-hooks-custom-list]"); const count = article.querySelector("[data-hooks-custom-count]"); const note = article.querySelector("[data-hooks-live-note]"); const add = article.querySelector("[data-hooks-add]"); if (!target) return; const values = draft ? draft.ignoreValues : []; const files = draft ? draft.ignoreFiles : []; const total = values.length + files.length; if (count) { count.textContent = total ? `${total} ${total === 1 ? "exception" : "exceptions"}` : "No exceptions."; } if (add) add.disabled = !interactive(); if (note) { note.textContent = interactive() || applying ? "Exceptions apply to the design hook and to npx impeccable detect in this project." : "The editing session has ended. /impeccable design-context reopens it."; } target.innerHTML = ""; values.forEach((entry, index) => { const row = document.createElement("article"); row.className = "dcx-hooks-custom-rule"; const copy = document.createElement("div"); copy.className = "dcx-hooks-rule-copy"; const kind = document.createElement("span"); kind.className = "dcx-hooks-custom-discipline"; kind.textContent = entry.value === "*" ? "Rule, in files" : "Value"; const name = document.createElement("strong"); name.textContent = entry.value === "*" ? ruleName(entry.rule) : `${ruleName(entry.rule)}: ${entry.value}`; const detail = document.createElement("p"); detail.textContent = entry.files?.length ? `In ${entry.files.join(", ")}` : "Everywhere in this project."; copy.append(kind, name, detail); if (entry.reason) { const reason = document.createElement("p"); reason.className = "dcx-hooks-custom-reason"; reason.textContent = entry.reason; copy.append(reason); } const controls = document.createElement("div"); controls.className = "dcx-hooks-custom-controls"; const remove = document.createElement("button"); remove.className = "dcx-hooks-remove"; remove.type = "button"; remove.dataset.hooksRemoveValue = String(index); remove.setAttribute("aria-label", `Remove exception for ${ruleName(entry.rule)}`); remove.textContent = "Remove"; remove.disabled = !interactive(); controls.append(remove); row.append(copy, controls); target.appendChild(row); }); files.forEach((glob, index) => { const row = document.createElement("article"); row.className = "dcx-hooks-custom-rule"; const copy = document.createElement("div"); copy.className = "dcx-hooks-rule-copy"; const kind = document.createElement("span"); kind.className = "dcx-hooks-custom-discipline"; kind.textContent = "Skipped files"; const name = document.createElement("strong"); name.textContent = glob; const detail = document.createElement("p"); detail.textContent = "Every rule skips matching files."; copy.append(kind, name, detail); const controls = document.createElement("div"); controls.className = "dcx-hooks-custom-controls"; const remove = document.createElement("button"); remove.className = "dcx-hooks-remove"; remove.type = "button"; remove.dataset.hooksRemoveFile = String(index); remove.setAttribute("aria-label", `Stop skipping ${glob}`); remove.textContent = "Remove"; remove.disabled = !interactive(); controls.append(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; const enabled = draft ? draft.enabled : true; input.checked = enabled; input.disabled = !interactive(); status.classList.toggle("is-paused", !enabled); copy.textContent = "Enable hooks"; if (draft) { detail.textContent = "Runs in this project; changes land in .impeccable/config.json when you press Apply."; } else if (liveError) { detail.textContent = liveError; } else if (!docSession()) { detail.textContent = "The editing session has ended. /impeccable design-context reopens it."; } else { detail.textContent = "Reading this project’s settings…"; } stateText.textContent = enabled ? "On" : "Off"; }; const syncApplyBar = (article) => { const bar = article.querySelector("[data-hooks-applybar]"); const copy = article.querySelector("[data-hooks-applybar-copy]"); const apply = article.querySelector("[data-hooks-apply]"); const discard = article.querySelector("[data-hooks-discard]"); if (!bar || !copy || !apply || !discard) return; const changes = changeCount(); const show = Boolean(live && draft) && (changes > 0 || applying || appliedFlash); bar.hidden = !show; if (!show) return; apply.disabled = applying || changes === 0; discard.disabled = applying || changes === 0; if (applying) { copy.textContent = "Applying…"; } else if (changes > 0) { copy.textContent = `${changes} ${changes === 1 ? "change" : "changes"} not applied yet.`; } else { copy.textContent = "Applied to .impeccable/config.json."; } }; const renderArticle = (article) => { syncMaster(article); renderFamilies(article); renderRules(article); renderExceptions(article); syncApplyBar(article); }; const hooksArticles = () => [...document.querySelectorAll('.dcx-article[data-dcx-category="hooks"]')]; const renderAll = () => hooksArticles().forEach(renderArticle); const fetchLiveState = async () => { const url = hooksUrl(); if (!url || fetchStarted) return; fetchStarted = true; try { const response = await fetch(url); const body = await response.json().catch(() => null); if (!response.ok || !body?.ok || !body.state) { throw new Error(body?.error || `The doc session answered ${response.status}.`); } live = body.state; draft = cloneState(live); liveError = ""; } catch { liveError = "Could not read this project’s hook settings; the controls stay read-only."; /* A later remount retries: the session may only now be announced. */ fetchStarted = false; } renderAll(); }; const applyDraft = async (article) => { const url = hooksUrl(); const session = docSession(); if (!url || !session || !draft || applying) return; applying = true; appliedFlash = false; if (appliedFlashTimer) { window.clearTimeout(appliedFlashTimer); appliedFlashTimer = 0; } renderAll(); try { const response = await fetch(`${session.base}/doc/hooks`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: session.token, state: { ...draft, baseline: live } }), }); const body = await response.json().catch(() => null); if (!response.ok || !body?.ok || !body.state) { throw new Error(body?.error || `The doc session answered ${response.status}.`); } live = body.state; draft = cloneState(live); appliedFlash = true; appliedFlashTimer = window.setTimeout(() => { appliedFlashTimer = 0; appliedFlash = false; renderAll(); }, 2600); } catch (error) { const message = String(error.message || error); applying = false; if (message.includes("hook config changed on disk") && live && draft) { const oldLive = live; const oldDraft = draft; try { const refreshed = await fetch(url); const refreshedBody = await refreshed.json().catch(() => null); if (refreshed.ok && refreshedBody?.ok && refreshedBody.state) { live = refreshedBody.state; draft = rebaseDraft(oldLive, oldDraft, live); renderAll(); /* Written after renderAll so the bar's own copy cannot eat it. */ const copy = article.querySelector("[data-hooks-applybar-copy]"); if (copy) copy.textContent = "Another run changed this project's hook settings while you edited. Your changes were re-applied on top; review and press Apply again."; return; } } catch { /* The session dropped mid-conflict; fall through to the plain error. */ } } syncApplyBar(article); /* After the bar re-renders its change count, the reason overwrites it; written after the sync so the sync cannot eat it. */ const copy = article.querySelector("[data-hooks-applybar-copy]"); if (copy) copy.textContent = `Not applied: ${message}`; return; } applying = false; renderAll(); }; const initializeMountedArticles = () => { syncFrame = 0; hooksArticles().forEach((article) => { if (article.dataset.dcxHooksReady === "true") return; article.dataset.dcxHooksReady = "true"; renderArticle(article); }); if (live === null) fetchLiveState(); }; const scheduleSync = () => { if (syncFrame) return; syncFrame = requestAnimationFrame(initializeMountedArticles); }; const syncKindFields = (form) => { const kind = form.querySelector("[data-hooks-kind]")?.value || "value"; const hint = form.querySelector("[data-hooks-kind-hint]"); if (hint) hint.textContent = EXCEPTION_KINDS[kind]?.hint || ""; form.querySelector('[data-hooks-field="rule"]')?.toggleAttribute("hidden", kind === "file"); form.querySelector('[data-hooks-field="value"]')?.toggleAttribute("hidden", kind !== "value"); form.querySelector('[data-hooks-field="files"]')?.toggleAttribute("hidden", kind === "value"); form.querySelector('[data-hooks-field="reason"]')?.toggleAttribute("hidden", kind === "file"); }; const parseGlobList = (value) => [...new Set(String(value) .split(/[\n,]/) .map((glob) => glob.trim()) .filter(Boolean))]; 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; view.activeFamily = family.dataset.hooksFamily; persistView(); renderFamilies(article); renderRules(article); if (restoreFocus) { article.querySelector(`[data-hooks-family="${view.activeFamily}"]`)?.focus({ preventScroll: true }); } return; } const add = event.target.closest("[data-hooks-add]"); if (add && interactive()) { const form = article.querySelector("[data-hooks-form]"); if (!form) return; syncKindFields(form); setCustomFormOpen(form, true); add.setAttribute("aria-expanded", "true"); form.querySelector("[data-hooks-kind]")?.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 removeValue = event.target.closest("[data-hooks-remove-value]"); if (removeValue && interactive()) { draft.ignoreValues.splice(Number(removeValue.dataset.hooksRemoveValue), 1); renderAll(); (article.querySelector(".dcx-hooks-remove") || article.querySelector("[data-hooks-add]")) ?.focus({ preventScroll: true }); return; } const removeFile = event.target.closest("[data-hooks-remove-file]"); if (removeFile && interactive()) { draft.ignoreFiles.splice(Number(removeFile.dataset.hooksRemoveFile), 1); renderAll(); (article.querySelector(".dcx-hooks-remove") || article.querySelector("[data-hooks-add]")) ?.focus({ preventScroll: true }); return; } const discard = event.target.closest("[data-hooks-discard]"); if (discard && live) { draft = cloneState(live); renderAll(); return; } const apply = event.target.closest("[data-hooks-apply]"); if (apply) applyDraft(article); }); 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="${view.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-kind]")) { const form = event.target.closest("[data-hooks-form]"); if (form) syncKindFields(form); return; } if (!interactive()) return; if (event.target.matches("[data-hooks-master]")) { draft.enabled = event.target.checked; syncMaster(article); syncApplyBar(article); return; } if (event.target.matches("[data-hooks-rule]")) { const id = event.target.dataset.hooksRule; if (event.target.checked) draft.ignoreRules = draft.ignoreRules.filter((entry) => entry !== id); else if (!draft.ignoreRules.includes(id)) draft.ignoreRules.push(id); renderFamilies(article); syncApplyBar(article); const query = article.querySelector("[data-hooks-search]")?.value.trim(); const summary = article.querySelector("[data-hooks-summary]"); if (!query && summary) { const rules = familyRules(view.activeFamily); summary.textContent = `${rules.filter((rule) => isEnabled(rule.id)).length} of ${rules.length} selected`; } } }); 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 || !interactive()) return; const data = new FormData(form); const kind = String(data.get("kind") || "value"); const rule = String(data.get("rule") || "").trim(); const value = String(data.get("value") || "").trim(); const files = parseGlobList(data.get("files") || ""); const reason = String(data.get("reason") || "").trim(); if (kind === "file") { if (!files.length) return; files.forEach((glob) => { if (!draft.ignoreFiles.includes(glob)) draft.ignoreFiles.push(glob); }); } else { const entry = kind === "value" ? { rule, value } : { rule, value: "*", files }; if (!entry.rule || (kind === "value" && !entry.value)) return; if (kind === "rule-in-files" && !files.length) return; if (reason) entry.reason = reason; const keys = new Set(draft.ignoreValues.map(entryKey)); if (!keys.has(entryKey(entry))) draft.ignoreValues.push(entry); } form.reset(); syncKindFields(form); setCustomFormOpen(form, false); const addButton = article.querySelector("[data-hooks-add]"); addButton?.setAttribute("aria-expanded", "false"); renderAll(); addButton?.focus({ preventScroll: true }); }); renameInterfaceToHooks(); installTemplate(); const observer = new MutationObserver(scheduleSync); observer.observe(document.body, { childList: true, subtree: true }); window.addEventListener("pageshow", scheduleSync); scheduleSync(); })();