From 00d485659af82982aef0328d0419c49a2716d123 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Fri, 10 Apr 2026 12:57:06 -0700 Subject: [PATCH] Fix false positives: bg-black opacity modifiers and background-image contrast Two detector bugs that produced false positives on sites like uselinkshot.com: 1. The bg-black regex matched Tailwind opacity modifiers (bg-black/3, hover:bg-black/5) because / is a word boundary. Added negative lookahead. 2. resolveBackground ignored url() background-images, walking past them to the body's white bg. White text on a dark hero image was flagged as 1.0:1 white-on-white. Now bails on url() images like it does for gradients. Also: extension build auto-generates dist/extension.zip, version bumps for CLI (2.1.7) and extension (1.0.1). Co-Authored-By: Claude Opus 4.6 (1M context) --- extension/manifest.json | 2 +- package.json | 2 +- scripts/build-extension.js | 26 +++++++-------- src/detect-antipatterns-browser.js | 12 ++++--- src/detect-antipatterns.mjs | 12 ++++--- tests/detect-antipatterns-fixtures.test.mjs | 35 +++++++++++++++++++++ tests/fixtures/antipatterns/color.html | 21 +++++++++++++ 7 files changed, 83 insertions(+), 27 deletions(-) diff --git a/extension/manifest.json b/extension/manifest.json index c1ae628e4..2a9bb5eea 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Impeccable", "description": "Detect common UI anti-patterns in any web page", - "version": "1.0.0", + "version": "1.0.1", "permissions": ["activeTab", "scripting", "storage", "webNavigation"], "host_permissions": [""], "background": { diff --git a/package.json b/package.json index 700204b2a..a5bcb7c6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impeccable", - "version": "2.1.6", + "version": "2.1.7", "author": "Paul Bakaus", "description": "Design skills, commands, and anti-pattern detection for AI coding agents", "keywords": [ diff --git a/scripts/build-extension.js b/scripts/build-extension.js index 8ea871927..252fc3fdc 100644 --- a/scripts/build-extension.js +++ b/scripts/build-extension.js @@ -5,10 +5,9 @@ * * 1. Generates the extension variant of the browser detector * 2. Extracts antipatterns.json for the panel UI - * 3. Optionally packages as a .zip for Chrome Web Store + * 3. Packages as extension.zip for Chrome Web Store upload * * Run: node scripts/build-extension.js - * node scripts/build-extension.js --zip */ import fs from 'fs'; @@ -73,17 +72,14 @@ if (apMatch) { // --- 3. Zip packaging --- -if (process.argv.includes('--zip')) { - const archiver = (await import('archiver')).default; - const zipPath = path.join(ROOT, 'dist/impeccable-extension.zip'); - fs.mkdirSync(path.dirname(zipPath), { recursive: true }); +import { execSync } from 'child_process'; - const zipStream = fs.createWriteStream(zipPath); - const archive = archiver('zip', { zlib: { level: 9 } }); - archive.pipe(zipStream); - archive.directory(EXT_DIR, false); - - await archive.finalize(); - const size = fs.statSync(zipPath).size; - console.log(`Packaged ${path.relative(ROOT, zipPath)} (${(size / 1024).toFixed(1)} KB)`); -} +const zipPath = path.join(ROOT, 'dist/extension.zip'); +fs.mkdirSync(path.join(ROOT, 'dist'), { recursive: true }); +try { fs.unlinkSync(zipPath); } catch {} +execSync( + `zip -r ${JSON.stringify(zipPath)} . -x "STORE_LISTING.md" ".DS_Store"`, + { cwd: EXT_DIR, stdio: 'pipe' }, +); +const size = fs.statSync(zipPath).size; +console.log(`Packaged ${path.relative(ROOT, zipPath)} (${(size / 1024).toFixed(1)} KB)`); diff --git a/src/detect-antipatterns-browser.js b/src/detect-antipatterns-browser.js index 40e3e9f56..550cdf17d 100644 --- a/src/detect-antipatterns-browser.js +++ b/src/detect-antipatterns-browser.js @@ -516,7 +516,7 @@ function checkColors(opts) { // Tailwind class checks if (classList) { const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' '); - if (/\bbg-black\b/.test(classStr)) { + if (/\bbg-black\b(?!\/)/.test(classStr)) { findings.push({ id: 'pure-black-white', snippet: 'bg-black' }); } @@ -822,9 +822,11 @@ function resolveBackground(el, win) { while (current && current.nodeType === 1) { const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current); - // If this element has a gradient background, it's opaque but we can't determine the color + // If this element has a background-image (gradient or url), it's visually + // opaque but we can't determine the effective color — bail out so callers + // don't get a false solid-color answer. const bgImage = style.backgroundImage || ''; - if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) { + if (bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage))) { return null; } @@ -834,8 +836,8 @@ function resolveBackground(el, win) { const rawStyle = current.getAttribute?.('style') || ''; const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i); const inlineBg = bgMatch ? bgMatch[1].trim() : ''; - // Check for gradient in inline style too - if (/gradient/i.test(inlineBg)) return null; + // Check for gradient or url() image in inline style too + if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return null; bg = parseRgb(inlineBg); if (!bg && inlineBg) { const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i); diff --git a/src/detect-antipatterns.mjs b/src/detect-antipatterns.mjs index c842a1b9a..66f45ccb6 100644 --- a/src/detect-antipatterns.mjs +++ b/src/detect-antipatterns.mjs @@ -511,7 +511,7 @@ function checkColors(opts) { // Tailwind class checks if (classList) { const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' '); - if (/\bbg-black\b/.test(classStr)) { + if (/\bbg-black\b(?!\/)/.test(classStr)) { findings.push({ id: 'pure-black-white', snippet: 'bg-black' }); } @@ -817,9 +817,11 @@ function resolveBackground(el, win) { while (current && current.nodeType === 1) { const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current); - // If this element has a gradient background, it's opaque but we can't determine the color + // If this element has a background-image (gradient or url), it's visually + // opaque but we can't determine the effective color — bail out so callers + // don't get a false solid-color answer. const bgImage = style.backgroundImage || ''; - if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) { + if (bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage))) { return null; } @@ -829,8 +831,8 @@ function resolveBackground(el, win) { const rawStyle = current.getAttribute?.('style') || ''; const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i); const inlineBg = bgMatch ? bgMatch[1].trim() : ''; - // Check for gradient in inline style too - if (/gradient/i.test(inlineBg)) return null; + // Check for gradient or url() image in inline style too + if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return null; bg = parseRgb(inlineBg); if (!bg && inlineBg) { const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i); diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs index 22ceae8ab..21fd9672e 100644 --- a/tests/detect-antipatterns-fixtures.test.mjs +++ b/tests/detect-antipatterns-fixtures.test.mjs @@ -59,6 +59,41 @@ describe('detectHtml — jsdom fixtures', () => { ); }); + it('color: white text on background-image url() ancestor is not flagged as low-contrast', async () => { + const f = await detectHtml(path.join(FIXTURES, 'color.html')); + // The pass column has white text on a div with background-image: url(). + // The detector can't know the image color, so it must not assume the body + // bg and report a false low-contrast finding (#ffffff on #fafafa). + const falsePositive = f.filter(r => + r.antipattern === 'low-contrast' && + /#ffffff on #fafafa/i.test(r.snippet || '') + ); + assert.equal( + falsePositive.length, 0, + `expected no low-contrast from bg-image ancestor, got: ${falsePositive.map(r => r.snippet).join('; ')}` + ); + }); + + it('color: Tailwind bg-black/N opacity modifiers are not flagged as pure-black-white', async () => { + const f = await detectHtml(path.join(FIXTURES, 'color.html')); + // The pass column has bg-black/3, hover:bg-black/5, bg-black/50 — none are pure black. + // Only the flag column's literal bg-black class should trigger pure-black-white. + const pureBlackFindings = f.filter(r => r.antipattern === 'pure-black-white'); + const opacityFalsePositives = pureBlackFindings.filter(r => + (r.snippet || '').includes('bg-black') && + f.some(() => true) // check that bg-black/N class triggers are absent + ); + // There should be exactly the flag-column hits (bg-black class + #000000 inline) + // and zero from the pass-column opacity variants. + // The pass-column elements have data-test attributes starting with "bg-black-" + // The Tailwind class check produces snippet "bg-black" — count those. + const twSnippets = pureBlackFindings.filter(r => (r.snippet || '') === 'bg-black'); + assert.equal( + twSnippets.length, 1, + `expected exactly 1 Tailwind bg-black finding (flag column only), got ${twSnippets.length}: ${twSnippets.map(r => r.snippet).join('; ')}` + ); + }); + it('color: emoji-only text is never flagged as low-contrast', async () => { // Emojis render as multicolor glyphs regardless of CSS `color`, so the // CSS text color is irrelevant for contrast. The fixture's emoji cards diff --git a/tests/fixtures/antipatterns/color.html b/tests/fixtures/antipatterns/color.html index 1c0a687b7..07f8a55d1 100644 --- a/tests/fixtures/antipatterns/color.html +++ b/tests/fixtures/antipatterns/color.html @@ -97,6 +97,27 @@

Red heading — not AI purple

Amber heading — distinctive

+

Background-image url() ancestor (not low contrast)

+ +
+

White text on image background — should not flag low-contrast

+
+ +

Tailwind opacity-modified bg-black (not pure black)

+ +
+

bg-black/3 — 3% opacity, not pure black

+
+
+

hover:bg-black/5 — hover state, not pure black

+
+
+

bg-black/50 — 50% opacity, not pure black

+
+

Emoji on light backgrounds