Improve hook false-positive handling

This commit is contained in:
Paul Bakaus
2026-06-15 13:30:28 +09:00
parent a9c15481a9
commit 858b9bbea6
90 changed files with 1072 additions and 416 deletions
+7 -1
View File
@@ -46,7 +46,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
@@ -57,6 +57,12 @@ Example value-specific exception:
node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
```bash
node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
```
Example whole-rule font exception:
```bash
@@ -1084,9 +1084,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -110,9 +110,14 @@ const REGEX_MATCHERS = [
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
test: () => true,
fmt: (m) => m[0] },
fmt: (m) => {
const token = m[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
return `animation: ${token || m[1].trim()}`;
} },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
@@ -514,9 +514,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -333,8 +333,8 @@ function isInsideProject(filePath, cwd) {
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
'[impeccable@1] Required design corrections',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Required design corrections',
'[impeccable@1] Design hook findings requiring review',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
);
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
}
+40 -16
View File
@@ -453,16 +453,22 @@ function isIgnoredFindingValue(finding, ignoreValues) {
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
if (rule !== 'overused-font') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding));
if (rule !== 'overused-font' && rule !== 'bounce-easing') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding) {
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
@@ -482,6 +488,24 @@ function extractFindingIgnoreValueRaw(finding) {
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
@@ -524,7 +548,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
const shown = findings.slice(0, cap);
const remaining = total - shown.length;
const header = `${ENVELOPE_PREFIX} Required design corrections in ${display} (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
const lines = shown.map((f) => formatFindingLine(f));
const more = remaining > 0
? `... and ${remaining} more (see /impeccable audit).`
@@ -556,7 +580,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
const cwd = opts.cwd || process.cwd();
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
const header = `${ENVELOPE_PREFIX} Required design corrections across ${realGroups.length} files (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
const lines = [];
let shownCount = 0;
@@ -968,7 +992,7 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
// `knownFindings` here are the cache strings like "side-tab:3".
const sample = knownFindings.slice(0, 3).join(', ');
const more = count > 3 ? `, +${count - 3} more` : '';
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} issue(s) flagged earlier this session (${sample}${more}). Address them before finalizing — the previous reminder still applies.`;
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
}
export function shouldEmitAckForFile(filePath) {
@@ -977,28 +1001,28 @@ export function shouldEmitAckForFile(filePath) {
// The directive footer is the part of the hook output that steers model
// behavior. Three intentional moves:
// 1. **Imperative, not advisory.** "Fix these..." beats "Consider
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
// revising..." which the model treats as a soft suggestion it can
// override when the user asked for any kind of throwaway / demo UI.
// 2. **Explicit exception clause.** Without it, the model will try to
// "fix" intentional bad fixtures, anti-pattern examples in docs, or
// test cases. Naming the exception inline beats hoping the model
// infers it from context.
// 2. **Explicit judgment clause.** Without it, the model will try to
// "fix" intentional motion, bad fixtures, anti-pattern examples in
// docs, or test cases. Naming the judgment inline beats hoping the
// model infers it from context.
// 3. **Acknowledgement instruction.** Hook output is injected as
// developer-role context, not a chat turn, so the user never sees the
// raw envelope. Asking the model to surface the fix in its reply is
// the cheapest way to make the feedback loop visible to the user.
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
const fileIgnoreGuidance = opts.grouped
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
: `run \`${ignoreFileCommand}\``;
return [
'Fix these in your next reply before finalizing. Acknowledge what you changed so the user sees the correction.',
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Skip the fix only if the user explicitly asked for an intentionally bad UI, an anti-pattern example, a test fixture, or documentation of bad design. In that case, say so and continue.',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Do not add hook ignores unless the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
].join('\n');
}
+7 -1
View File
@@ -46,7 +46,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
@@ -57,6 +57,12 @@ Example value-specific exception:
node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
```bash
node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
```
Example whole-rule font exception:
```bash
@@ -1084,9 +1084,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -110,9 +110,14 @@ const REGEX_MATCHERS = [
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
test: () => true,
fmt: (m) => m[0] },
fmt: (m) => {
const token = m[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
return `animation: ${token || m[1].trim()}`;
} },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
@@ -514,9 +514,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -333,8 +333,8 @@ function isInsideProject(filePath, cwd) {
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
'[impeccable@1] Required design corrections',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Required design corrections',
'[impeccable@1] Design hook findings requiring review',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
);
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
}
+40 -16
View File
@@ -453,16 +453,22 @@ function isIgnoredFindingValue(finding, ignoreValues) {
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
if (rule !== 'overused-font') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding));
if (rule !== 'overused-font' && rule !== 'bounce-easing') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding) {
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
@@ -482,6 +488,24 @@ function extractFindingIgnoreValueRaw(finding) {
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
@@ -524,7 +548,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
const shown = findings.slice(0, cap);
const remaining = total - shown.length;
const header = `${ENVELOPE_PREFIX} Required design corrections in ${display} (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
const lines = shown.map((f) => formatFindingLine(f));
const more = remaining > 0
? `... and ${remaining} more (see /impeccable audit).`
@@ -556,7 +580,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
const cwd = opts.cwd || process.cwd();
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
const header = `${ENVELOPE_PREFIX} Required design corrections across ${realGroups.length} files (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
const lines = [];
let shownCount = 0;
@@ -968,7 +992,7 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
// `knownFindings` here are the cache strings like "side-tab:3".
const sample = knownFindings.slice(0, 3).join(', ');
const more = count > 3 ? `, +${count - 3} more` : '';
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} issue(s) flagged earlier this session (${sample}${more}). Address them before finalizing — the previous reminder still applies.`;
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
}
export function shouldEmitAckForFile(filePath) {
@@ -977,28 +1001,28 @@ export function shouldEmitAckForFile(filePath) {
// The directive footer is the part of the hook output that steers model
// behavior. Three intentional moves:
// 1. **Imperative, not advisory.** "Fix these..." beats "Consider
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
// revising..." which the model treats as a soft suggestion it can
// override when the user asked for any kind of throwaway / demo UI.
// 2. **Explicit exception clause.** Without it, the model will try to
// "fix" intentional bad fixtures, anti-pattern examples in docs, or
// test cases. Naming the exception inline beats hoping the model
// infers it from context.
// 2. **Explicit judgment clause.** Without it, the model will try to
// "fix" intentional motion, bad fixtures, anti-pattern examples in
// docs, or test cases. Naming the judgment inline beats hoping the
// model infers it from context.
// 3. **Acknowledgement instruction.** Hook output is injected as
// developer-role context, not a chat turn, so the user never sees the
// raw envelope. Asking the model to surface the fix in its reply is
// the cheapest way to make the feedback loop visible to the user.
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
const fileIgnoreGuidance = opts.grouped
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
: `run \`${ignoreFileCommand}\``;
return [
'Fix these in your next reply before finalizing. Acknowledge what you changed so the user sees the correction.',
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Skip the fix only if the user explicitly asked for an intentionally bad UI, an anti-pattern example, a test fixture, or documentation of bad design. In that case, say so and continue.',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Do not add hook ignores unless the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
].join('\n');
}
+7 -1
View File
@@ -46,7 +46,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
@@ -57,6 +57,12 @@ Example value-specific exception:
node .cursor/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
```bash
node .cursor/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
```
Example whole-rule font exception:
```bash
@@ -1084,9 +1084,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -110,9 +110,14 @@ const REGEX_MATCHERS = [
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
test: () => true,
fmt: (m) => m[0] },
fmt: (m) => {
const token = m[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
return `animation: ${token || m[1].trim()}`;
} },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
@@ -514,9 +514,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -333,8 +333,8 @@ function isInsideProject(filePath, cwd) {
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
'[impeccable@1] Required design corrections',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Required design corrections',
'[impeccable@1] Design hook findings requiring review',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
);
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
}
+40 -16
View File
@@ -453,16 +453,22 @@ function isIgnoredFindingValue(finding, ignoreValues) {
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
if (rule !== 'overused-font') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding));
if (rule !== 'overused-font' && rule !== 'bounce-easing') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding) {
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
@@ -482,6 +488,24 @@ function extractFindingIgnoreValueRaw(finding) {
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
@@ -524,7 +548,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
const shown = findings.slice(0, cap);
const remaining = total - shown.length;
const header = `${ENVELOPE_PREFIX} Required design corrections in ${display} (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
const lines = shown.map((f) => formatFindingLine(f));
const more = remaining > 0
? `... and ${remaining} more (see /impeccable audit).`
@@ -556,7 +580,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
const cwd = opts.cwd || process.cwd();
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
const header = `${ENVELOPE_PREFIX} Required design corrections across ${realGroups.length} files (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
const lines = [];
let shownCount = 0;
@@ -968,7 +992,7 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
// `knownFindings` here are the cache strings like "side-tab:3".
const sample = knownFindings.slice(0, 3).join(', ');
const more = count > 3 ? `, +${count - 3} more` : '';
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} issue(s) flagged earlier this session (${sample}${more}). Address them before finalizing — the previous reminder still applies.`;
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
}
export function shouldEmitAckForFile(filePath) {
@@ -977,28 +1001,28 @@ export function shouldEmitAckForFile(filePath) {
// The directive footer is the part of the hook output that steers model
// behavior. Three intentional moves:
// 1. **Imperative, not advisory.** "Fix these..." beats "Consider
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
// revising..." which the model treats as a soft suggestion it can
// override when the user asked for any kind of throwaway / demo UI.
// 2. **Explicit exception clause.** Without it, the model will try to
// "fix" intentional bad fixtures, anti-pattern examples in docs, or
// test cases. Naming the exception inline beats hoping the model
// infers it from context.
// 2. **Explicit judgment clause.** Without it, the model will try to
// "fix" intentional motion, bad fixtures, anti-pattern examples in
// docs, or test cases. Naming the judgment inline beats hoping the
// model infers it from context.
// 3. **Acknowledgement instruction.** Hook output is injected as
// developer-role context, not a chat turn, so the user never sees the
// raw envelope. Asking the model to surface the fix in its reply is
// the cheapest way to make the feedback loop visible to the user.
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
const fileIgnoreGuidance = opts.grouped
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
: `run \`${ignoreFileCommand}\``;
return [
'Fix these in your next reply before finalizing. Acknowledge what you changed so the user sees the correction.',
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Skip the fix only if the user explicitly asked for an intentionally bad UI, an anti-pattern example, a test fixture, or documentation of bad design. In that case, say so and continue.',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Do not add hook ignores unless the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
].join('\n');
}
+7 -1
View File
@@ -46,7 +46,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
@@ -57,6 +57,12 @@ Example value-specific exception:
node .gemini/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
```bash
node .gemini/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
```
Example whole-rule font exception:
```bash
@@ -1084,9 +1084,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -110,9 +110,14 @@ const REGEX_MATCHERS = [
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
test: () => true,
fmt: (m) => m[0] },
fmt: (m) => {
const token = m[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
return `animation: ${token || m[1].trim()}`;
} },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
@@ -514,9 +514,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -333,8 +333,8 @@ function isInsideProject(filePath, cwd) {
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
'[impeccable@1] Required design corrections',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Required design corrections',
'[impeccable@1] Design hook findings requiring review',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
);
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
}
+40 -16
View File
@@ -453,16 +453,22 @@ function isIgnoredFindingValue(finding, ignoreValues) {
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
if (rule !== 'overused-font') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding));
if (rule !== 'overused-font' && rule !== 'bounce-easing') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding) {
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
@@ -482,6 +488,24 @@ function extractFindingIgnoreValueRaw(finding) {
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
@@ -524,7 +548,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
const shown = findings.slice(0, cap);
const remaining = total - shown.length;
const header = `${ENVELOPE_PREFIX} Required design corrections in ${display} (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
const lines = shown.map((f) => formatFindingLine(f));
const more = remaining > 0
? `... and ${remaining} more (see /impeccable audit).`
@@ -556,7 +580,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
const cwd = opts.cwd || process.cwd();
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
const header = `${ENVELOPE_PREFIX} Required design corrections across ${realGroups.length} files (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
const lines = [];
let shownCount = 0;
@@ -968,7 +992,7 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
// `knownFindings` here are the cache strings like "side-tab:3".
const sample = knownFindings.slice(0, 3).join(', ');
const more = count > 3 ? `, +${count - 3} more` : '';
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} issue(s) flagged earlier this session (${sample}${more}). Address them before finalizing — the previous reminder still applies.`;
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
}
export function shouldEmitAckForFile(filePath) {
@@ -977,28 +1001,28 @@ export function shouldEmitAckForFile(filePath) {
// The directive footer is the part of the hook output that steers model
// behavior. Three intentional moves:
// 1. **Imperative, not advisory.** "Fix these..." beats "Consider
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
// revising..." which the model treats as a soft suggestion it can
// override when the user asked for any kind of throwaway / demo UI.
// 2. **Explicit exception clause.** Without it, the model will try to
// "fix" intentional bad fixtures, anti-pattern examples in docs, or
// test cases. Naming the exception inline beats hoping the model
// infers it from context.
// 2. **Explicit judgment clause.** Without it, the model will try to
// "fix" intentional motion, bad fixtures, anti-pattern examples in
// docs, or test cases. Naming the judgment inline beats hoping the
// model infers it from context.
// 3. **Acknowledgement instruction.** Hook output is injected as
// developer-role context, not a chat turn, so the user never sees the
// raw envelope. Asking the model to surface the fix in its reply is
// the cheapest way to make the feedback loop visible to the user.
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
const fileIgnoreGuidance = opts.grouped
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
: `run \`${ignoreFileCommand}\``;
return [
'Fix these in your next reply before finalizing. Acknowledge what you changed so the user sees the correction.',
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Skip the fix only if the user explicitly asked for an intentionally bad UI, an anti-pattern example, a test fixture, or documentation of bad design. In that case, say so and continue.',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Do not add hook ignores unless the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
].join('\n');
}
+7 -1
View File
@@ -46,7 +46,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
@@ -57,6 +57,12 @@ Example value-specific exception:
node .github/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
```bash
node .github/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
```
Example whole-rule font exception:
```bash
@@ -1084,9 +1084,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -110,9 +110,14 @@ const REGEX_MATCHERS = [
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
test: () => true,
fmt: (m) => m[0] },
fmt: (m) => {
const token = m[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
return `animation: ${token || m[1].trim()}`;
} },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
@@ -514,9 +514,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -333,8 +333,8 @@ function isInsideProject(filePath, cwd) {
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
'[impeccable@1] Required design corrections',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Required design corrections',
'[impeccable@1] Design hook findings requiring review',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
);
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
}
+40 -16
View File
@@ -453,16 +453,22 @@ function isIgnoredFindingValue(finding, ignoreValues) {
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
if (rule !== 'overused-font') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding));
if (rule !== 'overused-font' && rule !== 'bounce-easing') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding) {
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
@@ -482,6 +488,24 @@ function extractFindingIgnoreValueRaw(finding) {
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
@@ -524,7 +548,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
const shown = findings.slice(0, cap);
const remaining = total - shown.length;
const header = `${ENVELOPE_PREFIX} Required design corrections in ${display} (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
const lines = shown.map((f) => formatFindingLine(f));
const more = remaining > 0
? `... and ${remaining} more (see /impeccable audit).`
@@ -556,7 +580,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
const cwd = opts.cwd || process.cwd();
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
const header = `${ENVELOPE_PREFIX} Required design corrections across ${realGroups.length} files (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
const lines = [];
let shownCount = 0;
@@ -968,7 +992,7 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
// `knownFindings` here are the cache strings like "side-tab:3".
const sample = knownFindings.slice(0, 3).join(', ');
const more = count > 3 ? `, +${count - 3} more` : '';
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} issue(s) flagged earlier this session (${sample}${more}). Address them before finalizing — the previous reminder still applies.`;
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
}
export function shouldEmitAckForFile(filePath) {
@@ -977,28 +1001,28 @@ export function shouldEmitAckForFile(filePath) {
// The directive footer is the part of the hook output that steers model
// behavior. Three intentional moves:
// 1. **Imperative, not advisory.** "Fix these..." beats "Consider
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
// revising..." which the model treats as a soft suggestion it can
// override when the user asked for any kind of throwaway / demo UI.
// 2. **Explicit exception clause.** Without it, the model will try to
// "fix" intentional bad fixtures, anti-pattern examples in docs, or
// test cases. Naming the exception inline beats hoping the model
// infers it from context.
// 2. **Explicit judgment clause.** Without it, the model will try to
// "fix" intentional motion, bad fixtures, anti-pattern examples in
// docs, or test cases. Naming the judgment inline beats hoping the
// model infers it from context.
// 3. **Acknowledgement instruction.** Hook output is injected as
// developer-role context, not a chat turn, so the user never sees the
// raw envelope. Asking the model to surface the fix in its reply is
// the cheapest way to make the feedback loop visible to the user.
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
const fileIgnoreGuidance = opts.grouped
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
: `run \`${ignoreFileCommand}\``;
return [
'Fix these in your next reply before finalizing. Acknowledge what you changed so the user sees the correction.',
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Skip the fix only if the user explicitly asked for an intentionally bad UI, an anti-pattern example, a test fixture, or documentation of bad design. In that case, say so and continue.',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Do not add hook ignores unless the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
].join('\n');
}
+8 -1
View File
@@ -7,7 +7,14 @@
"tests/detect-antipatterns.test.js",
"site/pages/slop/**"
],
"ignoreValues": [],
"ignoreValues": [
{
"rule": "bounce-easing",
"value": "bounce-ball",
"createdAt": "2026-06-15T04:15:03.164Z",
"reason": "User confirmed ball bounce animation is intentional"
}
],
"limits": {
"maxFindings": 5,
"maxChars": 8000
+7 -1
View File
@@ -46,7 +46,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
@@ -57,6 +57,12 @@ Example value-specific exception:
node .kiro/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
```bash
node .kiro/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
```
Example whole-rule font exception:
```bash
@@ -1084,9 +1084,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -110,9 +110,14 @@ const REGEX_MATCHERS = [
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
test: () => true,
fmt: (m) => m[0] },
fmt: (m) => {
const token = m[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
return `animation: ${token || m[1].trim()}`;
} },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
@@ -514,9 +514,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -333,8 +333,8 @@ function isInsideProject(filePath, cwd) {
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
'[impeccable@1] Required design corrections',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Required design corrections',
'[impeccable@1] Design hook findings requiring review',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
);
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
}
+40 -16
View File
@@ -453,16 +453,22 @@ function isIgnoredFindingValue(finding, ignoreValues) {
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
if (rule !== 'overused-font') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding));
if (rule !== 'overused-font' && rule !== 'bounce-easing') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding) {
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
@@ -482,6 +488,24 @@ function extractFindingIgnoreValueRaw(finding) {
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
@@ -524,7 +548,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
const shown = findings.slice(0, cap);
const remaining = total - shown.length;
const header = `${ENVELOPE_PREFIX} Required design corrections in ${display} (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
const lines = shown.map((f) => formatFindingLine(f));
const more = remaining > 0
? `... and ${remaining} more (see /impeccable audit).`
@@ -556,7 +580,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
const cwd = opts.cwd || process.cwd();
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
const header = `${ENVELOPE_PREFIX} Required design corrections across ${realGroups.length} files (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
const lines = [];
let shownCount = 0;
@@ -968,7 +992,7 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
// `knownFindings` here are the cache strings like "side-tab:3".
const sample = knownFindings.slice(0, 3).join(', ');
const more = count > 3 ? `, +${count - 3} more` : '';
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} issue(s) flagged earlier this session (${sample}${more}). Address them before finalizing — the previous reminder still applies.`;
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
}
export function shouldEmitAckForFile(filePath) {
@@ -977,28 +1001,28 @@ export function shouldEmitAckForFile(filePath) {
// The directive footer is the part of the hook output that steers model
// behavior. Three intentional moves:
// 1. **Imperative, not advisory.** "Fix these..." beats "Consider
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
// revising..." which the model treats as a soft suggestion it can
// override when the user asked for any kind of throwaway / demo UI.
// 2. **Explicit exception clause.** Without it, the model will try to
// "fix" intentional bad fixtures, anti-pattern examples in docs, or
// test cases. Naming the exception inline beats hoping the model
// infers it from context.
// 2. **Explicit judgment clause.** Without it, the model will try to
// "fix" intentional motion, bad fixtures, anti-pattern examples in
// docs, or test cases. Naming the judgment inline beats hoping the
// model infers it from context.
// 3. **Acknowledgement instruction.** Hook output is injected as
// developer-role context, not a chat turn, so the user never sees the
// raw envelope. Asking the model to surface the fix in its reply is
// the cheapest way to make the feedback loop visible to the user.
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
const fileIgnoreGuidance = opts.grouped
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
: `run \`${ignoreFileCommand}\``;
return [
'Fix these in your next reply before finalizing. Acknowledge what you changed so the user sees the correction.',
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Skip the fix only if the user explicitly asked for an intentionally bad UI, an anti-pattern example, a test fixture, or documentation of bad design. In that case, say so and continue.',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Do not add hook ignores unless the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
].join('\n');
}
@@ -46,7 +46,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
@@ -57,6 +57,12 @@ Example value-specific exception:
node .opencode/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
```bash
node .opencode/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
```
Example whole-rule font exception:
```bash
@@ -1084,9 +1084,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -110,9 +110,14 @@ const REGEX_MATCHERS = [
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
test: () => true,
fmt: (m) => m[0] },
fmt: (m) => {
const token = m[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
return `animation: ${token || m[1].trim()}`;
} },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
@@ -514,9 +514,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -333,8 +333,8 @@ function isInsideProject(filePath, cwd) {
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
'[impeccable@1] Required design corrections',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Required design corrections',
'[impeccable@1] Design hook findings requiring review',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
);
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
}
@@ -453,16 +453,22 @@ function isIgnoredFindingValue(finding, ignoreValues) {
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
if (rule !== 'overused-font') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding));
if (rule !== 'overused-font' && rule !== 'bounce-easing') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding) {
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
@@ -482,6 +488,24 @@ function extractFindingIgnoreValueRaw(finding) {
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
@@ -524,7 +548,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
const shown = findings.slice(0, cap);
const remaining = total - shown.length;
const header = `${ENVELOPE_PREFIX} Required design corrections in ${display} (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
const lines = shown.map((f) => formatFindingLine(f));
const more = remaining > 0
? `... and ${remaining} more (see /impeccable audit).`
@@ -556,7 +580,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
const cwd = opts.cwd || process.cwd();
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
const header = `${ENVELOPE_PREFIX} Required design corrections across ${realGroups.length} files (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
const lines = [];
let shownCount = 0;
@@ -968,7 +992,7 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
// `knownFindings` here are the cache strings like "side-tab:3".
const sample = knownFindings.slice(0, 3).join(', ');
const more = count > 3 ? `, +${count - 3} more` : '';
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} issue(s) flagged earlier this session (${sample}${more}). Address them before finalizing — the previous reminder still applies.`;
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
}
export function shouldEmitAckForFile(filePath) {
@@ -977,28 +1001,28 @@ export function shouldEmitAckForFile(filePath) {
// The directive footer is the part of the hook output that steers model
// behavior. Three intentional moves:
// 1. **Imperative, not advisory.** "Fix these..." beats "Consider
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
// revising..." which the model treats as a soft suggestion it can
// override when the user asked for any kind of throwaway / demo UI.
// 2. **Explicit exception clause.** Without it, the model will try to
// "fix" intentional bad fixtures, anti-pattern examples in docs, or
// test cases. Naming the exception inline beats hoping the model
// infers it from context.
// 2. **Explicit judgment clause.** Without it, the model will try to
// "fix" intentional motion, bad fixtures, anti-pattern examples in
// docs, or test cases. Naming the judgment inline beats hoping the
// model infers it from context.
// 3. **Acknowledgement instruction.** Hook output is injected as
// developer-role context, not a chat turn, so the user never sees the
// raw envelope. Asking the model to surface the fix in its reply is
// the cheapest way to make the feedback loop visible to the user.
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
const fileIgnoreGuidance = opts.grouped
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
: `run \`${ignoreFileCommand}\``;
return [
'Fix these in your next reply before finalizing. Acknowledge what you changed so the user sees the correction.',
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Skip the fix only if the user explicitly asked for an intentionally bad UI, an anti-pattern example, a test fixture, or documentation of bad design. In that case, say so and continue.',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Do not add hook ignores unless the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
].join('\n');
}
+7 -1
View File
@@ -46,7 +46,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
@@ -57,6 +57,12 @@ Example value-specific exception:
node .pi/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
```bash
node .pi/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
```
Example whole-rule font exception:
```bash
@@ -1084,9 +1084,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -110,9 +110,14 @@ const REGEX_MATCHERS = [
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
test: () => true,
fmt: (m) => m[0] },
fmt: (m) => {
const token = m[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
return `animation: ${token || m[1].trim()}`;
} },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
@@ -514,9 +514,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -333,8 +333,8 @@ function isInsideProject(filePath, cwd) {
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
'[impeccable@1] Required design corrections',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Required design corrections',
'[impeccable@1] Design hook findings requiring review',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
);
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
}
+40 -16
View File
@@ -453,16 +453,22 @@ function isIgnoredFindingValue(finding, ignoreValues) {
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
if (rule !== 'overused-font') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding));
if (rule !== 'overused-font' && rule !== 'bounce-easing') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding) {
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
@@ -482,6 +488,24 @@ function extractFindingIgnoreValueRaw(finding) {
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
@@ -524,7 +548,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
const shown = findings.slice(0, cap);
const remaining = total - shown.length;
const header = `${ENVELOPE_PREFIX} Required design corrections in ${display} (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
const lines = shown.map((f) => formatFindingLine(f));
const more = remaining > 0
? `... and ${remaining} more (see /impeccable audit).`
@@ -556,7 +580,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
const cwd = opts.cwd || process.cwd();
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
const header = `${ENVELOPE_PREFIX} Required design corrections across ${realGroups.length} files (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
const lines = [];
let shownCount = 0;
@@ -968,7 +992,7 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
// `knownFindings` here are the cache strings like "side-tab:3".
const sample = knownFindings.slice(0, 3).join(', ');
const more = count > 3 ? `, +${count - 3} more` : '';
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} issue(s) flagged earlier this session (${sample}${more}). Address them before finalizing — the previous reminder still applies.`;
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
}
export function shouldEmitAckForFile(filePath) {
@@ -977,28 +1001,28 @@ export function shouldEmitAckForFile(filePath) {
// The directive footer is the part of the hook output that steers model
// behavior. Three intentional moves:
// 1. **Imperative, not advisory.** "Fix these..." beats "Consider
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
// revising..." which the model treats as a soft suggestion it can
// override when the user asked for any kind of throwaway / demo UI.
// 2. **Explicit exception clause.** Without it, the model will try to
// "fix" intentional bad fixtures, anti-pattern examples in docs, or
// test cases. Naming the exception inline beats hoping the model
// infers it from context.
// 2. **Explicit judgment clause.** Without it, the model will try to
// "fix" intentional motion, bad fixtures, anti-pattern examples in
// docs, or test cases. Naming the judgment inline beats hoping the
// model infers it from context.
// 3. **Acknowledgement instruction.** Hook output is injected as
// developer-role context, not a chat turn, so the user never sees the
// raw envelope. Asking the model to surface the fix in its reply is
// the cheapest way to make the feedback loop visible to the user.
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
const fileIgnoreGuidance = opts.grouped
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
: `run \`${ignoreFileCommand}\``;
return [
'Fix these in your next reply before finalizing. Acknowledge what you changed so the user sees the correction.',
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Skip the fix only if the user explicitly asked for an intentionally bad UI, an anti-pattern example, a test fixture, or documentation of bad design. In that case, say so and continue.',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Do not add hook ignores unless the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
].join('\n');
}
+7 -1
View File
@@ -46,7 +46,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
@@ -57,6 +57,12 @@ Example value-specific exception:
node .qoder/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
```bash
node .qoder/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
```
Example whole-rule font exception:
```bash
@@ -1084,9 +1084,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -110,9 +110,14 @@ const REGEX_MATCHERS = [
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
test: () => true,
fmt: (m) => m[0] },
fmt: (m) => {
const token = m[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
return `animation: ${token || m[1].trim()}`;
} },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
@@ -514,9 +514,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -333,8 +333,8 @@ function isInsideProject(filePath, cwd) {
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
'[impeccable@1] Required design corrections',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Required design corrections',
'[impeccable@1] Design hook findings requiring review',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
);
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
}
+40 -16
View File
@@ -453,16 +453,22 @@ function isIgnoredFindingValue(finding, ignoreValues) {
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
if (rule !== 'overused-font') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding));
if (rule !== 'overused-font' && rule !== 'bounce-easing') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding) {
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
@@ -482,6 +488,24 @@ function extractFindingIgnoreValueRaw(finding) {
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
@@ -524,7 +548,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
const shown = findings.slice(0, cap);
const remaining = total - shown.length;
const header = `${ENVELOPE_PREFIX} Required design corrections in ${display} (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
const lines = shown.map((f) => formatFindingLine(f));
const more = remaining > 0
? `... and ${remaining} more (see /impeccable audit).`
@@ -556,7 +580,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
const cwd = opts.cwd || process.cwd();
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
const header = `${ENVELOPE_PREFIX} Required design corrections across ${realGroups.length} files (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
const lines = [];
let shownCount = 0;
@@ -968,7 +992,7 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
// `knownFindings` here are the cache strings like "side-tab:3".
const sample = knownFindings.slice(0, 3).join(', ');
const more = count > 3 ? `, +${count - 3} more` : '';
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} issue(s) flagged earlier this session (${sample}${more}). Address them before finalizing — the previous reminder still applies.`;
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
}
export function shouldEmitAckForFile(filePath) {
@@ -977,28 +1001,28 @@ export function shouldEmitAckForFile(filePath) {
// The directive footer is the part of the hook output that steers model
// behavior. Three intentional moves:
// 1. **Imperative, not advisory.** "Fix these..." beats "Consider
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
// revising..." which the model treats as a soft suggestion it can
// override when the user asked for any kind of throwaway / demo UI.
// 2. **Explicit exception clause.** Without it, the model will try to
// "fix" intentional bad fixtures, anti-pattern examples in docs, or
// test cases. Naming the exception inline beats hoping the model
// infers it from context.
// 2. **Explicit judgment clause.** Without it, the model will try to
// "fix" intentional motion, bad fixtures, anti-pattern examples in
// docs, or test cases. Naming the judgment inline beats hoping the
// model infers it from context.
// 3. **Acknowledgement instruction.** Hook output is injected as
// developer-role context, not a chat turn, so the user never sees the
// raw envelope. Asking the model to surface the fix in its reply is
// the cheapest way to make the feedback loop visible to the user.
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
const fileIgnoreGuidance = opts.grouped
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
: `run \`${ignoreFileCommand}\``;
return [
'Fix these in your next reply before finalizing. Acknowledge what you changed so the user sees the correction.',
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Skip the fix only if the user explicitly asked for an intentionally bad UI, an anti-pattern example, a test fixture, or documentation of bad design. In that case, say so and continue.',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Do not add hook ignores unless the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
].join('\n');
}
@@ -46,7 +46,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
@@ -57,6 +57,12 @@ Example value-specific exception:
node .rovodev/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
```bash
node .rovodev/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
```
Example whole-rule font exception:
```bash
@@ -1084,9 +1084,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -110,9 +110,14 @@ const REGEX_MATCHERS = [
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
test: () => true,
fmt: (m) => m[0] },
fmt: (m) => {
const token = m[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
return `animation: ${token || m[1].trim()}`;
} },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
@@ -514,9 +514,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -333,8 +333,8 @@ function isInsideProject(filePath, cwd) {
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
'[impeccable@1] Required design corrections',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Required design corrections',
'[impeccable@1] Design hook findings requiring review',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
);
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
}
+40 -16
View File
@@ -453,16 +453,22 @@ function isIgnoredFindingValue(finding, ignoreValues) {
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
if (rule !== 'overused-font') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding));
if (rule !== 'overused-font' && rule !== 'bounce-easing') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding) {
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
@@ -482,6 +488,24 @@ function extractFindingIgnoreValueRaw(finding) {
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
@@ -524,7 +548,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
const shown = findings.slice(0, cap);
const remaining = total - shown.length;
const header = `${ENVELOPE_PREFIX} Required design corrections in ${display} (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
const lines = shown.map((f) => formatFindingLine(f));
const more = remaining > 0
? `... and ${remaining} more (see /impeccable audit).`
@@ -556,7 +580,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
const cwd = opts.cwd || process.cwd();
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
const header = `${ENVELOPE_PREFIX} Required design corrections across ${realGroups.length} files (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
const lines = [];
let shownCount = 0;
@@ -968,7 +992,7 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
// `knownFindings` here are the cache strings like "side-tab:3".
const sample = knownFindings.slice(0, 3).join(', ');
const more = count > 3 ? `, +${count - 3} more` : '';
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} issue(s) flagged earlier this session (${sample}${more}). Address them before finalizing — the previous reminder still applies.`;
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
}
export function shouldEmitAckForFile(filePath) {
@@ -977,28 +1001,28 @@ export function shouldEmitAckForFile(filePath) {
// The directive footer is the part of the hook output that steers model
// behavior. Three intentional moves:
// 1. **Imperative, not advisory.** "Fix these..." beats "Consider
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
// revising..." which the model treats as a soft suggestion it can
// override when the user asked for any kind of throwaway / demo UI.
// 2. **Explicit exception clause.** Without it, the model will try to
// "fix" intentional bad fixtures, anti-pattern examples in docs, or
// test cases. Naming the exception inline beats hoping the model
// infers it from context.
// 2. **Explicit judgment clause.** Without it, the model will try to
// "fix" intentional motion, bad fixtures, anti-pattern examples in
// docs, or test cases. Naming the judgment inline beats hoping the
// model infers it from context.
// 3. **Acknowledgement instruction.** Hook output is injected as
// developer-role context, not a chat turn, so the user never sees the
// raw envelope. Asking the model to surface the fix in its reply is
// the cheapest way to make the feedback loop visible to the user.
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
const fileIgnoreGuidance = opts.grouped
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
: `run \`${ignoreFileCommand}\``;
return [
'Fix these in your next reply before finalizing. Acknowledge what you changed so the user sees the correction.',
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Skip the fix only if the user explicitly asked for an intentionally bad UI, an anti-pattern example, a test fixture, or documentation of bad design. In that case, say so and continue.',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Do not add hook ignores unless the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
].join('\n');
}
@@ -46,7 +46,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
@@ -57,6 +57,12 @@ Example value-specific exception:
node .trae-cn/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
```bash
node .trae-cn/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
```
Example whole-rule font exception:
```bash
@@ -1084,9 +1084,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -110,9 +110,14 @@ const REGEX_MATCHERS = [
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
test: () => true,
fmt: (m) => m[0] },
fmt: (m) => {
const token = m[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
return `animation: ${token || m[1].trim()}`;
} },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
@@ -514,9 +514,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -333,8 +333,8 @@ function isInsideProject(filePath, cwd) {
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
'[impeccable@1] Required design corrections',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Required design corrections',
'[impeccable@1] Design hook findings requiring review',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
);
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
}
+40 -16
View File
@@ -453,16 +453,22 @@ function isIgnoredFindingValue(finding, ignoreValues) {
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
if (rule !== 'overused-font') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding));
if (rule !== 'overused-font' && rule !== 'bounce-easing') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding) {
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
@@ -482,6 +488,24 @@ function extractFindingIgnoreValueRaw(finding) {
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
@@ -524,7 +548,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
const shown = findings.slice(0, cap);
const remaining = total - shown.length;
const header = `${ENVELOPE_PREFIX} Required design corrections in ${display} (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
const lines = shown.map((f) => formatFindingLine(f));
const more = remaining > 0
? `... and ${remaining} more (see /impeccable audit).`
@@ -556,7 +580,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
const cwd = opts.cwd || process.cwd();
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
const header = `${ENVELOPE_PREFIX} Required design corrections across ${realGroups.length} files (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
const lines = [];
let shownCount = 0;
@@ -968,7 +992,7 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
// `knownFindings` here are the cache strings like "side-tab:3".
const sample = knownFindings.slice(0, 3).join(', ');
const more = count > 3 ? `, +${count - 3} more` : '';
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} issue(s) flagged earlier this session (${sample}${more}). Address them before finalizing — the previous reminder still applies.`;
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
}
export function shouldEmitAckForFile(filePath) {
@@ -977,28 +1001,28 @@ export function shouldEmitAckForFile(filePath) {
// The directive footer is the part of the hook output that steers model
// behavior. Three intentional moves:
// 1. **Imperative, not advisory.** "Fix these..." beats "Consider
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
// revising..." which the model treats as a soft suggestion it can
// override when the user asked for any kind of throwaway / demo UI.
// 2. **Explicit exception clause.** Without it, the model will try to
// "fix" intentional bad fixtures, anti-pattern examples in docs, or
// test cases. Naming the exception inline beats hoping the model
// infers it from context.
// 2. **Explicit judgment clause.** Without it, the model will try to
// "fix" intentional motion, bad fixtures, anti-pattern examples in
// docs, or test cases. Naming the judgment inline beats hoping the
// model infers it from context.
// 3. **Acknowledgement instruction.** Hook output is injected as
// developer-role context, not a chat turn, so the user never sees the
// raw envelope. Asking the model to surface the fix in its reply is
// the cheapest way to make the feedback loop visible to the user.
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
const fileIgnoreGuidance = opts.grouped
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
: `run \`${ignoreFileCommand}\``;
return [
'Fix these in your next reply before finalizing. Acknowledge what you changed so the user sees the correction.',
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Skip the fix only if the user explicitly asked for an intentionally bad UI, an anti-pattern example, a test fixture, or documentation of bad design. In that case, say so and continue.',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Do not add hook ignores unless the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
].join('\n');
}
+7 -1
View File
@@ -46,7 +46,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
@@ -57,6 +57,12 @@ Example value-specific exception:
node .trae/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
```bash
node .trae/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
```
Example whole-rule font exception:
```bash
@@ -1084,9 +1084,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -110,9 +110,14 @@ const REGEX_MATCHERS = [
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
test: () => true,
fmt: (m) => m[0] },
fmt: (m) => {
const token = m[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
return `animation: ${token || m[1].trim()}`;
} },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
@@ -514,9 +514,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -333,8 +333,8 @@ function isInsideProject(filePath, cwd) {
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
'[impeccable@1] Required design corrections',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Required design corrections',
'[impeccable@1] Design hook findings requiring review',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
);
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
}
+40 -16
View File
@@ -453,16 +453,22 @@ function isIgnoredFindingValue(finding, ignoreValues) {
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
if (rule !== 'overused-font') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding));
if (rule !== 'overused-font' && rule !== 'bounce-easing') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding) {
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
@@ -482,6 +488,24 @@ function extractFindingIgnoreValueRaw(finding) {
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
@@ -524,7 +548,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
const shown = findings.slice(0, cap);
const remaining = total - shown.length;
const header = `${ENVELOPE_PREFIX} Required design corrections in ${display} (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
const lines = shown.map((f) => formatFindingLine(f));
const more = remaining > 0
? `... and ${remaining} more (see /impeccable audit).`
@@ -556,7 +580,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
const cwd = opts.cwd || process.cwd();
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
const header = `${ENVELOPE_PREFIX} Required design corrections across ${realGroups.length} files (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
const lines = [];
let shownCount = 0;
@@ -968,7 +992,7 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
// `knownFindings` here are the cache strings like "side-tab:3".
const sample = knownFindings.slice(0, 3).join(', ');
const more = count > 3 ? `, +${count - 3} more` : '';
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} issue(s) flagged earlier this session (${sample}${more}). Address them before finalizing — the previous reminder still applies.`;
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
}
export function shouldEmitAckForFile(filePath) {
@@ -977,28 +1001,28 @@ export function shouldEmitAckForFile(filePath) {
// The directive footer is the part of the hook output that steers model
// behavior. Three intentional moves:
// 1. **Imperative, not advisory.** "Fix these..." beats "Consider
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
// revising..." which the model treats as a soft suggestion it can
// override when the user asked for any kind of throwaway / demo UI.
// 2. **Explicit exception clause.** Without it, the model will try to
// "fix" intentional bad fixtures, anti-pattern examples in docs, or
// test cases. Naming the exception inline beats hoping the model
// infers it from context.
// 2. **Explicit judgment clause.** Without it, the model will try to
// "fix" intentional motion, bad fixtures, anti-pattern examples in
// docs, or test cases. Naming the judgment inline beats hoping the
// model infers it from context.
// 3. **Acknowledgement instruction.** Hook output is injected as
// developer-role context, not a chat turn, so the user never sees the
// raw envelope. Asking the model to surface the fix in its reply is
// the cheapest way to make the feedback loop visible to the user.
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
const fileIgnoreGuidance = opts.grouped
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
: `run \`${ignoreFileCommand}\``;
return [
'Fix these in your next reply before finalizing. Acknowledge what you changed so the user sees the correction.',
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Skip the fix only if the user explicitly asked for an intentionally bad UI, an anti-pattern example, a test fixture, or documentation of bad design. In that case, say so and continue.',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Do not add hook ignores unless the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
].join('\n');
}
+7 -3
View File
@@ -1084,9 +1084,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
+7 -2
View File
@@ -110,9 +110,14 @@ const REGEX_MATCHERS = [
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
test: () => true,
fmt: (m) => m[0] },
fmt: (m) => {
const token = m[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
return `animation: ${token || m[1].trim()}`;
} },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
+7 -3
View File
@@ -514,9 +514,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "impeccable",
"version": "3.0.0",
"version": "3.0.1",
"author": "Paul Bakaus",
"description": "Design skills, commands, and anti-pattern detection for AI coding agents",
"keywords": [
+7 -1
View File
@@ -46,7 +46,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
@@ -57,6 +57,12 @@ Example value-specific exception:
node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
```bash
node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
```
Example whole-rule font exception:
```bash
@@ -1084,9 +1084,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -110,9 +110,14 @@ const REGEX_MATCHERS = [
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
test: () => true,
fmt: (m) => m[0] },
fmt: (m) => {
const token = m[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
return `animation: ${token || m[1].trim()}`;
} },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
@@ -514,9 +514,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -333,8 +333,8 @@ function isInsideProject(filePath, cwd) {
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
'[impeccable@1] Required design corrections',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Required design corrections',
'[impeccable@1] Design hook findings requiring review',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
);
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
}
+40 -16
View File
@@ -453,16 +453,22 @@ function isIgnoredFindingValue(finding, ignoreValues) {
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
if (rule !== 'overused-font') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding));
if (rule !== 'overused-font' && rule !== 'bounce-easing') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding) {
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
@@ -482,6 +488,24 @@ function extractFindingIgnoreValueRaw(finding) {
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
@@ -524,7 +548,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
const shown = findings.slice(0, cap);
const remaining = total - shown.length;
const header = `${ENVELOPE_PREFIX} Required design corrections in ${display} (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
const lines = shown.map((f) => formatFindingLine(f));
const more = remaining > 0
? `... and ${remaining} more (see /impeccable audit).`
@@ -556,7 +580,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
const cwd = opts.cwd || process.cwd();
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
const header = `${ENVELOPE_PREFIX} Required design corrections across ${realGroups.length} files (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
const lines = [];
let shownCount = 0;
@@ -968,7 +992,7 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
// `knownFindings` here are the cache strings like "side-tab:3".
const sample = knownFindings.slice(0, 3).join(', ');
const more = count > 3 ? `, +${count - 3} more` : '';
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} issue(s) flagged earlier this session (${sample}${more}). Address them before finalizing — the previous reminder still applies.`;
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
}
export function shouldEmitAckForFile(filePath) {
@@ -977,28 +1001,28 @@ export function shouldEmitAckForFile(filePath) {
// The directive footer is the part of the hook output that steers model
// behavior. Three intentional moves:
// 1. **Imperative, not advisory.** "Fix these..." beats "Consider
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
// revising..." which the model treats as a soft suggestion it can
// override when the user asked for any kind of throwaway / demo UI.
// 2. **Explicit exception clause.** Without it, the model will try to
// "fix" intentional bad fixtures, anti-pattern examples in docs, or
// test cases. Naming the exception inline beats hoping the model
// infers it from context.
// 2. **Explicit judgment clause.** Without it, the model will try to
// "fix" intentional motion, bad fixtures, anti-pattern examples in
// docs, or test cases. Naming the judgment inline beats hoping the
// model infers it from context.
// 3. **Acknowledgement instruction.** Hook output is injected as
// developer-role context, not a chat turn, so the user never sees the
// raw envelope. Asking the model to surface the fix in its reply is
// the cheapest way to make the feedback loop visible to the user.
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
const fileIgnoreGuidance = opts.grouped
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
: `run \`${ignoreFileCommand}\``;
return [
'Fix these in your next reply before finalizing. Acknowledge what you changed so the user sees the correction.',
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Skip the fix only if the user explicitly asked for an intentionally bad UI, an anti-pattern example, a test fixture, or documentation of bad design. In that case, say so and continue.',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Do not add hook ignores unless the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
].join('\n');
}
+10 -2
View File
@@ -28,7 +28,7 @@ import '../styles/changelog-faq-kinpaku.css';
<p class="cf-entry-lead">Project design hooks, deeper Live Mode support for Svelte and manual edits, and a broad detector accuracy pass across the skill, CLI, and extension.</p>
<ul class="cf-items">
<li><strong>Project design hooks.</strong> <code>/impeccable hooks</code> installs and repairs a project-local detector hook for Claude, Codex, and Cursor. Claude and Codex get post-edit reminders, Cursor can block proposed writes before they land, and <code>/impeccable hooks on</code> now handles manifest setup and consent instead of leaving users to wire files by hand.</li>
<li><strong>Hook findings are actionable, not noisy.</strong> Hook runs track clean, pending, and fresh findings, cache duplicate reports, audit their own activity, and offer narrow ignore flows through <code>ignore-value</code>, <code>ignore-file</code>, and <code>ignore-rule</code>. Shared config lives in <code>.impeccable/config.json</code>, with local consent and overrides in <code>.impeccable/config.local.json</code>.</li>
<li><strong>Hook findings are actionable, not noisy.</strong> Hook runs track clean, pending, and fresh findings, cache duplicate reports, audit their own activity, and now frame detector output as findings requiring review: fix true issues, but use context judgment for intentional designs such as literal bounce motion. Narrow ignore flows through <code>ignore-value</code>, <code>ignore-file</code>, and <code>ignore-rule</code> keep confirmed exceptions out of source comments. Shared config lives in <code>.impeccable/config.json</code>, with local consent and overrides in <code>.impeccable/config.local.json</code>.</li>
<li><strong>Svelte-native Live Mode.</strong> Svelte and SvelteKit variants now preview as temporary framework components with params stored in <code>params.json</code>, then accept back into the selected source component. That keeps stateful pages closer to their real shape and avoids the HMR resets caused by string-injected previews.</li>
<li><strong>Manual and browser Live Mode got sturdier.</strong> Manual text edits have dedicated evidence, apply, and discard routes; Live Mode preserves insertion anchors and mapped-list accept cleanup; and the browser payload is split into DOM helpers, UI primitives, vocabulary, and manual-apply modules instead of one giant script.</li>
<li><strong>Detector accuracy improved across the bundled skill.</strong> Hidden and unrendered elements are skipped in browser rules, sr-only and visually hidden text no longer trips <code>text-overflow</code>, repeated kicker false positives are reduced in card and list contexts, oversized H1 detection now requires viewport dominance, clipped overflow distinguishes decorative viewports from escaping content, OKLCH alpha parses correctly, Sass files count as CSS-like detector inputs, transparent borders or shadows no longer trigger the GPT thin-border rule, and page-level numbered-marker checks no longer treat JS, TS, JSX, TSX, or CSS implementation literals as visible page copy.</li>
@@ -83,6 +83,14 @@ import '../styles/changelog-faq-kinpaku.css';
</ul>
</article>
<article id="cli-v3.0.1" class="cf-entry">
<header class="cf-entry-head"><span class="cf-version">CLI v3.0.1</span><span class="cf-date">June 15, 2026</span></header>
<ul class="cf-items">
<li><strong>Motion findings preserve the actual value.</strong> <code>bounce-easing</code> findings now report the concrete animation token, Tailwind class, or overshooting cubic-bezier value instead of collapsing animation names to a generic <code>bounce</code>. That gives hooks and audits enough information to suggest narrow <code>ignore-value</code> exceptions for intentional motion.</li>
<li><strong>Project hook guidance uses judgment first.</strong> Hook output now says findings require review, not automatic correction. Agents are told to fix real design problems, classify contextual false positives explicitly, and avoid changing intentional design just to satisfy the detector.</li>
</ul>
</article>
<article id="cli-v3.0.0" class="cf-entry">
<header class="cf-entry-head"><span class="cf-version">CLI v3.0.0</span><span class="cf-date">June 14, 2026</span></header>
<ul class="cf-items">
@@ -134,7 +142,7 @@ import '../styles/changelog-faq-kinpaku.css';
<ul class="cf-items">
<li><strong>Firefox build.</strong> The same detector, popup, DevTools panel, and per-rule toggles now ship as a Firefox add-on. <code>bun run build:extension</code> emits a Gecko-compatible package next to the Chrome one, with the background worker declared as an event page and a data-collection declaration that states what the extension already does: the scan runs in the page, and nothing leaves your machine.</li>
<li><strong>Firefox DevTools paths are fixed.</strong> DevTools panel and sidebar URLs are root-relative in the Firefox manifest, so packaged builds can open their extension pages reliably.</li>
<li><strong>Detector results match the latest engine.</strong> The overlay picks up the same false-positive fixes as the CLI: hidden-element skips, sr-only text-overflow handling, tighter repeated kicker, oversized H1, clipped-overflow, OKLCH alpha, Sass-adjacent CSS parsing, and transparent-border handling.</li>
<li><strong>Detector results match the latest engine.</strong> The overlay picks up the same false-positive fixes as the CLI: hidden-element skips, sr-only text-overflow handling, tighter repeated kicker, oversized H1, clipped-overflow, OKLCH alpha, Sass-adjacent CSS parsing, transparent-border handling, and exact animation values for bounce/easing findings.</li>
<li><strong>Scan responses are easier to correlate.</strong> Extension scan messages echo scan IDs back to the caller, and the store metadata and icon set were refreshed for the current 41-rule detector.</li>
</ul>
</article>
+12 -7
View File
@@ -1040,15 +1040,20 @@ code {
@keyframes toggle-drift { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(2px); } }
@keyframes toggle-snap { from { transform: translateX(0); fill: var(--color-mist); } to { transform: translateX(8px); fill: var(--color-accent); } }
/* Motion (Gentle bob + smooth travel on hover) */
/* Motion (Gentle bob + full bounce on hover) */
.anim-squash-ball { transform-origin: 20px 20px; animation: ball-bob 2.5s ease-in-out infinite; }
.foundation-column:hover .anim-squash-ball { animation: travel-ball 1.5s var(--ease-out-quint) infinite; }
.foundation-column:hover .anim-squash-ball { animation: bounce-ball 1.5s linear infinite; }
@keyframes ball-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(3px); } }
@keyframes travel-ball {
@keyframes bounce-ball {
0% { transform: translateY(0); }
35% { transform: translateY(11px); }
60% { transform: translateY(12px); }
100% { transform: translateY(0); }
6% { transform: translateY(0.5px); }
18% { transform: translateY(4px); }
35% { transform: translateY(12px); }
42% { transform: translateY(12px) scaleX(1.3) scaleY(0.6); }
48% { transform: translateY(12px); }
65% { transform: translateY(4px); }
78% { transform: translateY(0.5px); }
88%, 100% { transform: translateY(0); }
}
/* UX Writing (Cursor always blinks) */
@@ -1081,7 +1086,7 @@ code {
.foundation-card:hover .anim-res-line-1 { transform: translate(-7px, 4.75px) scaleX(0.65); }
.foundation-card:hover .anim-res-line-2 { transform: translate(-6px, 4.25px) scaleX(0.6); }
.foundation-card:hover .anim-toggle-move { animation: toggle-snap 0.35s var(--ease-in-out) forwards; }
.foundation-card:hover .anim-squash-ball { animation: travel-ball 1.5s var(--ease-out-quint) infinite; }
.foundation-card:hover .anim-squash-ball { animation: bounce-ball 1.5s linear infinite; }
}
/* Small tablet: 2-col grid */
+7 -1
View File
@@ -46,7 +46,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
@@ -57,6 +57,12 @@ Example value-specific exception:
node {{scripts_path}}/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
```bash
node {{scripts_path}}/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
```
Example whole-rule font exception:
```bash
+2 -2
View File
@@ -333,8 +333,8 @@ function isInsideProject(filePath, cwd) {
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
'[impeccable@1] Required design corrections',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Required design corrections',
'[impeccable@1] Design hook findings requiring review',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
);
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
}
+40 -16
View File
@@ -453,16 +453,22 @@ function isIgnoredFindingValue(finding, ignoreValues) {
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
if (rule !== 'overused-font') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding));
if (rule !== 'overused-font' && rule !== 'bounce-easing') return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding) {
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
@@ -482,6 +488,24 @@ function extractFindingIgnoreValueRaw(finding) {
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
@@ -524,7 +548,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
const shown = findings.slice(0, cap);
const remaining = total - shown.length;
const header = `${ENVELOPE_PREFIX} Required design corrections in ${display} (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
const lines = shown.map((f) => formatFindingLine(f));
const more = remaining > 0
? `... and ${remaining} more (see /impeccable audit).`
@@ -556,7 +580,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
const cwd = opts.cwd || process.cwd();
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
const header = `${ENVELOPE_PREFIX} Required design corrections across ${realGroups.length} files (${total} issue(s)):`;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
const lines = [];
let shownCount = 0;
@@ -968,7 +992,7 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
// `knownFindings` here are the cache strings like "side-tab:3".
const sample = knownFindings.slice(0, 3).join(', ');
const more = count > 3 ? `, +${count - 3} more` : '';
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} issue(s) flagged earlier this session (${sample}${more}). Address them before finalizing — the previous reminder still applies.`;
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
}
export function shouldEmitAckForFile(filePath) {
@@ -977,28 +1001,28 @@ export function shouldEmitAckForFile(filePath) {
// The directive footer is the part of the hook output that steers model
// behavior. Three intentional moves:
// 1. **Imperative, not advisory.** "Fix these..." beats "Consider
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
// revising..." which the model treats as a soft suggestion it can
// override when the user asked for any kind of throwaway / demo UI.
// 2. **Explicit exception clause.** Without it, the model will try to
// "fix" intentional bad fixtures, anti-pattern examples in docs, or
// test cases. Naming the exception inline beats hoping the model
// infers it from context.
// 2. **Explicit judgment clause.** Without it, the model will try to
// "fix" intentional motion, bad fixtures, anti-pattern examples in
// docs, or test cases. Naming the judgment inline beats hoping the
// model infers it from context.
// 3. **Acknowledgement instruction.** Hook output is injected as
// developer-role context, not a chat turn, so the user never sees the
// raw envelope. Asking the model to surface the fix in its reply is
// the cheapest way to make the feedback loop visible to the user.
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
const fileIgnoreGuidance = opts.grouped
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
: `run \`${ignoreFileCommand}\``;
return [
'Fix these in your next reply before finalizing. Acknowledge what you changed so the user sees the correction.',
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Skip the fix only if the user explicitly asked for an intentionally bad UI, an anti-pattern example, a test fixture, or documentation of bad design. In that case, say so and continue.',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Do not add hook ignores unless the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
].join('\n');
}
+4 -2
View File
@@ -541,8 +541,10 @@ describe('detectText — motion', () => {
});
test('detects animation: bounce CSS', () => {
const f = detectText('.icon { animation: bounce 1s infinite; }', 'test.css');
expect(f.some(r => r.antipattern === 'bounce-easing')).toBe(true);
const f = detectText('.icon { animation: bounce-ball 1s infinite; }', 'test.css');
const finding = f.find(r => r.antipattern === 'bounce-easing');
expect(finding).toBeTruthy();
expect(finding.snippet).toBe('animation: bounce-ball');
});
test('detects animation-name: elastic', () => {
+57 -25
View File
@@ -336,15 +336,20 @@ describe('filterFindings()', () => {
const findings = [
finding('overused-font', 1, { snippet: 'Primary font: Inter (86% of text)' }),
finding('overused-font', 2, { snippet: 'Primary font: Roboto' }),
finding('bounce-easing', 3, { snippet: 'animation: bounce-ball' }),
finding('bounce-easing', 4, { snippet: 'animation: wobble-card' }),
finding('side-tab', 3),
];
const filtered = filterFindings(findings, '', '.css', {
ignoreRules: [],
ignoreValues: [{ rule: 'overused-font', value: 'inter' }],
ignoreValues: [
{ rule: 'overused-font', value: 'inter' },
{ rule: 'bounce-easing', value: 'bounce-ball' },
],
minSeverity: 'warning',
limits: DEFAULT_CONFIG.limits,
});
assert.deepEqual(filtered.map((f) => `${f.antipattern}:${f.line}`), ['overused-font:2', 'side-tab:3']);
assert.deepEqual(filtered.map((f) => `${f.antipattern}:${f.line}`), ['overused-font:2', 'bounce-easing:4', 'side-tab:3']);
});
it('extracts overused-font values from primary, CSS, and Google font snippets', () => {
@@ -362,6 +367,21 @@ describe('filterFindings()', () => {
);
assert.equal(extractFindingIgnoreValue(finding('side-tab', 1)), '');
});
it('extracts bounce-easing values from motion snippets', () => {
assert.equal(
extractFindingIgnoreValue(finding('bounce-easing', 1, { snippet: 'animation: bounce-ball' })),
'bounce-ball',
);
assert.equal(
extractFindingIgnoreValue(finding('bounce-easing', 1, { snippet: 'animate-bounce (Tailwind)' })),
'animate-bounce',
);
assert.equal(
extractFindingIgnoreValue(finding('bounce-easing', 1, { snippet: 'cubic-bezier(0.3, -0.4, 0.6, 1.4)' })),
'cubic-bezier(0.3, -0.4, 0.6, 1.4)',
);
});
});
describe('hook-admin.mjs', () => {
@@ -529,7 +549,7 @@ describe('renderTemplate()', () => {
const findings = Array.from({ length: 12 }, (_, i) =>
finding('side-tab', i + 1, { name: `R${i}`, description: 'd' }));
const text = renderTemplate(findings, '/x/Card.tsx', DEFAULT_CONFIG, { cwd: '/x' });
assert.ok(text.startsWith(`${ENVELOPE_PREFIX} Required design corrections in Card.tsx (12 issue(s)):`));
assert.ok(text.startsWith(`${ENVELOPE_PREFIX} Design hook findings requiring review in Card.tsx (12 issue(s)):`));
assert.match(text, /\.\.\. and 7 more \(see \/impeccable audit\)\./);
// Exactly 5 finding lines.
const lines = text.split('\n').filter((l) => l.startsWith('- '));
@@ -537,19 +557,23 @@ describe('renderTemplate()', () => {
assert.ok(text.length <= DEFAULT_CONFIG.limits.maxChars);
});
it('emits a directive footer (imperative + exception clause + confirmed ignore guidance)', () => {
// Steers the model: imperative "fix", explicit exception for
// intentional bad UI / fixtures, and "acknowledge" so the user
// sees the correction in the chat reply. See `directiveFooter()`
// in hook-lib.mjs for the rationale.
it('emits a directive footer (imperative + judgment clause + confirmed ignore guidance)', () => {
// Steers the model: imperative "handle", explicit context judgment
// before editing, and "acknowledge" so the user sees the resolution
// in the chat reply. See `directiveFooter()` in hook-lib.mjs for
// the rationale.
const text = renderTemplate(
[finding('side-tab', 1, { name: 'X' })],
'/x/Card.tsx', DEFAULT_CONFIG, { cwd: '/x' }
);
assert.match(text, /Fix these in your next reply/);
assert.match(text, /Acknowledge what you changed/);
assert.match(text, /intentionally bad UI|anti-pattern example|test fixture/);
assert.match(text, /Do not add hook ignores unless the user explicitly confirms/);
assert.match(text, /Handle these before finalizing/);
assert.match(text, /fix findings that are real design problems/);
assert.match(text, /classify contextually intentional findings as false positives/);
assert.match(text, /Use context judgment before editing/);
assert.match(text, /not automatically a defect/);
assert.match(text, /literal or domain-appropriate motion/);
assert.match(text, /Do not change intentional design just to satisfy the hook/);
assert.match(text, /Persist hook ignores only after the user explicitly confirms/);
assert.match(text, /Do not add source comments such as `impeccable: ignore`/);
assert.match(text, /ignore-value \.\.\. --shared/);
assert.match(text, /ignore-rule overused-font --all-values/);
@@ -567,6 +591,14 @@ describe('renderTemplate()', () => {
assert.match(text, /ignore-rule overused-font --all-values/);
});
it('shows the exact value-specific command for bounce-easing findings', () => {
const text = renderTemplate(
[finding('bounce-easing', 1, { name: 'Bounce or elastic easing', snippet: 'animation: bounce-ball' })],
'/x/main.css', DEFAULT_CONFIG, { cwd: '/x' }
);
assert.match(text, /\/impeccable hooks ignore-value bounce-easing bounce-ball --shared/);
});
it('drops the L<line> prefix when line is 0', () => {
const text = renderTemplate(
[finding('side-tab', 0, { name: 'X' })],
@@ -706,13 +738,13 @@ describe('runHook()', () => {
const r1 = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
assert.equal(r1.exitCode, 0);
assert.ok(r1.stdout.includes(ENVELOPE_PREFIX));
assert.match(r1.stdout, /Required design corrections/);
assert.match(r1.stdout, /Design hook findings requiring review/);
assert.equal(r1.audit.emitted, true);
const r2 = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
assert.equal(r2.exitCode, 0);
assert.ok(r2.stdout.includes(ENVELOPE_PREFIX));
assert.match(r2.stdout, /Still has 1 issue\(s\) flagged earlier this session/);
assert.match(r2.stdout, /Still has 1 finding\(s\) flagged earlier this session/);
assert.match(r2.stdout, /side-tab:1/);
assert.equal(r2.audit.emitted, true);
assert.equal(r2.audit.kind, 'pending');
@@ -754,7 +786,7 @@ describe('runHook()', () => {
cwd,
detector: fakeDetector([finding('side-tab', 1)]),
});
assert.match(r.stdout, /Required design corrections/);
assert.match(r.stdout, /Design hook findings requiring review/);
assert.match(r.stdout, /side-tab/);
});
@@ -762,7 +794,7 @@ describe('runHook()', () => {
const file = writeFixture('src/build.js', 'export const value = 1;');
const det = fakeDetector([finding('side-tab', 1)]);
const first = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
assert.match(first.stdout, /Required design corrections/);
assert.match(first.stdout, /Design hook findings requiring review/);
const second = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
assert.equal(second.stdout, '');
@@ -793,7 +825,7 @@ describe('runHook()', () => {
env: { IMPECCABLE_HOOK_QUIET: '1' }, cwd, detector: detFindings,
});
assert.ok(rFindings.stdout.includes(ENVELOPE_PREFIX));
assert.match(rFindings.stdout, /Required design corrections/);
assert.match(rFindings.stdout, /Design hook findings requiring review/);
assert.equal(rFindings.audit.emitted, true);
});
@@ -965,7 +997,7 @@ describe('runHook()', () => {
const det = fakeDetector([finding('side-tab', 1)]);
const r = await runHook({ stdinJson: JSON.stringify(event), env: {}, cwd, detector: det });
assert.equal(r.exitCode, 0);
assert.match(r.stdout, /Required design corrections/);
assert.match(r.stdout, /Design hook findings requiring review/);
});
it('detector throw is swallowed; never breaks turn', async () => {
@@ -989,7 +1021,7 @@ describe('runHook()', () => {
cwd,
detector: { detectHtml, detectText },
});
assert.match(r.stdout, /Required design corrections/);
assert.match(r.stdout, /Design hook findings requiring review/);
assert.doesNotMatch(r.stdout, /No anti-patterns/);
assert.ok(r.audit.findings > 0);
});
@@ -1053,10 +1085,10 @@ describe('renderCleanAck() / renderPendingAck()', () => {
const known = ['side-tab:3', 'gradient-text:4', 'ai-color-palette:8', 'overused-font:12'];
const text = renderPendingAck('/x/src/SlopCard.jsx', known, { cwd: '/x' });
assert.match(text, /^\[impeccable@1\] Design hook scanned src\/SlopCard\.jsx\./);
assert.match(text, /Still has 4 issue\(s\) flagged earlier this session/);
assert.match(text, /Still has 4 finding\(s\) flagged earlier this session/);
assert.match(text, /side-tab:3, gradient-text:4, ai-color-palette:8/);
assert.match(text, /\+1 more/); // 4 total, 3 shown
assert.match(text, /Address them before finalizing/);
assert.match(text, /Handle them before finalizing/);
});
it('renderPendingAck omits the "+N more" suffix when ≤3 known findings', () => {
@@ -1229,7 +1261,7 @@ describe('runHook() — co-located stylesheet scan', () => {
cwd,
detector: det,
});
assert.match(r.stdout, /Required design corrections/);
assert.match(r.stdout, /Design hook findings requiring review/);
assert.match(r.stdout, /styles\.css/);
});
@@ -1254,7 +1286,7 @@ describe('runHook() — co-located stylesheet scan', () => {
cwd,
detector: det,
});
assert.match(r.stdout, /Required design corrections/);
assert.match(r.stdout, /Design hook findings requiring review/);
assert.match(r.stdout, /styles\.sass/);
});
@@ -1285,7 +1317,7 @@ describe('runHook() — co-located stylesheet scan', () => {
detector: det,
});
assert.match(r.stdout, /Required design corrections/);
assert.match(r.stdout, /Design hook findings requiring review/);
assert.match(r.stdout, /App\.jsx/);
assert.match(r.stdout, /styles\.css/);
assert.match(r.stdout, /side-tab/);
@@ -1415,7 +1447,7 @@ describe('Cursor hook scripts', () => {
assert.equal(payload.permission, 'deny');
assert.match(payload.user_message, /blocked this write/);
assert.match(payload.user_message, /side-tab/);
assert.match(payload.agent_message, /Fix these in your next reply/);
assert.match(payload.agent_message, /Handle these before finalizing/);
const entries = fs.readFileSync(logPath, 'utf-8').trim().split('\n').map((line) => JSON.parse(line));
assert.equal(entries[0].event, 'preToolUse');