diff --git a/.agents/skills/impeccable/reference/hooks.md b/.agents/skills/impeccable/reference/hooks.md index e437ca296..f7ebb8c2e 100644 --- a/.agents/skills/impeccable/reference/hooks.md +++ b/.agents/skills/impeccable/reference/hooks.md @@ -23,10 +23,11 @@ The first argument is the action. Defaults to `status`. | `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. | | `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. | | `off` | Set `enabled: false` in `.impeccable/config.json`. | -| `ignore-rule ` | Append `` to `detector.ignoreRules`; for `overused-font`, requires `--all-values`. | -| `ignore-file ` | Append `` to `detector.ignoreFiles`. | +| `ignore-rule ` | Append `` to `detector.ignoreRules`; for `overused-font`, requires `--all-values`. Suppresses the rule across the whole project. | +| `ignore-file ` | Append `` to `detector.ignoreFiles`. Suppresses **every** rule for matching files. | | `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. | | `ignore-value --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. | +| `ignore-value "*" --file [--file ...]` | Turn one rule off in matching files only, leaving it active everywhere else. Repeat `--file`, or use `--file=` / `--files=`. A bare `"*"` with no `--file` is refused: use `ignore-rule ` if you really mean project-wide. | | `reset` | Delete the project config, dedup cache, and Cursor pending queue. | ## Flow @@ -51,7 +52,8 @@ 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 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 ` for the current file. +- If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value "*" --file `. Run `npx impeccable detect ` first to see what actually fires there. +- Reach for `ignore-file ` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above. - Use `ignore-rule ` 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. - Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable ` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it. @@ -73,7 +75,14 @@ Example whole-rule font exception: node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-rule overused-font --all-values --reason "User asked to ignore overused fonts generally" ``` -Example file-scoped exception: +Example one-rule-in-one-file exception, for a file that is still worth reviewing +for everything else: + +```bash +node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-value design-system-font-size "*" --file "src/overlay/widget.js" --reason "Injected widget builds its own type scale; DESIGN.md's ramp describes the site" +``` + +Example whole-file exception, for a file that is out of scope entirely: ```bash node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card.tsx" diff --git a/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 1affdda43..ab2ed562a 100644 --- a/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -43,23 +43,94 @@ function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } +// CSS named colors whose channels are equal (achromatic). Anything outside +// this set falls through to the format parsers, and an unrecognized spelling +// stays non-neutral so a real accent is never skipped. +const NEUTRAL_COLOR_KEYWORDS = new Set([ + 'transparent', 'currentcolor', + 'black', 'white', 'gray', 'grey', 'silver', + 'dimgray', 'dimgrey', 'darkgray', 'darkgrey', 'lightgray', 'lightgrey', + 'gainsboro', 'whitesmoke', +]); + +function hexChannels(color) { + const long = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})(?:[0-9a-f]{2})?$/i); + if (long) return [parseInt(long[1], 16), parseInt(long[2], 16), parseInt(long[3], 16)]; + const short = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])(?:[0-9a-f])?$/i); + if (short) return [1, 2, 3].map((i) => parseInt(short[i] + short[i], 16)); + return null; +} + +/** + * Split one box-shadow layer into top-level tokens. + * + * Whitespace inside parens does not separate tokens: `rgb(0 0 0)` and + * `var(--x, 4px)` are each a single value, and splitting them on spaces would + * read their innards as separate lengths. + */ +function tokenizeShadowLayer(layer) { + const tokens = []; + let depth = 0; + let current = ''; + for (const char of String(layer || '')) { + if (char === '(') depth++; + else if (char === ')') depth--; + else if (depth === 0 && /\s/.test(char)) { + if (current) tokens.push(current); + current = ''; + continue; + } + current += char; + } + if (current) tokens.push(current); + return tokens; +} + +function lastMatch(text, re) { + const all = [...String(text || '').matchAll(re)]; + return all.length ? all[all.length - 1] : null; +} + +function isShadowLength(token) { + return /^-?\d*\.?\d+(?:px)?$/i.test(String(token || '')); +} + +/** + * Neutrality test for colors as written in source CSS. + * + * shared/color.mjs's isNeutralColor only parses the computed function forms a + * browser or jsdom emits (rgb/oklch/lab/...) and deliberately reports every + * other spelling as chromatic so an unknown format is never silently skipped. + * That default is wrong for authored CSS, where `#000` and `black` are the + * normal spellings: calling it directly reports a plain black hairline as a + * colored stripe. Handle hex and named neutrals here, then defer. + */ +function isNeutralAuthoredColor(rawColor) { + const c = String(rawColor || '').trim().toLowerCase(); + if (!c) return false; + if (NEUTRAL_COLOR_KEYWORDS.has(c)) return true; + // Modern rgb() takes space-separated channels (`rgb(0 0 0)`). shared/color.mjs + // parses only the comma form a browser's getComputedStyle emits, so authored + // space-separated neutrals fell through it and reported as chromatic — the + // exemption this function exists for, missed. Normalize before delegating. + if (/^rgba?\(/i.test(c)) { + const channels = c.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i); + if (channels) { + const values = [1, 2, 3].map((i) => Number(channels[i])); + return (Math.max(...values) - Math.min(...values)) < 30; + } + return isNeutralColor(c); + } + if (/^(?:hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c); + const channels = hexChannels(c); + if (channels) return (Math.max(...channels) - Math.min(...channels)) < 30; + return false; +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i); if (!m) return false; - const c = m[1].toLowerCase(); - if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true; - if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c); - const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/); - if (hex) { - const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)]; - return (Math.max(r, g, b) - Math.min(r, g, b)) < 30; - } - const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/); - if (shex) { - const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)]; - return (Math.max(r, g, b) - Math.min(r, g, b)) < 30; - } - return false; + return isNeutralAuthoredColor(m[1]); } const REGEX_MATCHERS = [ @@ -345,12 +416,120 @@ const REGEX_ANALYZERS = [ ]; // --------------------------------------------------------------------------- -// Style block extraction (Vue/Svelte