This commit is contained in:
Abdul Wahab
2026-09-01 10:09:45 +05:00
parent 35de8595b6
commit 07ebdfd5b1
6 changed files with 665 additions and 142 deletions
+413 -138
View File
@@ -30,49 +30,105 @@ import RULES from '../../data/hook-rules.json';
"Copy", "Copy",
"Quality", "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." },
};
const initialState = () => ({ /* Only the view preference persists in the browser. The state that matters
enabled: true, lives in the project's .impeccable/config.json and arrives from the doc
activeFamily: "fingerprints", session, so the page shows what the hook will actually do, not a preview. */
disabled: ["em-dash-overuse"], const loadView = () => {
custom: [],
});
const loadState = () => {
const fallback = initialState();
try { try {
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || "null"); const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || "null");
if (!parsed || typeof parsed !== "object") return fallback; return { activeFamily: parsed && FAMILY_META[parsed.activeFamily] ? parsed.activeFamily : "fingerprints" };
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 { } catch {
return fallback; return { activeFamily: "fingerprints" };
} }
}; };
const state = loadState(); const view = loadView();
const disabledRules = new Set(state.disabled);
const disciplineAnimations = new WeakMap();
const customFormAnimations = new WeakMap();
let syncFrame = 0;
const persist = () => { const persistView = () => {
state.disabled = [...disabledRules];
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); localStorage.setItem(STORAGE_KEY, JSON.stringify({ activeFamily: view.activeFamily }));
} catch { } catch {
// The file:// preview may deny storage; the in-memory controls still work. // 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) const escapeHtml = (value) => String(value)
.replaceAll("&", "&") .replaceAll("&", "&")
.replaceAll("<", "&lt;") .replaceAll("<", "&lt;")
@@ -90,6 +146,8 @@ import RULES from '../../data/hook-rules.json';
return firstSentence || text; return firstSentence || text;
}; };
const ruleName = (id) => RULES.find((rule) => rule.id === id)?.name || id;
const templateMarkup = () => ` const templateMarkup = () => `
<article class="dcx-article"> <article class="dcx-article">
<header> <header>
@@ -103,7 +161,7 @@ import RULES from '../../data/hook-rules.json';
<div class="dcx-hooks-status" data-hooks-status> <div class="dcx-hooks-status" data-hooks-status>
<div class="dcx-hooks-status-copy"> <div class="dcx-hooks-status-copy">
<strong data-hooks-master-copy>Enable hooks</strong> <strong data-hooks-master-copy>Enable hooks</strong>
<p data-hooks-master-detail>Preview only — project settings are unchanged.</p> <p data-hooks-master-detail>Reading this project&rsquo;s settings&hellip;</p>
</div> </div>
<div class="dcx-hooks-status-control"> <div class="dcx-hooks-status-control">
<span class="dcx-hooks-status-state" data-hooks-master-state>On</span> <span class="dcx-hooks-status-state" data-hooks-master-state>On</span>
@@ -140,43 +198,55 @@ import RULES from '../../data/hook-rules.json';
</div> </div>
</div> </div>
</section> </section>
<section class="dcx-block" data-label="Custom rules"> <section class="dcx-block" data-label="Exceptions">
<span class="dcx-block-label">Custom rules</span> <span class="dcx-block-label">Exceptions</span>
<div class="dcx-hooks-custom" data-hooks-custom> <div class="dcx-hooks-custom" data-hooks-custom>
<div class="dcx-hooks-custom-toolbar"> <div class="dcx-hooks-custom-toolbar">
<p data-hooks-custom-count>No custom rules.</p> <p data-hooks-custom-count>No exceptions.</p>
<button class="dcx-hooks-button" type="button" data-hooks-add aria-expanded="false" aria-controls="dcx-hooks-custom-form">Add rule</button> <button class="dcx-hooks-button" type="button" data-hooks-add aria-expanded="false" aria-controls="dcx-hooks-custom-form">Add exception</button>
</div> </div>
<form class="dcx-hooks-custom-form" id="dcx-hooks-custom-form" data-hooks-form hidden> <form class="dcx-hooks-custom-form" id="dcx-hooks-custom-form" data-hooks-form hidden>
<label> <label>
<span>Rule name</span> <span>Kind</span>
<input name="name" required maxlength="80" placeholder="e.g. Approved corner radius"> <select name="kind" data-hooks-kind>
</label> ${Object.entries(EXCEPTION_KINDS).map(([id, meta]) => `<option value="${id}">${escapeHtml(meta.label)}</option>`).join("")}
<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> </select>
</label> </label>
<label class="dcx-hooks-custom-form-description"> <p class="dcx-hooks-kind-hint" data-hooks-kind-hint>${escapeHtml(EXCEPTION_KINDS.value.hint)}</p>
<span>What should it catch?</span> <label data-hooks-field="rule">
<textarea name="description" required rows="3" maxlength="240" placeholder="Describe the condition and the correction."></textarea> <span>Rule</span>
<select name="rule">
${[...RULES].sort((a, b) => a.name.localeCompare(b.name)).map((rule) => `<option value="${escapeHtml(rule.id)}">${escapeHtml(rule.name)}</option>`).join("")}
</select>
</label>
<label data-hooks-field="value">
<span>Value</span>
<input name="value" maxlength="200" placeholder="e.g. Inter, or #7BA98F">
</label>
<label data-hooks-field="files" hidden>
<span>Files</span>
<input name="files" maxlength="400" placeholder="Globs, comma separated: src/legacy/**, docs/demo.html">
</label>
<label data-hooks-field="reason">
<span>Reason</span>
<input name="reason" maxlength="200" placeholder="Optional: who decided, and the evidence">
</label> </label>
<div class="dcx-hooks-form-actions"> <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 dcx-hooks-button--quiet" type="button" data-hooks-cancel>Cancel</button>
<button class="dcx-hooks-button" type="submit">Save rule</button> <button class="dcx-hooks-button" type="submit">Add</button>
</div> </div>
</form> </form>
<div class="dcx-hooks-custom-list" data-hooks-custom-list></div> <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> <p class="dcx-hooks-storage-note" data-hooks-live-note>Exceptions apply to the design hook and to npx impeccable detect in this project.</p>
</div> </div>
</section> </section>
<div class="dcx-hooks-applybar" data-hooks-applybar hidden>
<p class="dcx-hooks-applybar-copy" data-hooks-applybar-copy aria-live="polite"></p>
<div class="dcx-hooks-applybar-actions">
<button class="dcx-hooks-button dcx-hooks-button--quiet" type="button" data-hooks-discard>Discard</button>
<button class="dcx-hooks-button" type="button" data-hooks-apply>Apply</button>
</div>
</div>
</article> </article>
`; `;
@@ -210,7 +280,8 @@ import RULES from '../../data/hook-rules.json';
}; };
const familyRules = (family) => RULES.filter((rule) => rule.group === family); const familyRules = (family) => RULES.filter((rule) => rule.group === family);
const isEnabled = (id) => !disabledRules.has(id); const isEnabled = (id) => !(draft ? draft.ignoreRules.includes(id) : false);
const interactive = () => Boolean(live && draft && !applying);
const revealSelectedFamily = (target) => { const revealSelectedFamily = (target) => {
if (!MOBILE_FAMILIES.matches) return; if (!MOBILE_FAMILIES.matches) return;
@@ -229,6 +300,10 @@ import RULES from '../../data/hook-rules.json';
}); });
}; };
const disciplineAnimations = new WeakMap();
const customFormAnimations = new WeakMap();
let syncFrame = 0;
const setDisciplineOpen = (details, expanded) => { const setDisciplineOpen = (details, expanded) => {
const panel = details.querySelector(":scope > .dcx-hooks-disclosure"); const panel = details.querySelector(":scope > .dcx-hooks-disclosure");
const inner = panel?.querySelector(":scope > .dcx-hooks-disclosure-inner"); const inner = panel?.querySelector(":scope > .dcx-hooks-disclosure-inner");
@@ -333,7 +408,7 @@ import RULES from '../../data/hook-rules.json';
target.innerHTML = Object.entries(FAMILY_META).map(([id, meta]) => { target.innerHTML = Object.entries(FAMILY_META).map(([id, meta]) => {
const rules = familyRules(id); const rules = familyRules(id);
const enabled = rules.filter((rule) => isEnabled(rule.id)).length; const enabled = rules.filter((rule) => isEnabled(rule.id)).length;
const selected = state.activeFamily === id; const selected = view.activeFamily === id;
return ` return `
<button <button
id="dcx-hooks-family-${id}" id="dcx-hooks-family-${id}"
@@ -352,7 +427,7 @@ import RULES from '../../data/hook-rules.json';
`; `;
}).join(""); }).join("");
const panel = article.querySelector("#dcx-hooks-rule-panel"); const panel = article.querySelector("#dcx-hooks-rule-panel");
panel?.setAttribute("aria-labelledby", `dcx-hooks-family-${state.activeFamily}`); panel?.setAttribute("aria-labelledby", `dcx-hooks-family-${view.activeFamily}`);
requestAnimationFrame(() => revealSelectedFamily(target)); requestAnimationFrame(() => revealSelectedFamily(target));
}; };
@@ -363,7 +438,7 @@ import RULES from '../../data/hook-rules.json';
if (!target || !summary) return; if (!target || !summary) return;
const query = (search?.value || "").trim().toLowerCase(); const query = (search?.value || "").trim().toLowerCase();
const rules = familyRules(state.activeFamily); const rules = familyRules(view.activeFamily);
const filtered = rules.filter((rule) => !query const filtered = rules.filter((rule) => !query
|| `${rule.id} ${rule.name} ${rule.description} ${rule.discipline}`.toLowerCase().includes(query)); || `${rule.id} ${rule.name} ${rule.description} ${rule.discipline}`.toLowerCase().includes(query));
const enabled = rules.filter((rule) => isEnabled(rule.id)).length; const enabled = rules.filter((rule) => isEnabled(rule.id)).length;
@@ -388,8 +463,9 @@ import RULES from '../../data/hook-rules.json';
return; return;
} }
const disabledUi = interactive() ? "" : "disabled";
target.innerHTML = orderedGroups.map(([discipline, entries], index) => { target.innerHTML = orderedGroups.map(([discipline, entries], index) => {
const disclosureId = `dcx-hooks-${state.activeFamily}-${slugify(discipline)}`; const disclosureId = `dcx-hooks-${view.activeFamily}-${slugify(discipline)}`;
const summaryId = `${disclosureId}-summary`; const summaryId = `${disclosureId}-summary`;
return ` return `
<details class="dcx-hooks-discipline" ${query || index === 0 ? "open" : ""}> <details class="dcx-hooks-discipline" ${query || index === 0 ? "open" : ""}>
@@ -413,6 +489,7 @@ import RULES from '../../data/hook-rules.json';
data-hooks-rule="${escapeHtml(rule.id)}" data-hooks-rule="${escapeHtml(rule.id)}"
aria-label="Enable ${escapeHtml(rule.name)}" aria-label="Enable ${escapeHtml(rule.name)}"
${isEnabled(rule.id) ? "checked" : ""} ${isEnabled(rule.id) ? "checked" : ""}
${disabledUi}
> >
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</label> </label>
@@ -426,61 +503,96 @@ import RULES from '../../data/hook-rules.json';
}).join(""); }).join("");
}; };
const renderCustom = (article) => { /* 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 target = article.querySelector("[data-hooks-custom-list]");
const count = article.querySelector("[data-hooks-custom-count]"); 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; if (!target) return;
const values = draft ? draft.ignoreValues : [];
const files = draft ? draft.ignoreFiles : [];
const total = values.length + files.length;
if (count) { if (count) {
count.textContent = state.custom.length count.textContent = total
? `${state.custom.length} custom ${state.custom.length === 1 ? "rule" : "rules"}` ? `${total} ${total === 1 ? "exception" : "exceptions"}`
: "No custom rules."; : "No exceptions.";
} }
if (add) add.disabled = !interactive();
if (!state.custom.length) { if (note) {
target.innerHTML = ""; note.textContent = interactive() || applying
return; ? "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 = ""; target.innerHTML = "";
state.custom.forEach((rule) => { values.forEach((entry, index) => {
const row = document.createElement("article"); const row = document.createElement("article");
row.className = "dcx-hooks-custom-rule"; row.className = "dcx-hooks-custom-rule";
const copy = document.createElement("div"); const copy = document.createElement("div");
copy.className = "dcx-hooks-rule-copy"; copy.className = "dcx-hooks-rule-copy";
const id = document.createElement("code"); const kind = document.createElement("span");
id.textContent = rule.id; kind.className = "dcx-hooks-custom-discipline";
kind.textContent = entry.value === "*" ? "Rule, in files" : "Value";
const name = document.createElement("strong"); const name = document.createElement("strong");
name.textContent = rule.name; name.textContent = entry.value === "*"
const description = document.createElement("p"); ? ruleName(entry.rule)
description.textContent = rule.description; : `${ruleName(entry.rule)}: ${entry.value}`;
const discipline = document.createElement("span"); const detail = document.createElement("p");
discipline.className = "dcx-hooks-custom-discipline"; detail.textContent = entry.files?.length
discipline.textContent = rule.discipline; ? `In ${entry.files.join(", ")}`
copy.append(id, name, description, discipline); : "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"); const controls = document.createElement("div");
controls.className = "dcx-hooks-custom-controls"; 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"); const remove = document.createElement("button");
remove.className = "dcx-hooks-remove"; remove.className = "dcx-hooks-remove";
remove.type = "button"; remove.type = "button";
remove.dataset.hooksRemove = rule.id; remove.dataset.hooksRemoveValue = String(index);
remove.setAttribute("aria-label", `Remove ${rule.name}`); remove.setAttribute("aria-label", `Remove exception for ${ruleName(entry.rule)}`);
remove.textContent = "Remove"; remove.textContent = "Remove";
controls.append(toggle, 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); row.append(copy, controls);
target.appendChild(row); target.appendChild(row);
}); });
@@ -494,27 +606,148 @@ import RULES from '../../data/hook-rules.json';
const stateText = article.querySelector("[data-hooks-master-state]"); const stateText = article.querySelector("[data-hooks-master-state]");
if (!input || !status || !copy || !detail || !stateText) return; if (!input || !status || !copy || !detail || !stateText) return;
input.checked = state.enabled; const enabled = draft ? draft.enabled : true;
status.classList.toggle("is-paused", !state.enabled); input.checked = enabled;
input.disabled = !interactive();
status.classList.toggle("is-paused", !enabled);
copy.textContent = "Enable hooks"; copy.textContent = "Enable hooks";
detail.textContent = "Preview only — project settings are unchanged."; if (draft) {
stateText.textContent = state.enabled ? "On" : "Off"; 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 projects 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) => { const renderArticle = (article) => {
syncMaster(article); syncMaster(article);
renderFamilies(article); renderFamilies(article);
renderRules(article); renderRules(article);
renderCustom(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 projects 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 = () => { const initializeMountedArticles = () => {
syncFrame = 0; syncFrame = 0;
document.querySelectorAll('.dcx-article[data-dcx-category="hooks"]').forEach((article) => { hooksArticles().forEach((article) => {
if (article.dataset.dcxHooksReady === "true") return; if (article.dataset.dcxHooksReady === "true") return;
article.dataset.dcxHooksReady = "true"; article.dataset.dcxHooksReady = "true";
renderArticle(article); renderArticle(article);
}); });
if (live === null) fetchLiveState();
}; };
const scheduleSync = () => { const scheduleSync = () => {
@@ -522,6 +755,21 @@ import RULES from '../../data/hook-rules.json';
syncFrame = requestAnimationFrame(initializeMountedArticles); 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) => { document.addEventListener("click", (event) => {
const article = event.target.closest('.dcx-article[data-dcx-category="hooks"]'); const article = event.target.closest('.dcx-article[data-dcx-category="hooks"]');
if (!article) return; if (!article) return;
@@ -536,23 +784,24 @@ import RULES from '../../data/hook-rules.json';
const family = event.target.closest("[data-hooks-family]"); const family = event.target.closest("[data-hooks-family]");
if (family) { if (family) {
const restoreFocus = family === document.activeElement; const restoreFocus = family === document.activeElement;
state.activeFamily = family.dataset.hooksFamily; view.activeFamily = family.dataset.hooksFamily;
persist(); persistView();
renderFamilies(article); renderFamilies(article);
renderRules(article); renderRules(article);
if (restoreFocus) { if (restoreFocus) {
article.querySelector(`[data-hooks-family="${state.activeFamily}"]`)?.focus({ preventScroll: true }); article.querySelector(`[data-hooks-family="${view.activeFamily}"]`)?.focus({ preventScroll: true });
} }
return; return;
} }
const add = event.target.closest("[data-hooks-add]"); const add = event.target.closest("[data-hooks-add]");
if (add) { if (add && interactive()) {
const form = article.querySelector("[data-hooks-form]"); const form = article.querySelector("[data-hooks-form]");
if (!form) return; if (!form) return;
syncKindFields(form);
setCustomFormOpen(form, true); setCustomFormOpen(form, true);
add.setAttribute("aria-expanded", "true"); add.setAttribute("aria-expanded", "true");
form.querySelector("input[name='name']")?.focus(); form.querySelector("[data-hooks-kind]")?.focus();
return; return;
} }
@@ -567,14 +816,33 @@ import RULES from '../../data/hook-rules.json';
return; return;
} }
const remove = event.target.closest("[data-hooks-remove]"); const removeValue = event.target.closest("[data-hooks-remove-value]");
if (remove) { if (removeValue && interactive()) {
state.custom = state.custom.filter((rule) => rule.id !== remove.dataset.hooksRemove); draft.ignoreValues.splice(Number(removeValue.dataset.hooksRemoveValue), 1);
persist(); renderAll();
renderCustom(article); (article.querySelector(".dcx-hooks-remove") || article.querySelector("[data-hooks-add]"))
(article.querySelector("[data-hooks-remove]") || article.querySelector("[data-hooks-add]"))
?.focus({ preventScroll: true }); ?.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) => { document.addEventListener("input", (event) => {
@@ -609,40 +877,40 @@ import RULES from '../../data/hook-rules.json';
? buttons.length - 1 ? buttons.length - 1
: (index + direction + buttons.length) % buttons.length; : (index + direction + buttons.length) % buttons.length;
buttons[nextIndex].click(); buttons[nextIndex].click();
article.querySelector(`[data-hooks-family="${state.activeFamily}"]`)?.focus(); article.querySelector(`[data-hooks-family="${view.activeFamily}"]`)?.focus();
}); });
document.addEventListener("change", (event) => { document.addEventListener("change", (event) => {
const article = event.target.closest('.dcx-article[data-dcx-category="hooks"]'); const article = event.target.closest('.dcx-article[data-dcx-category="hooks"]');
if (!article) return; 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]")) { if (event.target.matches("[data-hooks-master]")) {
state.enabled = event.target.checked; draft.enabled = event.target.checked;
persist();
syncMaster(article); syncMaster(article);
syncApplyBar(article);
return; return;
} }
if (event.target.matches("[data-hooks-rule]")) { if (event.target.matches("[data-hooks-rule]")) {
if (event.target.checked) disabledRules.delete(event.target.dataset.hooksRule); const id = event.target.dataset.hooksRule;
else disabledRules.add(event.target.dataset.hooksRule); if (event.target.checked) draft.ignoreRules = draft.ignoreRules.filter((entry) => entry !== id);
persist(); else if (!draft.ignoreRules.includes(id)) draft.ignoreRules.push(id);
renderFamilies(article); renderFamilies(article);
syncApplyBar(article);
const query = article.querySelector("[data-hooks-search]")?.value.trim(); const query = article.querySelector("[data-hooks-search]")?.value.trim();
const summary = article.querySelector("[data-hooks-summary]"); const summary = article.querySelector("[data-hooks-summary]");
if (!query && summary) { if (!query && summary) {
const rules = familyRules(state.activeFamily); const rules = familyRules(view.activeFamily);
summary.textContent = `${rules.filter((rule) => isEnabled(rule.id)).length} of ${rules.length} selected`; 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();
}
} }
}); });
@@ -651,30 +919,37 @@ import RULES from '../../data/hook-rules.json';
if (!form) return; if (!form) return;
event.preventDefault(); event.preventDefault();
const article = form.closest('.dcx-article[data-dcx-category="hooks"]'); const article = form.closest('.dcx-article[data-dcx-category="hooks"]');
if (!article) return; if (!article || !interactive()) return;
const data = new FormData(form); const data = new FormData(form);
const name = String(data.get("name") || "").trim(); const kind = String(data.get("kind") || "value");
const description = String(data.get("description") || "").trim(); const rule = String(data.get("rule") || "").trim();
const discipline = String(data.get("discipline") || "Visual Details"); const value = String(data.get("value") || "").trim();
if (!name || !description) return; const files = parseGlobList(data.get("files") || "");
const reason = String(data.get("reason") || "").trim();
const base = slugify(name); if (kind === "file") {
let id = base; if (!files.length) return;
let suffix = 2; files.forEach((glob) => {
const existing = new Set([...RULES.map((rule) => rule.id), ...state.custom.map((rule) => rule.id)]); if (!draft.ignoreFiles.includes(glob)) draft.ignoreFiles.push(glob);
while (existing.has(id)) { });
id = `${base}-${suffix}`; } else {
suffix += 1; 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);
} }
state.custom.push({ id, name, description, discipline, enabled: true });
persist();
form.reset(); form.reset();
syncKindFields(form);
setCustomFormOpen(form, false); setCustomFormOpen(form, false);
const addButton = article.querySelector("[data-hooks-add]"); const addButton = article.querySelector("[data-hooks-add]");
addButton?.setAttribute("aria-expanded", "false"); addButton?.setAttribute("aria-expanded", "false");
renderCustom(article); renderAll();
addButton?.focus({ preventScroll: true }); addButton?.focus({ preventScroll: true });
}); });
+2 -3
View File
@@ -30,9 +30,8 @@
const settingsIcon = ` const settingsIcon = `
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2.8v2.1M12 19.1v2.1M4.2 7.3l1.8 1M18 15.7l1.8 1M2.9 15.2l2-.7M19.1 9.5l2-.7M7.2 3.7l1.1 1.8M15.7 18.5l1.1 1.8"></path> <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>
<circle cx="12" cy="12" r="5.1"></circle> <circle cx="12" cy="12" r="3"></circle>
<circle cx="12" cy="12" r="1.75"></circle>
</svg>`; </svg>`;
const closeIcon = ` const closeIcon = `
+75
View File
@@ -630,3 +630,78 @@
transition: none; transition: none;
} }
} }
/* ============================================================
Live wiring: the exceptions form's kind-driven fields and the
Apply bar. State loads from and lands in the project's
.impeccable/config.json through the doc session; the bar shows
only while the draft and the project disagree.
============================================================ */
.dcx-detail-article--hooks .dcx-hooks-custom-form label[hidden] {
display: none;
}
.dcx-detail-article--hooks .dcx-hooks-kind-hint {
grid-column: 1 / -1;
margin: -6px 0 0;
font-size: 0.82rem;
line-height: 1.5;
color: var(--ks-text-muted);
}
.dcx-detail-article--hooks .dcx-hooks-custom-reason {
margin: 2px 0 0;
font-size: 0.82rem;
font-style: italic;
color: var(--ks-text-faint);
}
.dcx-detail-article--hooks .dcx-hooks-switch input:disabled + span,
.dcx-detail-article--hooks .dcx-hooks-button:disabled {
opacity: 0.45;
cursor: default;
}
.dcx-detail-article--hooks .dcx-hooks-applybar {
position: sticky;
bottom: 18px;
z-index: 4;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 26px;
padding: 14px 18px;
border: 1px solid var(--ks-rule);
border-radius: 12px;
background: var(--ks-graphite-2);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.35);
}
.dcx-detail-article--hooks .dcx-hooks-applybar[hidden] {
display: none;
}
.dcx-detail-article--hooks .dcx-hooks-applybar-copy {
margin: 0;
font-size: 0.88rem;
color: var(--ks-text);
}
.dcx-detail-article--hooks .dcx-hooks-applybar-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
@media (max-width: 560px) {
.dcx-detail-article--hooks .dcx-hooks-applybar {
flex-direction: column;
align-items: stretch;
text-align: center;
}
.dcx-detail-article--hooks .dcx-hooks-applybar-actions .dcx-hooks-button {
flex: 1;
}
}
+1
View File
@@ -102,6 +102,7 @@ node {{scripts_path}}/hook-admin.mjs ignore-file "src/legacy/Card.tsx"
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched. - Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing. - Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- The design context document's Hooks page reads and writes this same config through `hook-admin.mjs` (`state` and `apply`, its machine channel, called by the doc session); those two verbs are not part of this command's routing. Entries it wrote are user decisions: the person pressed Apply in the page, so treat them like any user-made config.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders. - Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly. - The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
+142 -1
View File
@@ -38,7 +38,7 @@ import {
extractFindingIgnoreValue, extractFindingIgnoreValue,
} from './hook-lib.mjs'; } from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']); const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset', 'state', 'apply']);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [ const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs', 'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs', 'skills/impeccable/scripts/hook.mjs',
@@ -788,6 +788,145 @@ function reset(cwd) {
return parts.length ? parts.join(' ') : 'No hook config or cache to remove. Already at defaults.'; return parts.length ? parts.join(' ') : 'No hook config or cache to remove. Already at defaults.';
} }
/* ============================================================
The design context document's machine channel.
`state` prints the shared-scope hook state as one JSON object; `apply`
reads the full desired state from stdin as JSON and writes it exactly.
The document's Hooks page is the caller, through the doc session; the
page shows the user every entry it read, so what it sends back is the
whole managed set and removals are as deliberate as additions. The
union-merging writers above cannot express a removal, which is why
`apply` writes the managed detector keys wholesale; unmanaged keys
(designSystem, advisoryRules, extensions) survive untouched, and the
hook section keeps every field the UI does not manage. An apply may
carry a `baseline` field: the state a previous read returned. When the
project no longer matches it, the write is refused, so an entry another
writer added after that read is never silently clobbered.
============================================================ */
function uiState(cwd) {
const detector = readRawDetectorConfig(cwd) || mergeDetectorConfig(null);
const hook = readRawHookConfig(cwd);
return {
enabled: !(hook && hook.enabled === false),
ignoreRules: Array.isArray(detector.ignoreRules) ? detector.ignoreRules : [],
ignoreFiles: Array.isArray(detector.ignoreFiles) ? detector.ignoreFiles : [],
ignoreValues: normalizeIgnoreValueEntries(detector.ignoreValues || []),
};
}
const APPLY_LIST_LIMIT = 200;
function cleanStringList(value, label) {
if (value === undefined) return [];
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
if (value.length > APPLY_LIST_LIMIT) throw new Error(`${label} holds more than ${APPLY_LIST_LIMIT} entries`);
const out = [];
for (const entry of value) {
if (typeof entry !== 'string' || !entry.trim()) throw new Error(`${label} entries must be non-empty strings`);
if (entry.length > 400) throw new Error(`${label} entry exceeds 400 characters`);
if (!out.includes(entry.trim())) out.push(entry.trim());
}
return out;
}
function cleanIgnoreValues(value) {
if (value === undefined) return [];
if (!Array.isArray(value)) throw new Error('ignoreValues must be an array');
if (value.length > APPLY_LIST_LIMIT) throw new Error(`ignoreValues holds more than ${APPLY_LIST_LIMIT} entries`);
const out = [];
for (const entry of value) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) throw new Error('ignoreValues entries must be objects');
const rule = typeof entry.rule === 'string' ? entry.rule.trim() : '';
const val = typeof entry.value === 'string' ? entry.value.trim() : '';
if (!rule || !val) throw new Error('ignoreValues entries need a rule and a value');
const clean = { rule, value: val };
if (entry.files !== undefined) {
const files = cleanStringList(entry.files, 'ignoreValues files');
if (files.length > 0) clean.files = files;
}
if (val === '*' && !clean.files) throw new Error('a "*" value needs a files scope; use ignoreRules for project-wide');
if (typeof entry.reason === 'string' && entry.reason.trim()) clean.reason = entry.reason.trim().slice(0, 400);
out.push(clean);
}
return normalizeIgnoreValueEntries(out);
}
// Exact-set write for the three managed detector keys. Unlike
// writeDetectorConfig this does not union with what is on disk: the caller
// read the full state first and hands back the complete set, so an entry
// missing from the payload is a removal, not an oversight.
function setDetectorExact(cwd, desired) {
const filePath = getConfigPath(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const nextHook = stripDetectorKeys(hookSection(existing));
const existingDetector = detectorSection(existing) || {};
const next = {
...existing,
detector: {
...existingDetector,
ignoreRules: desired.ignoreRules,
ignoreFiles: desired.ignoreFiles,
ignoreValues: desired.ignoreValues,
},
};
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
else delete next.hook;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
}
// Canonical projection for the baseline comparison: order-stable and
// validation-free, because a baseline is a previous read echoed back and
// cleaning it could reject entries that are already on disk.
function canonState(state) {
if (!state || typeof state !== 'object' || Array.isArray(state)) return null;
const list = (value) => (Array.isArray(value) ? value.map(String) : []);
const values = Array.isArray(state.ignoreValues)
? state.ignoreValues.map((entry) => [
String(entry?.rule ?? ''),
String(entry?.value ?? ''),
Array.isArray(entry?.files) ? entry.files.map(String) : null,
typeof entry?.reason === 'string' ? entry.reason : null,
])
: [];
return JSON.stringify([state.enabled === true, list(state.ignoreRules), list(state.ignoreFiles), values]);
}
function applyUiState(cwd) {
let payload;
try {
payload = JSON.parse(fs.readFileSync(0, 'utf-8'));
} catch {
throw new Error('apply reads one JSON object from stdin');
}
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('apply reads one JSON object from stdin');
}
if (payload.enabled !== undefined && typeof payload.enabled !== 'boolean') {
throw new Error('enabled must be a boolean');
}
if (payload.baseline !== undefined) {
const baseline = canonState(payload.baseline);
if (baseline === null) throw new Error('baseline must be the state object a previous read returned');
if (baseline !== canonState(uiState(cwd))) {
throw new Error('the hook config changed on disk after this state was read; read it again and reapply');
}
}
const desired = {
ignoreRules: cleanStringList(payload.ignoreRules, 'ignoreRules'),
ignoreFiles: cleanStringList(payload.ignoreFiles, 'ignoreFiles'),
ignoreValues: cleanIgnoreValues(payload.ignoreValues),
};
if (typeof payload.enabled === 'boolean' && payload.enabled !== uiState(cwd).enabled) {
setEnabled(cwd, payload.enabled);
}
setDetectorExact(cwd, desired);
return JSON.stringify(uiState(cwd));
}
function main() { function main() {
const [, , actionArg, ...rest] = process.argv; const [, , actionArg, ...rest] = process.argv;
const action = (actionArg || 'status').toLowerCase(); const action = (actionArg || 'status').toLowerCase();
@@ -808,6 +947,8 @@ function main() {
case 'ignore-file': out = addIgnoreFile(cwd, rest); break; case 'ignore-file': out = addIgnoreFile(cwd, rest); break;
case 'ignore-value': out = addIgnoreValue(cwd, rest); break; case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
case 'reset': out = reset(cwd); break; case 'reset': out = reset(cwd); break;
case 'state': out = JSON.stringify(uiState(cwd)); break;
case 'apply': out = applyUiState(cwd); break;
} }
process.stdout.write(out + '\n'); process.stdout.write(out + '\n');
} catch (err) { } catch (err) {
+32
View File
@@ -822,6 +822,38 @@ test('doc session reads and applies hook state, token-gated', async () => {
assert.deepEqual(clearedConfig.detector.ignoreRules, []); assert.deepEqual(clearedConfig.detector.ignoreRules, []);
assert.deepEqual(clearedConfig.detector.ignoreValues, []); assert.deepEqual(clearedConfig.detector.ignoreValues, []);
// A stale baseline is refused, protecting entries another writer added
// after the page read its state; the fresh state then applies cleanly.
const before = (await (await fetch(`${base}/doc/hooks?token=t-hooks`)).json()).state;
const configPath = path.join(fixture.cwd, '.impeccable/config.json');
const drifted = JSON.parse(await readFile(configPath, 'utf8'));
drifted.detector.ignoreRules = ['kicker-above-heading'];
await writeFile(configPath, `${JSON.stringify(drifted, null, 2)}\n`);
const conflicted = await fetch(`${base}/doc/hooks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: 't-hooks',
state: { enabled: false, ignoreRules: ['side-tab'], ignoreFiles: [], ignoreValues: [], baseline: before },
}),
});
assert.equal(conflicted.status, 400);
assert.match((await conflicted.json()).error, /hook config changed on disk/);
const untouched = JSON.parse(await readFile(configPath, 'utf8'));
assert.deepEqual(untouched.detector.ignoreRules, ['kicker-above-heading']);
const rebasedState = (await (await fetch(`${base}/doc/hooks?token=t-hooks`)).json()).state;
const accepted = await fetch(`${base}/doc/hooks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: 't-hooks',
state: { ...rebasedState, ignoreRules: [...rebasedState.ignoreRules, 'side-tab'], baseline: rebasedState },
}),
});
assert.equal(accepted.status, 200);
const merged = JSON.parse(await readFile(configPath, 'utf8'));
assert.deepEqual(merged.detector.ignoreRules, ['kicker-above-heading', 'side-tab']);
// The gate and the validation hold. // The gate and the validation hold.
assert.equal((await fetch(`${base}/doc/hooks`)).status, 403); assert.equal((await fetch(`${base}/doc/hooks`)).status, 403);
assert.equal((await fetch(`${base}/doc/hooks?token=wrong`)).status, 403); assert.equal((await fetch(`${base}/doc/hooks?token=wrong`)).status, 403);