mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
689 lines
26 KiB
JavaScript
689 lines
26 KiB
JavaScript
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 = () => `
|
|
<article class="dcx-article">
|
|
<header>
|
|
<h2 class="dcx-title">Hooks</h2>
|
|
<p class="dcx-lede">Checks that catch design regressions while you work.</p>
|
|
</header>
|
|
<section class="dcx-block" data-label="How it runs">
|
|
<span class="dcx-block-label">How it runs</span>
|
|
<div class="dcx-hooks-intro">
|
|
<p class="dcx-hooks-definition">Hooks watch interface changes and surface problems before they spread.</p>
|
|
<div class="dcx-hooks-status" data-hooks-status>
|
|
<div class="dcx-hooks-status-copy">
|
|
<strong data-hooks-master-copy>Enable hooks</strong>
|
|
<p data-hooks-master-detail>Preview only — project settings are unchanged.</p>
|
|
</div>
|
|
<div class="dcx-hooks-status-control">
|
|
<span class="dcx-hooks-status-state" data-hooks-master-state>On</span>
|
|
<label class="dcx-hooks-switch dcx-hooks-switch--master">
|
|
<input type="checkbox" role="switch" data-hooks-master aria-label="Enable design hooks">
|
|
<span aria-hidden="true"></span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<dl class="dcx-hooks-flow">
|
|
<div>
|
|
<dt>On each edit</dt>
|
|
<dd>Checks the changed UI.</dd>
|
|
</div>
|
|
<div>
|
|
<dt>At session end</dt>
|
|
<dd>Runs a full pass on touched UI files.</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
</section>
|
|
<section class="dcx-block" data-label="Built-in rules">
|
|
<span class="dcx-block-label">Built-in rules</span>
|
|
<div class="dcx-hooks-browser" data-hooks-browser>
|
|
<div class="dcx-hooks-families" role="tablist" aria-label="Rule families" data-hooks-families></div>
|
|
<div class="dcx-hooks-rule-panel" id="dcx-hooks-rule-panel" role="tabpanel">
|
|
<div class="dcx-hooks-toolbar">
|
|
<label class="dcx-hooks-search">
|
|
<input type="search" autocomplete="off" aria-label="Search rules" placeholder="Search rules" data-hooks-search>
|
|
</label>
|
|
<p class="dcx-hooks-summary" data-hooks-summary aria-live="polite"></p>
|
|
</div>
|
|
<div class="dcx-hooks-rule-groups" data-hooks-rule-groups></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<section class="dcx-block" data-label="Custom rules">
|
|
<span class="dcx-block-label">Custom rules</span>
|
|
<div class="dcx-hooks-custom" data-hooks-custom>
|
|
<div class="dcx-hooks-custom-toolbar">
|
|
<p data-hooks-custom-count>No custom rules.</p>
|
|
<button class="dcx-hooks-button" type="button" data-hooks-add aria-expanded="false" aria-controls="dcx-hooks-custom-form">Add rule</button>
|
|
</div>
|
|
<form class="dcx-hooks-custom-form" id="dcx-hooks-custom-form" data-hooks-form hidden>
|
|
<label>
|
|
<span>Rule name</span>
|
|
<input name="name" required maxlength="80" placeholder="e.g. Approved corner radius">
|
|
</label>
|
|
<label>
|
|
<span>Category</span>
|
|
<select name="discipline">
|
|
<option>Visual Details</option>
|
|
<option>Typography</option>
|
|
<option>Color & Contrast</option>
|
|
<option>Layout & Space</option>
|
|
<option>Motion</option>
|
|
<option>Imagery</option>
|
|
<option>Copy</option>
|
|
</select>
|
|
</label>
|
|
<label class="dcx-hooks-custom-form-description">
|
|
<span>What should it catch?</span>
|
|
<textarea name="description" required rows="3" maxlength="240" placeholder="Describe the condition and the correction."></textarea>
|
|
</label>
|
|
<div class="dcx-hooks-form-actions">
|
|
<button class="dcx-hooks-button dcx-hooks-button--quiet" type="button" data-hooks-cancel>Cancel</button>
|
|
<button class="dcx-hooks-button" type="submit">Save rule</button>
|
|
</div>
|
|
</form>
|
|
<div class="dcx-hooks-custom-list" data-hooks-custom-list></div>
|
|
<p class="dcx-hooks-storage-note">Preview only — saved in this browser; custom rules do not run.</p>
|
|
</div>
|
|
</section>
|
|
</article>
|
|
`;
|
|
|
|
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 `
|
|
<button
|
|
id="dcx-hooks-family-${id}"
|
|
class="dcx-hooks-family${selected ? " is-active" : ""}"
|
|
type="button"
|
|
role="tab"
|
|
tabindex="${selected ? "0" : "-1"}"
|
|
aria-selected="${selected}"
|
|
aria-label="${escapeHtml(meta.label)}, ${enabled} of ${rules.length} selected"
|
|
aria-controls="dcx-hooks-rule-panel"
|
|
data-hooks-family="${id}"
|
|
>
|
|
<span class="dcx-hooks-family-name">${escapeHtml(meta.label)}</span>
|
|
<span class="dcx-hooks-family-count">${enabled}/${rules.length}</span>
|
|
</button>
|
|
`;
|
|
}).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 = '<p class="dcx-hooks-empty">No rules match this search.</p>';
|
|
return;
|
|
}
|
|
|
|
target.innerHTML = orderedGroups.map(([discipline, entries], index) => {
|
|
const disclosureId = `dcx-hooks-${state.activeFamily}-${slugify(discipline)}`;
|
|
const summaryId = `${disclosureId}-summary`;
|
|
return `
|
|
<details class="dcx-hooks-discipline" ${query || index === 0 ? "open" : ""}>
|
|
<summary id="${summaryId}">
|
|
<span>${escapeHtml(discipline)}</span>
|
|
<span>${entries.length}</span>
|
|
</summary>
|
|
<div class="dcx-hooks-disclosure" id="${disclosureId}" role="region" aria-labelledby="${summaryId}">
|
|
<div class="dcx-hooks-disclosure-inner">
|
|
<ul class="dcx-hooks-rules">
|
|
${entries.map((rule) => `
|
|
<li class="dcx-hooks-rule" data-rule-id="${escapeHtml(rule.id)}">
|
|
<div class="dcx-hooks-rule-copy">
|
|
<strong>${escapeHtml(rule.name)}</strong>
|
|
<p>${escapeHtml(compactDescription(rule.description))}</p>
|
|
</div>
|
|
<label class="dcx-hooks-switch">
|
|
<input
|
|
type="checkbox"
|
|
role="switch"
|
|
data-hooks-rule="${escapeHtml(rule.id)}"
|
|
aria-label="Enable ${escapeHtml(rule.name)}"
|
|
${isEnabled(rule.id) ? "checked" : ""}
|
|
>
|
|
<span aria-hidden="true"></span>
|
|
</label>
|
|
</li>
|
|
`).join("")}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
`;
|
|
}).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();
|
|
})();
|