Compare commits

...
2 Commits
Author SHA1 Message Date
Paul Bakaus 858b9bbea6 Improve hook false-positive handling 2026-06-15 13:30:28 +09:00
github-actions[bot] a9c15481a9 Sync generated provider output 2026-06-15 04:05:09 +00:00
103 changed files with 1111 additions and 455 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
@@ -27,7 +27,7 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
// can offer `npx impeccable skills update`. Everything here is best-effort and
// can offer `npx impeccable update`. Everything here is best-effort and
// silent on failure: a network problem, sandbox, or missing cache must never
// block context output or print an error.
@@ -172,8 +172,8 @@ function buildUpdateDirective(localVersion, latestVersion) {
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
`(installed v${localVersion}, latest v${latestVersion}). ` +
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
`Update now? It runs \`npx impeccable skills update\`." ` +
`If they agree, run \`npx impeccable skills update\` (the update applies to the next session, not this one). ` +
`Update now? It runs \`npx impeccable update\`." ` +
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
`Either way, continue the current task without waiting, and do not raise this again.`
);
}
@@ -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
@@ -27,7 +27,7 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
// can offer `npx impeccable skills update`. Everything here is best-effort and
// can offer `npx impeccable update`. Everything here is best-effort and
// silent on failure: a network problem, sandbox, or missing cache must never
// block context output or print an error.
@@ -172,8 +172,8 @@ function buildUpdateDirective(localVersion, latestVersion) {
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
`(installed v${localVersion}, latest v${latestVersion}). ` +
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
`Update now? It runs \`npx impeccable skills update\`." ` +
`If they agree, run \`npx impeccable skills update\` (the update applies to the next session, not this one). ` +
`Update now? It runs \`npx impeccable update\`." ` +
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
`Either way, continue the current task without waiting, and do not raise this again.`
);
}
@@ -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
@@ -27,7 +27,7 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
// can offer `npx impeccable skills update`. Everything here is best-effort and
// can offer `npx impeccable update`. Everything here is best-effort and
// silent on failure: a network problem, sandbox, or missing cache must never
// block context output or print an error.
@@ -172,8 +172,8 @@ function buildUpdateDirective(localVersion, latestVersion) {
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
`(installed v${localVersion}, latest v${latestVersion}). ` +
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
`Update now? It runs \`npx impeccable skills update\`." ` +
`If they agree, run \`npx impeccable skills update\` (the update applies to the next session, not this one). ` +
`Update now? It runs \`npx impeccable update\`." ` +
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
`Either way, continue the current task without waiting, and do not raise this again.`
);
}
@@ -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
@@ -27,7 +27,7 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
// can offer `npx impeccable skills update`. Everything here is best-effort and
// can offer `npx impeccable update`. Everything here is best-effort and
// silent on failure: a network problem, sandbox, or missing cache must never
// block context output or print an error.
@@ -172,8 +172,8 @@ function buildUpdateDirective(localVersion, latestVersion) {
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
`(installed v${localVersion}, latest v${latestVersion}). ` +
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
`Update now? It runs \`npx impeccable skills update\`." ` +
`If they agree, run \`npx impeccable skills update\` (the update applies to the next session, not this one). ` +
`Update now? It runs \`npx impeccable update\`." ` +
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
`Either way, continue the current task without waiting, and do not raise this again.`
);
}
@@ -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
@@ -27,7 +27,7 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
// can offer `npx impeccable skills update`. Everything here is best-effort and
// can offer `npx impeccable update`. Everything here is best-effort and
// silent on failure: a network problem, sandbox, or missing cache must never
// block context output or print an error.
@@ -172,8 +172,8 @@ function buildUpdateDirective(localVersion, latestVersion) {
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
`(installed v${localVersion}, latest v${latestVersion}). ` +
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
`Update now? It runs \`npx impeccable skills update\`." ` +
`If they agree, run \`npx impeccable skills update\` (the update applies to the next session, not this one). ` +
`Update now? It runs \`npx impeccable update\`." ` +
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
`Either way, continue the current task without waiting, and do not raise this again.`
);
}
@@ -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
+3 -3
View File
@@ -27,7 +27,7 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
// can offer `npx impeccable skills update`. Everything here is best-effort and
// can offer `npx impeccable update`. Everything here is best-effort and
// silent on failure: a network problem, sandbox, or missing cache must never
// block context output or print an error.
@@ -172,8 +172,8 @@ function buildUpdateDirective(localVersion, latestVersion) {
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
`(installed v${localVersion}, latest v${latestVersion}). ` +
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
`Update now? It runs \`npx impeccable skills update\`." ` +
`If they agree, run \`npx impeccable skills update\` (the update applies to the next session, not this one). ` +
`Update now? It runs \`npx impeccable update\`." ` +
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
`Either way, continue the current task without waiting, and do not raise this again.`
);
}
@@ -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
@@ -27,7 +27,7 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
// can offer `npx impeccable skills update`. Everything here is best-effort and
// can offer `npx impeccable update`. Everything here is best-effort and
// silent on failure: a network problem, sandbox, or missing cache must never
// block context output or print an error.
@@ -172,8 +172,8 @@ function buildUpdateDirective(localVersion, latestVersion) {
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
`(installed v${localVersion}, latest v${latestVersion}). ` +
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
`Update now? It runs \`npx impeccable skills update\`." ` +
`If they agree, run \`npx impeccable skills update\` (the update applies to the next session, not this one). ` +
`Update now? It runs \`npx impeccable update\`." ` +
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
`Either way, continue the current task without waiting, and do not raise this again.`
);
}
@@ -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
+3 -3
View File
@@ -27,7 +27,7 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
// can offer `npx impeccable skills update`. Everything here is best-effort and
// can offer `npx impeccable update`. Everything here is best-effort and
// silent on failure: a network problem, sandbox, or missing cache must never
// block context output or print an error.
@@ -172,8 +172,8 @@ function buildUpdateDirective(localVersion, latestVersion) {
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
`(installed v${localVersion}, latest v${latestVersion}). ` +
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
`Update now? It runs \`npx impeccable skills update\`." ` +
`If they agree, run \`npx impeccable skills update\` (the update applies to the next session, not this one). ` +
`Update now? It runs \`npx impeccable update\`." ` +
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
`Either way, continue the current task without waiting, and do not raise this again.`
);
}
@@ -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
+3 -3
View File
@@ -27,7 +27,7 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
// can offer `npx impeccable skills update`. Everything here is best-effort and
// can offer `npx impeccable update`. Everything here is best-effort and
// silent on failure: a network problem, sandbox, or missing cache must never
// block context output or print an error.
@@ -172,8 +172,8 @@ function buildUpdateDirective(localVersion, latestVersion) {
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
`(installed v${localVersion}, latest v${latestVersion}). ` +
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
`Update now? It runs \`npx impeccable skills update\`." ` +
`If they agree, run \`npx impeccable skills update\` (the update applies to the next session, not this one). ` +
`Update now? It runs \`npx impeccable update\`." ` +
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
`Either way, continue the current task without waiting, and do not raise this again.`
);
}
@@ -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
@@ -27,7 +27,7 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
// can offer `npx impeccable skills update`. Everything here is best-effort and
// can offer `npx impeccable update`. Everything here is best-effort and
// silent on failure: a network problem, sandbox, or missing cache must never
// block context output or print an error.
@@ -172,8 +172,8 @@ function buildUpdateDirective(localVersion, latestVersion) {
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
`(installed v${localVersion}, latest v${latestVersion}). ` +
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
`Update now? It runs \`npx impeccable skills update\`." ` +
`If they agree, run \`npx impeccable skills update\` (the update applies to the next session, not this one). ` +
`Update now? It runs \`npx impeccable update\`." ` +
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
`Either way, continue the current task without waiting, and do not raise this again.`
);
}
@@ -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
@@ -27,7 +27,7 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
// can offer `npx impeccable skills update`. Everything here is best-effort and
// can offer `npx impeccable update`. Everything here is best-effort and
// silent on failure: a network problem, sandbox, or missing cache must never
// block context output or print an error.
@@ -172,8 +172,8 @@ function buildUpdateDirective(localVersion, latestVersion) {
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
`(installed v${localVersion}, latest v${latestVersion}). ` +
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
`Update now? It runs \`npx impeccable skills update\`." ` +
`If they agree, run \`npx impeccable skills update\` (the update applies to the next session, not this one). ` +
`Update now? It runs \`npx impeccable update\`." ` +
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
`Either way, continue the current task without waiting, and do not raise this again.`
);
}
@@ -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
+3 -3
View File
@@ -27,7 +27,7 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
// can offer `npx impeccable skills update`. Everything here is best-effort and
// can offer `npx impeccable update`. Everything here is best-effort and
// silent on failure: a network problem, sandbox, or missing cache must never
// block context output or print an error.
@@ -172,8 +172,8 @@ function buildUpdateDirective(localVersion, latestVersion) {
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
`(installed v${localVersion}, latest v${latestVersion}). ` +
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
`Update now? It runs \`npx impeccable skills update\`." ` +
`If they agree, run \`npx impeccable skills update\` (the update applies to the next session, not this one). ` +
`Update now? It runs \`npx impeccable update\`." ` +
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
`Either way, continue the current task without waiting, and do not raise this again.`
);
}
@@ -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
+3 -3
View File
@@ -27,7 +27,7 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
// can offer `npx impeccable skills update`. Everything here is best-effort and
// can offer `npx impeccable update`. Everything here is best-effort and
// silent on failure: a network problem, sandbox, or missing cache must never
// block context output or print an error.
@@ -172,8 +172,8 @@ function buildUpdateDirective(localVersion, latestVersion) {
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
`(installed v${localVersion}, latest v${latestVersion}). ` +
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
`Update now? It runs \`npx impeccable skills update\`." ` +
`If they agree, run \`npx impeccable skills update\` (the update applies to the next session, not this one). ` +
`Update now? It runs \`npx impeccable update\`." ` +
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
`Either way, continue the current task without waiting, and do not raise this again.`
);
}
@@ -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;
}

Some files were not shown because too many files have changed in this diff Show More