diff --git a/README.md b/README.md
index b82ee1229..ff46a6aa6 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Impeccable
-Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 59 deterministic detector rules for AI-generated frontend design.
+Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 61 deterministic detector rules for AI-generated frontend design.
> **Quick start:** From your project root, run `npx impeccable install`, then run `/impeccable init` inside your AI coding tool. Full docs: [impeccable.style](https://impeccable.style).
@@ -13,7 +13,7 @@ Every model trained on the same SaaS templates. Skip the guidance and you get th
Impeccable adds:
- **One setup flow.** `/impeccable init` writes `PRODUCT.md` and offers `DESIGN.md`, so later commands know the audience, brand/product lane, voice, anti-references, colors, type, and components.
- **23 commands.** A shared design vocabulary with your AI: `polish`, `audit`, `critique`, `distill`, `animate`, `bolder`, `quieter`, and more.
-- **59 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key.
+- **61 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key.
## What's Included
@@ -402,7 +402,7 @@ npx impeccable ignores add-file "src/legacy/**"
npx impeccable ignores add-value overused-font Inter --reason "Brand font"
```
-The detector catches 59 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more).
+The detector catches 61 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more).
By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution.
diff --git a/README.npm.md b/README.npm.md
index 3f16a1a7d..fac2704e7 100644
--- a/README.npm.md
+++ b/README.npm.md
@@ -1,6 +1,6 @@
# Impeccable CLI
-Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 59 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems.
+Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 61 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems.
## Quick Start
@@ -56,7 +56,7 @@ npx impeccable detect --fast src/
**Quality**: tiny body text, cramped padding, long line lengths, small touch targets
-59 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop).
+61 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop).
## Exit Codes
diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js
index 7df671eb5..62f5b87f1 100644
--- a/cli/engine/detect-antipatterns-browser.js
+++ b/cli/engine/detect-antipatterns-browser.js
@@ -232,6 +232,24 @@ const ANTIPATTERNS = [
'A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.',
skillSection: 'Imagery',
},
+ {
+ id: 'organic-clip-path',
+ category: 'quality',
+ name: 'Organic contour drawn as clip-path',
+ description:
+ 'A clip-path polygon with many arbitrary vertices, or a curved clip-path path(), is CSS approximating a torn edge, blob, or silhouette. It reads as the cheap version of the effect and is usually a produced or photographic material replaced with code. Derive an alpha matte from the real image, or ship the shape as a cut-out raster; keep clip-path for geometry (cut corners, diagonals, hexagons).',
+ skillSection: 'Imagery',
+ skillGuideline: 'geometric masks standing in for organic contours',
+ },
+ {
+ id: 'buried-raster',
+ category: 'quality',
+ name: 'Raster buried under a wash or opacity',
+ description:
+ 'A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.',
+ skillSection: 'Imagery',
+ skillGuideline: 'a produced material must survive to the screen',
+ },
{
id: 'dark-glow',
category: 'slop',
@@ -1924,8 +1942,13 @@ function enclosingCssSelector(cssText, index) {
if (!cssText || !Number.isFinite(index)) return null;
const open = cssText.lastIndexOf('{', index);
if (open === -1) return null;
+ // A match inside an inline style fragment (`style="…"` appended to the
+ // corpus by buildHtmlPatternCorpora) has no enclosing rule; the previous
+ // `{` belongs to some other selector.
+ const closeBeforeIndex = cssText.lastIndexOf('}', index);
+ if (closeBeforeIndex > open) return null;
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
- const raw = cssText.slice(prevClose + 1, open).trim().replace(/\s+/g, ' ');
+ const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
// and `to` would read as (never-matching) type selectors and get a valid
@@ -2691,6 +2714,95 @@ function scanHtmlForShapeAssembledIllustration(html) {
return findings;
}
+
+// --- Organic clip-path polygons ----------------------------------------------
+// A `clip-path: polygon(...)` with many vertices, or `clip-path: path(...)`
+// with curves, is CSS approximating an organic contour: a torn edge, a blob,
+// a silhouette. The approximation reads as the cheap version of the effect
+// (the craft floor's geometric-occlusion-mask ban), and it is the signature
+// of a comp's produced material being replaced with code. Geometric clips
+// (cut corners, diagonals, hexagons, arrows: few vertices, or vertices on
+// the 0/50/100 grid) pass; circle()/inset()/ellipse() pass; a mask-image
+// from an alpha matte passes.
+const ORGANIC_POLYGON_MIN_VERTICES = 10;
+function scanCssTextForOrganicClipPath(styleText) {
+ const findings = [];
+ const re = /clip-path\s*:\s*(polygon|path)\s*\(([^)]*(?:\)[^;}]*)?)/gi;
+ let m;
+ while ((m = re.exec(styleText)) !== null) {
+ const kind = m[1].toLowerCase();
+ const body = m[2];
+ if (kind === 'path') {
+ // curves (C, S, Q, T, A, absolute or relative) drawing a contour, not a
+ // rectilinear M/L/Z outline; letters in path data are only commands
+ const curves = (body.match(/[CSQTA]/gi) || []).length;
+ if (curves < 3) continue;
+ findings.push({ id: 'organic-clip-path', snippet: `clip-path: path() with ${curves} curve segments`, selector: enclosingCssSelector(styleText, m.index) || undefined });
+ continue;
+ }
+ const points = body.split(',').map((p) => p.trim()).filter(Boolean);
+ if (points.length < ORGANIC_POLYGON_MIN_VERTICES) continue;
+ // Vertices sitting on a coarse grid (multiples of 25%) are geometric; a
+ // contour has arbitrary values.
+ let offGrid = 0;
+ for (const p of points) {
+ const nums = p.match(/-?[\d.]+/g) || [];
+ for (const n of nums) { const v = parseFloat(n); if (Math.abs(v - Math.round(v / 25) * 25) > 0.5) offGrid++; }
+ }
+ if (offGrid < points.length) continue;
+ findings.push({ id: 'organic-clip-path', snippet: `clip-path: polygon() with ${points.length} vertices approximating an organic contour`, selector: enclosingCssSelector(styleText, m.index) || undefined });
+ }
+ return findings;
+}
+
+// --- Buried raster ------------------------------------------------------------
+// A raster (background-image url or ) that never reaches the screen:
+// under a near-opaque gradient wash in the same background stack, or on an
+// element at near-zero opacity. It is how a produced texture "ships" while
+// the page shows flat color, and the finish reviewer cannot see it either.
+// A tint under 0.9 alpha passes (hero darkening); a blend mode passes
+// (multiply/overlay keep the material visible); opacity >= 0.15 passes.
+function scanCssTextForBuriedRaster(styleText) {
+ const findings = [];
+ // background stacks: split declarations, look for url() + a gradient whose
+ // stops all carry alpha >= 0.9 (or opaque hex/named colors)
+ const declRe = /background(?:-image)?\s*:\s*([^;}]+)/gi;
+ let m;
+ while ((m = declRe.exec(styleText)) !== null) {
+ const value = m[1];
+ if (!/url\(/i.test(value) || !/gradient\(/i.test(value)) continue;
+ // a blend mode declared in the same rule keeps the raster visible
+ const ruleStart = styleText.lastIndexOf('{', m.index);
+ const ruleEnd = styleText.indexOf('}', m.index);
+ const rule = styleText.slice(ruleStart < 0 ? 0 : ruleStart, ruleEnd < 0 ? styleText.length : ruleEnd);
+ if (/background-blend-mode\s*:\s*(?!normal)/i.test(rule) || /mix-blend-mode\s*:\s*(?!normal)/i.test(rule)) continue;
+ // Layers are painted first-on-top: only a wash listed BEFORE the url()
+ // covers it. An image on top of a gradient is not buried.
+ const firstUrl = value.search(/url\(/i);
+ const gradients = [...value.matchAll(/(?:linear|radial|conic)-gradient\([^()]*(?:\([^()]*\)[^()]*)*\)/gi)].filter((gm) => gm.index < firstUrl).map((gm) => gm[0]);
+ let opaqueWash = false;
+ // an alpha token normalized to 0..1: '0.8' -> 0.8, '80%' -> 0.8
+ const alphaOf = (a) => { if (a == null) return 1; const v = parseFloat(a); return String(a).trim().endsWith('%') ? v / 100 : v; };
+ for (const g of gradients) {
+ const alphas = [...g.matchAll(/rgba?\(\s*[\d.]+%?\s*,?\s*[\d.]+%?\s*,?\s*[\d.]+%?\s*(?:[,/]\s*([\d.]+%?))?\s*\)|hsla?\([^)]*?(?:[,/]\s*([\d.]+%?))?\s*\)/gi)].map((a) => alphaOf(a[1] ?? a[2]));
+ const stripped = g.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)/gi, '');
+ // hex stops: 4- and 8-digit forms carry their own alpha
+ for (const h of stripped.matchAll(/#([0-9a-f]{3,8})\b/gi)) {
+ const hex = h[1];
+ if (hex.length === 4) alphas.push(parseInt(hex[3] + hex[3], 16) / 255);
+ else if (hex.length === 8) alphas.push(parseInt(hex.slice(6), 16) / 255);
+ else alphas.push(1);
+ }
+ const named = /\b(?:white|black|ivory|beige|linen|snow|cream)\b/i.test(stripped);
+ if (named) alphas.push(1);
+ if (alphas.length && alphas.every((a) => !Number.isFinite(a) || a >= 0.9)) { opaqueWash = true; break; }
+ }
+ if (!opaqueWash) continue;
+ findings.push({ id: 'buried-raster', snippet: `raster under a near-opaque gradient wash: ${value.trim().slice(0, 90)}`, selector: enclosingCssSelector(styleText, m.index) || undefined });
+ }
+ return findings;
+}
+
// Scoped scan corpora for the page-level pattern checks. CSS-property
// regexes run over the whole source string fire on documentation ABOUT
// css — `
background-clip: text` prose,
samples, HTML
@@ -2863,6 +2975,10 @@ function checkHtmlPatterns(html, corpora) {
// Shape-assembled illustrations (large pictorial SVGs built from primitives)
findings.push(...scanHtmlForShapeAssembledIllustration(html));
+ // Organic clip-path contours and rasters buried under washes or opacity
+ findings.push(...scanCssTextForOrganicClipPath(styleText));
+ findings.push(...scanCssTextForBuriedRaster(styleText));
+
// Auto-scrolling marquees (