mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-21 02:26:31 +03:00
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
7d29aaca1b
commit
00d485659a
@@ -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": ["<all_urls>"],
|
||||
"background": {
|
||||
|
||||
+1
-1
@@ -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": [
|
||||
|
||||
+11
-15
@@ -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)`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
+21
@@ -97,6 +97,27 @@
|
||||
<h3 style="color: rgb(220, 38, 38); font-size: 1.25rem; margin: 0;">Red heading — not AI purple</h3>
|
||||
<h3 style="color: rgb(180, 83, 9); font-size: 1.25rem; margin: 8px 0 0;">Amber heading — distinctive</h3>
|
||||
|
||||
<h3>Background-image url() ancestor (not low contrast)</h3>
|
||||
<!-- White text on transparent bg where ancestor has a background-image.
|
||||
The detector cannot determine the image color, so it must NOT assume
|
||||
white and report a false low-contrast finding. -->
|
||||
<div data-test="bg-image-ancestor" style="background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPj/HwADBwIAMCbHYQAAAABJRU5ErkJggg=='); background-size: cover; padding: 20px;">
|
||||
<p style="color: rgb(255, 255, 255); font-size: 16px;">White text on image background — should not flag low-contrast</p>
|
||||
</div>
|
||||
|
||||
<h3>Tailwind opacity-modified bg-black (not pure black)</h3>
|
||||
<!-- bg-black/N classes are NOT pure black — they apply an alpha modifier.
|
||||
The detector must not match these as pure-black-white. -->
|
||||
<div class="bg-black/3 p-4 rounded card" data-test="bg-black-opacity" style="background: rgba(0,0,0,0.03);">
|
||||
<p>bg-black/3 — 3% opacity, not pure black</p>
|
||||
</div>
|
||||
<div class="hover:bg-black/5 p-4 rounded card" data-test="bg-black-hover-opacity" style="background: rgba(0,0,0,0);">
|
||||
<p>hover:bg-black/5 — hover state, not pure black</p>
|
||||
</div>
|
||||
<div class="bg-black/50 p-4 rounded card" data-test="bg-black-half-opacity" style="background: rgba(0,0,0,0.5);">
|
||||
<p style="color: white;">bg-black/50 — 50% opacity, not pure black</p>
|
||||
</div>
|
||||
|
||||
<h3>Emoji on light backgrounds</h3>
|
||||
<!-- Emojis render as multicolor glyphs regardless of CSS color, so the
|
||||
CSS color is irrelevant for contrast. These should NOT be flagged. -->
|
||||
|
||||
Reference in New Issue
Block a user