mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 09:36:59 +03:00
update
This commit is contained in:
+413
-138
@@ -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("<", "<")
|
.replaceAll("<", "<")
|
||||||
@@ -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’s settings…</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 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) => {
|
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 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 = () => {
|
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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user