Detector architecture v2: static engine, benchmarks, lab, and visual contrast (#156)

* Add detector benchmark lab and visual contrast fallback

* Expand visual contrast fixture coverage

* Add browser visual contrast fallback

* Show visual contrast overlays in detector lab

* Fix detector lab short viewport layout

* Fix detector lab visual overlays

* Add visual contrast to browser scan overlays

* Avoid browser scroll jumps during visual contrast scans

* Resolve visual contrast lazily on scroll

* Refresh detector lab visual counts lazily

* Update pnpm lockfile for static parser deps

* Address Bugbot detector API comments

* Report extension visual contrast errors

* Refactor detector into engine modules

* Address Bugbot detector comments

* Fix latest Bugbot detector notes

* Fix visual contrast fixture labels

* Refine detector lab fixtures

* Fix stale detector overlay references

* Fix detector lab fixture URLs

* Fix typography lab fixture highlights

* Fix typography lab page-level signal

* Fix visual overlay lifecycle cleanup

* Remove dead spotlight timer cleanup

* Make browser async APIs reject consistently
This commit is contained in:
Paul Bakaus
2026-05-17 19:49:38 -07:00
committed by GitHub
parent 4af581e23f
commit e1d3ea0b6f
46 changed files with 11480 additions and 4915 deletions
+552 -14
View File
@@ -1,11 +1,10 @@
/**
* Puppeteer-backed fixture tests for browser-only detection rules.
*
* Some detection rules (cramped-padding, line-length, tight-leading,
* skipped-heading, justified-text, tiny-text, all-caps-body, wide-tracking,
* small-target) need real browser layout — they read getBoundingClientRect
* and getComputedStyle results that jsdom can't compute. Those rules can't
* be tested with the jsdom suite in detect-antipatterns-fixtures.test.mjs.
* Some detection rules (cramped-padding, line-length, body-text-viewport-edge)
* need real browser layout — they read getBoundingClientRect and real
* getComputedStyle results that the static HTML/CSS engine intentionally
* does not invent.
*
* This file uses detectUrl() (Puppeteer) to load fixtures in headless Chrome
* via a temporary static HTTP server, so the fixtures can use absolute
@@ -20,12 +19,10 @@ import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { detectUrl } from '../cli/engine/detect-antipatterns.mjs';
import { createBrowserDetector, detectUrl } from '../cli/engine/detect-antipatterns.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const PORT = 8765;
const BASE = `http://localhost:${PORT}`;
const MIME = {
'.html': 'text/html; charset=utf-8',
@@ -37,6 +34,7 @@ const MIME = {
};
let server;
let baseUrl;
before(async () => {
// Static server: maps /fixtures/* to tests/fixtures/* and
@@ -61,22 +59,29 @@ before(async () => {
res.writeHead(404).end();
}
});
await new Promise((resolve) => server.listen(PORT, resolve));
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.off('error', reject);
baseUrl = `http://127.0.0.1:${server.address().port}`;
resolve();
});
});
});
after(async () => {
await new Promise((resolve) => server.close(resolve));
if (server?.listening) await new Promise((resolve) => server.close(resolve));
});
describe('detectUrl — browser-only fixtures', () => {
// Only two rules genuinely need real browser layout (getBoundingClientRect):
// line-length → reads rect.width to compute chars-per-line
// cramped-padding → reads rect.width/height to filter small badges
// Everything else in the quality.html fixture runs in jsdom and is asserted
// Everything else in the quality.html fixture runs in static HTML/CSS and is asserted
// by tests/detect-antipatterns-fixtures.test.mjs.
it('cramped-padding: flag column triggers all 8 cramped cases, pass column adds none', async () => {
const f = await detectUrl(`${BASE}/fixtures/antipatterns/cramped-padding.html`);
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/cramped-padding.html`);
const cramped = f.filter(r => r.antipattern === 'cramped-padding');
// Flag column has 8 cases that should fire under the asymmetric
// proportional rule (vertical: max(4, fs×0.3), horizontal: max(8, fs×0.5)):
@@ -94,12 +99,49 @@ describe('detectUrl — browser-only fixtures', () => {
});
it('line-length: flag column triggers, pass column adds none', async () => {
const f = await detectUrl(`${BASE}/fixtures/antipatterns/quality.html`);
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/quality.html`);
assert.equal(f.filter(r => r.antipattern === 'line-length').length, 1);
});
it('typography side-by-side: element-level flag cases get regular overlays', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/typography.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
await page.evaluate(browserScript);
const result = await page.evaluate(() => {
const groups = window.impeccableScan();
const types = groups.flatMap(group => group.findings.map(finding => finding.type || finding.id));
return {
types,
pageTypes: groups
.filter(group => group.el === document.body || group.el === document.documentElement)
.flatMap(group => group.findings.map(finding => finding.type || finding.id)),
hasBanner: Boolean(document.querySelector('.impeccable-banner')),
overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
};
});
for (const id of ['tight-leading', 'tiny-text', 'all-caps-body', 'wide-tracking', 'justified-text']) {
assert.ok(result.types.includes(id), `expected browser typography scan to include ${id}: ${JSON.stringify(result)}`);
}
assert.ok(result.pageTypes.includes('overused-font'), `expected browser typography scan to include page-level overused-font: ${JSON.stringify(result)}`);
assert.equal(result.hasBanner, true, `expected page-level typography banner: ${JSON.stringify(result)}`);
assert.ok(result.overlays >= 5, `expected visible typography overlays, got: ${JSON.stringify(result)}`);
await page.close();
} finally {
await browser.close().catch(() => {});
}
});
it('body-text-viewport-edge: 3 flag paragraphs/list-items, 0 pass cases', async () => {
const f = await detectUrl(`${BASE}/fixtures/antipatterns/body-text-viewport-edge.html`);
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/body-text-viewport-edge.html`);
const edges = f.filter(r => r.antipattern === 'body-text-viewport-edge');
// Fixture has 3 escape-styled <p>/<li> paragraphs that bleed to
// the viewport edges. The pass column has 5 paragraphs that
@@ -107,4 +149,500 @@ describe('detectUrl — browser-only fixtures', () => {
// inside section with own background, short label < 40 chars).
assert.equal(edges.length, 3, `expected 3 body-text-viewport-edge findings, got ${edges.length}: ${JSON.stringify(edges.map(e => e.snippet))}`);
});
it('visual contrast: browser fallback catches low contrast on image backgrounds', async () => {
const analyticOnly = await detectUrl(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, {
waitUntil: 'load',
visualContrast: false,
});
assert.equal(
analyticOnly.some(r => r.antipattern === 'low-contrast' && /White text on light image/i.test(r.snippet || '')),
false,
'analytic contrast should not guess image-background contrast',
);
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, {
waitUntil: 'load',
visualContrast: true,
visualContrastMaxCandidates: 20,
});
const visualFindings = f.filter(r =>
r.antipattern === 'low-contrast' &&
/(?:browser|pixel) contrast/i.test(r.snippet || '')
);
assert.equal(
visualFindings.length,
4,
`expected 4 visual contrast findings, got ${visualFindings.length}: ${JSON.stringify(visualFindings.map(r => r.snippet))}`,
);
assert.ok(
f.some(r =>
r.antipattern === 'low-contrast' &&
/(?:browser|pixel) contrast/i.test(r.snippet || '') &&
/White text on light image/i.test(r.snippet || '')
),
`expected visual contrast finding for light image background, got: ${JSON.stringify(f.map(r => r.snippet))}`,
);
assert.ok(
f.some(r => r.antipattern === 'low-contrast' && /Dark text on dark image/i.test(r.snippet || '')),
'expected pixel contrast finding for dark text on dark image',
);
assert.ok(
f.some(r => r.antipattern === 'low-contrast' && /Translucent white text on a pale pattern/i.test(r.snippet || '')),
'expected pixel contrast finding for translucent text on pale pattern',
);
assert.ok(
f.some(r => r.antipattern === 'low-contrast' && /Muted gray text on a misty image/i.test(r.snippet || '')),
'expected pixel contrast finding for muted gray text on misty image',
);
assert.equal(
f.some(r => r.antipattern === 'low-contrast' && /White text on dark image/i.test(r.snippet || '')),
false,
'dark image background should keep enough contrast',
);
assert.equal(
f.some(r => r.antipattern === 'low-contrast' && /Dark text on light image/i.test(r.snippet || '')),
false,
'light image with dark text should keep enough contrast',
);
assert.equal(
f.some(r => r.antipattern === 'low-contrast' && /Should (?:flag|pass) after pixel sampling/i.test(r.snippet || '')),
false,
'fixture column headings should not be low-contrast findings',
);
});
it('browser API: visual contrast fallback resolves readable image backgrounds without overlays', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
await page.evaluate(browserScript);
const result = await page.evaluate(async () => {
const before = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
const analyses = await window.impeccableAnalyzeVisualContrast({ maxCandidates: 20, scrollOffscreen: true });
const after = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
return {
before,
after,
failed: analyses.filter(item => item.status === 'fail').map(item => item.finding?.snippet || ''),
passed: analyses.filter(item => item.status === 'pass').map(item => item.text || ''),
unresolved: analyses.filter(item => item.status === 'unresolved').map(item => item.reason || ''),
};
});
assert.equal(result.before, 0);
assert.equal(result.after, 0);
assert.equal(result.failed.length, 4, `expected 4 browser visual failures, got: ${JSON.stringify(result)}`);
assert.ok(result.failed.some(snippet => /White text on light image/i.test(snippet)));
assert.ok(result.failed.some(snippet => /Dark text on dark image/i.test(snippet)));
assert.ok(result.failed.every(snippet => /browser contrast/i.test(snippet)));
assert.ok(result.passed.some(text => /White text on dark image/i.test(text)));
assert.ok(result.passed.some(text => /Dark text on light image/i.test(text)));
} finally {
await browser.close().catch(() => {});
}
});
it('browser API: visual contrast scan decorates visible findings without scrolling by default', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
await page.evaluate(browserScript);
const result = await page.evaluate(async () => {
let scrollEvents = 0;
let maxScrollY = window.scrollY;
window.addEventListener('scroll', () => {
scrollEvents += 1;
maxScrollY = Math.max(maxScrollY, window.scrollY);
}, { passive: true });
const syncScanResult = window.impeccableScan({
visualContrast: true,
visualContrastMaxCandidates: 20,
});
const syncDetectResult = window.impeccableDetect({
visualContrast: true,
serialize: true,
});
const groups = await window.impeccableScanAsync({
visualContrast: true,
visualContrastMaxCandidates: 20,
});
await new Promise(resolve => setTimeout(resolve, 50));
return {
groups: groups.map(group => ({
text: group.el.textContent || '',
types: group.findings.map(finding => finding.type || finding.id),
})),
overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
labels: document.querySelectorAll('.impeccable-label').length,
analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
scrollEvents,
maxScrollY,
finalScrollY: window.scrollY,
syncScanIsArray: Array.isArray(syncScanResult),
syncDetectIsArray: Array.isArray(syncDetectResult),
hasAsyncApi: typeof window.impeccableScanAsync === 'function' && typeof window.impeccableDetectAsync === 'function',
};
});
const visualGroups = result.groups.filter(group =>
group.types.includes('low-contrast') &&
/(?:White text on light image|Dark text on dark image|Translucent white text|Muted gray text)/i.test(group.text)
);
assert.equal(result.analyses, 3, `expected 3 viewport visual failures, got: ${JSON.stringify(result)}`);
assert.equal(visualGroups.length, 3, `expected 3 viewport visual groups, got: ${JSON.stringify(result)}`);
assert.ok(result.overlays >= 3, `expected regular overlays for visible visual findings, got: ${JSON.stringify(result)}`);
assert.ok(result.labels >= 3, `expected regular labels for visible visual findings, got: ${JSON.stringify(result)}`);
assert.equal(result.maxScrollY, 0, `visual scan should not scroll the page by default: ${JSON.stringify(result)}`);
assert.equal(result.finalScrollY, 0, `visual scan should preserve scroll by default: ${JSON.stringify(result)}`);
assert.equal(result.syncScanIsArray, true, `impeccableScan should keep a synchronous Array return: ${JSON.stringify(result)}`);
assert.equal(result.syncDetectIsArray, true, `impeccableDetect should keep a synchronous Array return: ${JSON.stringify(result)}`);
assert.equal(result.hasAsyncApi, true, `visual contrast should expose explicit async APIs: ${JSON.stringify(result)}`);
const refreshedOverlayResult = await page.evaluate(async () => {
window.scrollTo(0, 0);
const target = [...document.querySelectorAll('p')]
.find(node => /White text on light image should be sampled/i.test(node.textContent || ''));
target.style.fontSize = '10px';
const initialGroups = window.impeccableScan({
visualContrast: true,
visualContrastMaxCandidates: 20,
});
const initialTargetGroup = initialGroups.find(group => group.el === target);
const deadline = Date.now() + 1000;
while (
Date.now() < deadline &&
!/low contrast/i.test(target?._impeccableOverlay?.textContent || '')
) {
const nextButton = target?._impeccableOverlay?.querySelector('button:last-of-type');
if (nextButton) nextButton.click();
await new Promise(resolve => setTimeout(resolve, 25));
}
const labelVariants = [];
const overlay = target?._impeccableOverlay;
for (let i = 0; i < 3; i++) {
labelVariants.push(overlay?.textContent || '');
overlay?.querySelector('button:last-of-type')?.click();
await new Promise(resolve => setTimeout(resolve, 0));
}
return {
initialTypes: initialTargetGroup?.findings.map(finding => finding.type || finding.id) || [],
labelText: target?._impeccableOverlay?.textContent || '',
labelVariants,
overlayConnected: Boolean(target?._impeccableOverlay?.isConnected),
};
});
assert.ok(refreshedOverlayResult.initialTypes.includes('tiny-text'), `test setup should create an initial sync overlay on the target: ${JSON.stringify(refreshedOverlayResult)}`);
assert.ok(refreshedOverlayResult.labelVariants.some(text => /tiny body text/i.test(text)), `expected refreshed overlay to keep the sync finding label: ${JSON.stringify(refreshedOverlayResult)}`);
assert.ok(refreshedOverlayResult.labelVariants.some(text => /low contrast/i.test(text)), `expected visual contrast to refresh the existing overlay label: ${JSON.stringify(refreshedOverlayResult)}`);
assert.equal(refreshedOverlayResult.overlayConnected, true, `expected refreshed overlay to stay connected: ${JSON.stringify(refreshedOverlayResult)}`);
const lazyResult = await page.evaluate(async () => {
const target = [...document.querySelectorAll('p')]
.find(node => /Muted gray text on a misty image/i.test(node.textContent || ''));
target?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
await new Promise(resolve => setTimeout(resolve, 250));
return {
overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
labels: document.querySelectorAll('.impeccable-label').length,
analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
targetHasOverlay: Boolean(target?._impeccableOverlay),
scrollY: window.scrollY,
};
});
assert.equal(lazyResult.analyses, 4, `expected lazy visual resolution after scrolling into view, got: ${JSON.stringify(lazyResult)}`);
assert.ok(lazyResult.overlays >= 4, `expected lazy visual overlay after scrolling into view, got: ${JSON.stringify(lazyResult)}`);
assert.ok(lazyResult.labels >= 4, `expected lazy visual label after scrolling into view, got: ${JSON.stringify(lazyResult)}`);
assert.equal(lazyResult.targetHasOverlay, true, `expected lazy visual target to get a regular overlay, got: ${JSON.stringify(lazyResult)}`);
assert.ok(lazyResult.scrollY > 0, `test should have naturally scrolled to the offscreen case: ${JSON.stringify(lazyResult)}`);
const staleOverlayResult = await page.evaluate(async () => {
const target = [...document.querySelectorAll('p')]
.find(node => /Muted gray text on a misty image/i.test(node.textContent || ''));
window.scrollTo(0, 0);
await new Promise(resolve => setTimeout(resolve, 50));
await window.impeccableScanAsync({
visualContrast: true,
visualContrastMaxCandidates: 20,
});
const staleCleared = !target?._impeccableOverlay;
target?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
await new Promise(resolve => setTimeout(resolve, 250));
return {
staleCleared,
targetHasOverlay: Boolean(target?._impeccableOverlay),
targetOverlayConnected: Boolean(target?._impeccableOverlay?.isConnected),
overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
};
});
assert.equal(staleOverlayResult.staleCleared, true, `expected clearOverlays to remove stale target overlay refs, got: ${JSON.stringify(staleOverlayResult)}`);
assert.equal(staleOverlayResult.targetHasOverlay, true, `expected lazy visual target to be highlightable after a rescan, got: ${JSON.stringify(staleOverlayResult)}`);
assert.equal(staleOverlayResult.targetOverlayConnected, true, `expected lazy visual overlay after rescan to be connected, got: ${JSON.stringify(staleOverlayResult)}`);
const offscreenResult = await page.evaluate(async () => {
window.scrollTo(0, 0);
let maxScrollY = window.scrollY;
window.addEventListener('scroll', () => {
maxScrollY = Math.max(maxScrollY, window.scrollY);
}, { passive: true });
const groups = await window.impeccableScanAsync({
visualContrast: true,
visualContrastMaxCandidates: 20,
visualContrastScrollOffscreen: true,
});
await new Promise(resolve => setTimeout(resolve, 50));
return {
groups: groups.map(group => ({
text: group.el.textContent || '',
types: group.findings.map(finding => finding.type || finding.id),
})),
analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
maxScrollY,
finalScrollY: window.scrollY,
};
});
const offscreenVisualGroups = offscreenResult.groups.filter(group =>
group.types.includes('low-contrast') &&
/(?:White text on light image|Dark text on dark image|Translucent white text|Muted gray text)/i.test(group.text)
);
assert.equal(offscreenResult.analyses, 4, `expected 4 opt-in visual failures, got: ${JSON.stringify(offscreenResult)}`);
assert.equal(offscreenVisualGroups.length, 4, `expected 4 opt-in visual groups, got: ${JSON.stringify(offscreenResult)}`);
assert.ok(offscreenResult.maxScrollY > 0, `offscreen opt-in should be allowed to scroll: ${JSON.stringify(offscreenResult)}`);
assert.equal(offscreenResult.finalScrollY, 0, `offscreen opt-in should restore scroll: ${JSON.stringify(offscreenResult)}`);
} finally {
await browser.close().catch(() => {});
}
});
it('extension mode remove cancels pending lazy visual contrast work', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => {
document.documentElement.dataset.impeccableExtension = 'true';
window.__impeccableMessages = [];
window.addEventListener('message', event => {
if (event.source !== window || !event.data?.source?.startsWith('impeccable-')) return;
window.__impeccableMessages.push(event.data);
});
});
await page.evaluate(browserScript);
const result = await page.evaluate(async () => {
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: {
visualContrast: true,
visualContrastMaxCandidates: 20,
},
}, '*');
const scanDeadline = Date.now() + 1000;
while (
Date.now() < scanDeadline &&
!window.impeccableGetLastVisualContrastAnalyses()
.some(item => item.status === 'unresolved' && item.reason === 'text outside viewport')
) {
await new Promise(resolve => setTimeout(resolve, 25));
}
const unresolvedBeforeRemove = window.impeccableGetLastVisualContrastAnalyses()
.filter(item => item.status === 'unresolved' && item.reason === 'text outside viewport').length;
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
await new Promise(resolve => setTimeout(resolve, 50));
const target = [...document.querySelectorAll('p')]
.find(node => /Muted gray text on a misty image/i.test(node.textContent || ''));
target?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
await new Promise(resolve => setTimeout(resolve, 300));
const resultsAfterRemove = window.__impeccableMessages
.filter(message => message.source === 'impeccable-results').length;
return {
unresolvedBeforeRemove,
overlayCount: document.querySelectorAll('.impeccable-overlay').length,
targetHasOverlay: Boolean(target?._impeccableOverlay),
resultsAfterRemove,
};
});
assert.ok(result.unresolvedBeforeRemove > 0, `test setup should leave lazy visual candidates pending: ${JSON.stringify(result)}`);
assert.equal(result.overlayCount, 0, `remove should not allow lazy visual overlays to reappear: ${JSON.stringify(result)}`);
assert.equal(result.targetHasOverlay, false, `remove should clear stale target overlay refs: ${JSON.stringify(result)}`);
await page.close();
} finally {
await browser.close().catch(() => {});
}
});
it('extension mode reports async visual contrast errors to the panel', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => {
document.documentElement.dataset.impeccableExtension = 'true';
window.__impeccableMessages = [];
window.addEventListener('message', event => {
if (event.source !== window || !event.data?.source?.startsWith('impeccable-')) return;
window.__impeccableMessages.push(event.data);
});
});
await page.evaluate(browserScript);
const result = await page.evaluate(async () => {
const originalGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function getContext() {
throw new Error('forced visual contrast canvas failure');
};
try {
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: {
visualContrast: true,
visualContrastMaxCandidates: 20,
},
}, '*');
const deadline = Date.now() + 1000;
while (
Date.now() < deadline &&
!window.__impeccableMessages.some(message => message.source === 'impeccable-error')
) {
await new Promise(resolve => setTimeout(resolve, 25));
}
return {
ready: window.__impeccableMessages.some(message => message.source === 'impeccable-ready'),
results: window.__impeccableMessages.some(message => message.source === 'impeccable-results'),
errors: window.__impeccableMessages
.filter(message => message.source === 'impeccable-error')
.map(message => message.message || ''),
};
} finally {
HTMLCanvasElement.prototype.getContext = originalGetContext;
}
});
assert.equal(result.ready, true, `expected extension ready message, got: ${JSON.stringify(result)}`);
assert.equal(result.results, true, `expected initial sync results before async visual error, got: ${JSON.stringify(result)}`);
assert.ok(
result.errors.some(message => /forced visual contrast canvas failure/.test(message)),
`expected extension visual contrast error message, got: ${JSON.stringify(result)}`,
);
await page.close();
} finally {
await browser.close().catch(() => {});
}
});
it('browser API: impeccableDetect is pure, impeccableScan decorates', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/quality.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
await page.evaluate(browserScript);
const pure = await page.evaluate(() => {
const before = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
const findings = window.impeccableDetect({ decorate: false, serialize: true });
const after = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
return { before, after, count: findings.length };
});
assert.equal(pure.before, 0);
assert.equal(pure.after, 0);
assert.ok(pure.count > 0);
const decorated = await page.evaluate(() => {
const groups = window.impeccableScan();
const overlays = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
return { groups: groups.length, overlays };
});
assert.ok(decorated.groups > 0);
assert.ok(decorated.overlays > 0);
await page.close();
} finally {
await browser.close().catch(() => {});
}
});
it('browser API: async scan and detect reject instead of throwing synchronously', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/quality.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
await page.evaluate(browserScript);
const result = await page.evaluate(async () => {
const originalQuerySelectorAll = Document.prototype.querySelectorAll;
Document.prototype.querySelectorAll = function querySelectorAll() {
throw new Error('forced query failure');
};
try {
const scan = await window.impeccableScanAsync().then(
() => ({ state: 'resolved' }),
error => ({ state: 'rejected', message: error?.message || String(error) }),
);
const detect = await window.impeccableDetectAsync().then(
() => ({ state: 'resolved' }),
error => ({ state: 'rejected', message: error?.message || String(error) }),
);
return { scan, detect };
} finally {
Document.prototype.querySelectorAll = originalQuerySelectorAll;
}
});
assert.deepEqual(result.scan, { state: 'rejected', message: 'forced query failure' });
assert.deepEqual(result.detect, { state: 'rejected', message: 'forced query failure' });
await page.close();
} finally {
await browser.close().catch(() => {});
}
});
it('createBrowserDetector reuses a browser and honors waitUntil overrides', async () => {
const detector = await createBrowserDetector({ waitUntil: 'load', settleMs: 0 });
try {
const first = await detector.detectUrl(`${baseUrl}/fixtures/antipatterns/quality.html`);
const second = await detector.detectUrl(`${baseUrl}/fixtures/antipatterns/body-text-viewport-edge.html`, {
waitUntil: 'domcontentloaded',
});
assert.ok(first.some(r => r.antipattern === 'line-length'));
assert.equal(second.filter(r => r.antipattern === 'body-text-viewport-edge').length, 3);
} finally {
await detector.close();
}
});
});
+55 -18
View File
@@ -1,6 +1,6 @@
/**
* jsdom fixture tests for anti-pattern detection.
* Run via Node's built-in test runner (not bun) to avoid jsdom resource limits.
* Static HTML/CSS fixture tests for anti-pattern detection.
* Run via Node's built-in test runner (not bun).
*
* Usage: node --test tests/detect-antipatterns-fixtures.test.mjs
*/
@@ -15,7 +15,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURES = path.join(__dirname, 'fixtures', 'antipatterns');
describe('detectHtml — jsdom fixtures', () => {
describe('detectHtml — static HTML/CSS fixtures', () => {
it('should-flag: catches border anti-patterns', async () => {
const f = await detectHtml(path.join(FIXTURES, 'should-flag.html'));
assert.ok(f.some(r => r.antipattern === 'side-tab'));
@@ -27,11 +27,32 @@ describe('detectHtml — jsdom fixtures', () => {
assert.equal(f.filter(r => r.antipattern === 'side-tab' || r.antipattern === 'border-accent-on-rounded').length, 0);
});
it('border-baseline: paired side-tab fixture flags only the positive column', async () => {
const f = await detectHtml(path.join(FIXTURES, 'border-baseline.html'));
const sideTabs = f.filter(r => r.antipattern === 'side-tab');
const accents = f.filter(r => r.antipattern === 'border-accent-on-rounded');
assert.equal(
sideTabs.length,
4,
`expected 4 side-tab findings, got ${sideTabs.length}: ${sideTabs.map(r => r.snippet).join('; ')}`
);
assert.equal(
accents.length,
2,
`expected 2 rounded accent findings, got ${accents.length}: ${accents.map(r => r.snippet).join('; ')}`
);
});
it('linked-stylesheet: catches borders, no false positives', async () => {
const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html'));
assert.ok(f.some(r => r.antipattern === 'side-tab'));
assert.ok(f.some(r => r.antipattern === 'border-accent-on-rounded'));
assert.equal(f.filter(r => r.snippet?.includes('clean')).length, 0);
assert.equal(
f.filter(r => r.antipattern !== 'side-tab' && r.antipattern !== 'border-accent-on-rounded').length,
0,
`expected only border findings, got: ${f.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`
);
});
it('partial-component: flags borders, skips page-level', async () => {
@@ -48,6 +69,11 @@ describe('detectHtml — jsdom fixtures', () => {
assert.ok(f.some(r => r.antipattern === 'low-contrast'), 'expected low-contrast');
assert.ok(f.some(r => r.antipattern === 'gradient-text'), 'expected gradient-text');
assert.ok(f.some(r => r.antipattern === 'ai-color-palette'), 'expected ai-color-palette');
assert.equal(
f.some(r => r.antipattern === 'pure-black-white' && /#ffffff|#fff/i.test(r.snippet || '')),
false,
'pure white surfaces with dark text should remain allowed',
);
// Gradient-bg + gray text case (added with the gradient-fix patch)
assert.ok(
f.some(r => r.antipattern === 'low-contrast' && /#808080|#3b82f6|#8b5cf6/i.test(r.snippet || '')),
@@ -163,10 +189,9 @@ describe('detectHtml — jsdom fixtures', () => {
);
});
it('legitimate-borders: minimal false positives', async () => {
it('legitimate-borders: zero findings', async () => {
const f = await detectHtml(path.join(FIXTURES, 'legitimate-borders.html'));
const borderFindings = f.filter(r => r.antipattern === 'side-tab' || r.antipattern === 'border-accent-on-rounded');
assert.ok(borderFindings.length <= 1);
assert.equal(f.length, 0, `expected no findings, got: ${f.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`);
});
it('modern-color-borders: oklch/oklab/lch/lab side-tabs are flagged, neutrals pass', async () => {
@@ -217,6 +242,20 @@ describe('detectHtml — jsdom fixtures', () => {
assert.ok(f.some(r => r.antipattern === 'overused-font'));
assert.ok(f.some(r => r.antipattern === 'single-font'));
assert.ok(f.some(r => r.antipattern === 'flat-type-hierarchy'));
assert.equal(
f.some(r => r.antipattern === 'low-contrast'),
false,
`typography fixture should not contain incidental contrast findings: ${f.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`
);
});
it('typography: side-by-side page has visible element-level flag cases', async () => {
const f = await detectHtml(path.join(FIXTURES, 'typography.html'));
const ids = new Set(f.map(r => r.antipattern));
for (const id of ['tight-leading', 'tiny-text', 'all-caps-body', 'wide-tracking', 'justified-text']) {
assert.ok(ids.has(id), `expected typography side-by-side fixture to include ${id}`);
}
assert.ok(ids.has('overused-font'), 'expected typography side-by-side fixture to include a page-level overused-font finding');
});
it('typography-should-pass: zero findings', async () => {
@@ -265,13 +304,13 @@ describe('detectHtml — icon-tile-stack', () => {
});
});
describe('detectHtml — quality (jsdom-compatible rules)', () => {
// Six of the eight quality rules can run in jsdom because they only need
describe('detectHtml — quality (static-compatible rules)', () => {
// Six of the eight quality rules can run in static HTML/CSS because they only need
// computed CSS values (tight-leading, tiny-text, justified-text,
// all-caps-body, wide-tracking) or pure DOM walks (skipped-heading).
// The other two (line-length, cramped-padding) need real layout rects and
// live in tests/detect-antipatterns-browser.test.mjs (Puppeteer-backed).
it('quality: flag column triggers all 6 jsdom-compatible quality rules', async () => {
it('quality: flag column triggers all 6 static-compatible quality rules', async () => {
const f = await detectHtml(path.join(FIXTURES, 'quality.html'));
assert.equal(f.filter(r => r.antipattern === 'tight-leading').length, 1);
assert.equal(f.filter(r => r.antipattern === 'tiny-text').length, 1);
@@ -288,8 +327,8 @@ describe('detectHtml — layout', () => {
const nested = f.filter(r => r.antipattern === 'nested-cards');
assert.ok(nested.length >= 4, `expected ≥4 nested-cards findings, got ${nested.length}`);
// The page-level layout rules (monotonous-spacing, everything-centered)
// need Tailwind-via-CDN to render, which jsdom doesn't execute. They're
// effectively dormant in this test environment regardless of the fixture
// need Tailwind-via-CDN to render, which the static engine does not fetch.
// They're effectively dormant in this test environment regardless of the fixture
// contents — so all we can verify is that the pass column doesn't push
// them awake unexpectedly.
assert.equal(f.filter(r => r.antipattern === 'monotonous-spacing').length, 0);
@@ -342,7 +381,7 @@ describe('detectHtml — hero-eyebrow-chip', () => {
'Pill Chip Above Hero',
'Already Uppercase Text',
// The rule no longer gates on heading font size (modern hero h1s
// use clamp() / vw / var() that jsdom can't resolve), and the
// use clamp() / vw / var() that static HTML/CSS cannot resolve), and the
// eyebrow text ceiling moved 30 → 60 chars. Both shapes now flag.
'Body-Sized Heading Below Eyebrow',
'Long Uppercase Sentence Above Hero',
@@ -410,19 +449,17 @@ describe('detectHtml — repeated-section-kickers', () => {
});
describe('detectHtml — motion', () => {
// jsdom doesn't fully apply class-based styles, so the absolute finding counts
// are lower than what a real browser would see. The hardcoded counts below are
// the calibrated jsdom baseline — if a future change pushes them up, that's a
// pass-column false positive; if down, the rule or fixture has regressed.
// The static CSS engine applies class-based fixture styles, so it catches all
// flag-column layout-transition cases without relying on browser layout.
it('motion: flag column triggers both motion rules, pass column adds none', async () => {
const f = await detectHtml(path.join(FIXTURES, 'motion.html'));
assert.equal(f.filter(r => r.antipattern === 'bounce-easing').length, 2);
assert.equal(f.filter(r => r.antipattern === 'layout-transition').length, 2);
assert.equal(f.filter(r => r.antipattern === 'layout-transition').length, 8);
});
});
describe('detectHtml — dark glow', () => {
// Calibrated jsdom baseline — see motion test note above.
// Calibrated static baseline — see motion test note above.
it('glow: flag column triggers dark-glow, pass column adds none', async () => {
const f = await detectHtml(path.join(FIXTURES, 'glow.html'));
assert.equal(f.filter(r => r.antipattern === 'dark-glow').length, 1);
+177 -3
View File
@@ -1,10 +1,11 @@
import { describe, test, expect } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { spawnSync } from 'child_process';
import {
ANTIPATTERNS, checkElementBorders, checkElementMotion, checkElementGlow, isNeutralColor, isFullPage,
detectText, extractStyleBlocks, extractCSSinJS,
detectText, detectHtml, extractStyleBlocks, extractCSSinJS,
walkDir, SCANNABLE_EXTENSIONS,
buildImportGraph, resolveImport,
detectFrameworkConfig, isPortListening, FRAMEWORK_CONFIGS,
@@ -12,6 +13,30 @@ import {
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns');
const SCRIPT = path.join(import.meta.dir, '..', 'cli', 'engine', 'detect-antipatterns.mjs');
const BENCH_SCRIPT = path.join(import.meta.dir, '..', 'scripts', 'benchmark-detector.mjs');
function writeStaticFixture(files) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-static-'));
for (const [name, contents] of Object.entries(files)) {
const fullPath = path.join(dir, name);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, contents);
}
return { dir, file: path.join(dir, 'index.html') };
}
async function withStaticFixture(files, callback) {
const fixture = writeStaticFixture(files);
try {
return await callback(fixture);
} finally {
fs.rmSync(fixture.dir, { recursive: true, force: true });
}
}
function findingIds(findings) {
return findings.map(f => f.antipattern);
}
// ---------------------------------------------------------------------------
@@ -170,7 +195,7 @@ describe('detectText — flat type hierarchy', () => {
});
});
// jsdom fixture tests moved to detect-antipatterns-fixtures.test.mjs (run via node --test)
// Static HTML/CSS fixture tests moved to detect-antipatterns-fixtures.test.mjs (run via node --test)
// ---------------------------------------------------------------------------
// Full page vs partial detection
@@ -508,6 +533,116 @@ describe('detectText — dark glow', () => {
});
});
// ---------------------------------------------------------------------------
// Static HTML/CSS engine
// ---------------------------------------------------------------------------
describe('detectHtml — static HTML/CSS engine', () => {
test('inlines local linked stylesheets', async () => {
const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html'));
expect(findingIds(f)).toContain('side-tab');
});
test('flattens @layer, resolves CSS variables and fallbacks, and skips unsupported selectors', async () => {
await withStaticFixture({
'index.html': `<!DOCTYPE html>
<html>
<head>
<style>
@layer components {
:root { --accent: #3b82f6; --fallback-accent: var(--missing-accent, #a855f7); }
.layer-side { border-left: 5px solid var(--accent); border-radius: 8px; }
.layer-top { border-top: 4px solid var(--fallback-accent); border-radius: 8px; }
.ignored:future-only(foo) { border-left: 20px solid #ef4444; }
}
</style>
</head>
<body>
<div class="layer-side">Layer variable side tab</div>
<div class="layer-top">Fallback variable top accent</div>
</body>
</html>`,
}, async ({ file }) => {
const profile = [];
const f = await detectHtml(file, { profile });
const ids = findingIds(f);
expect(ids).toContain('side-tab');
expect(ids).toContain('border-accent-on-rounded');
expect(profile.some(e => e.engine === 'static-html' && e.ruleId === 'unsupported-selector')).toBe(true);
});
});
test('honors specificity, source order, !important, and inline style precedence', async () => {
await withStaticFixture({
'index.html': `<!DOCTYPE html>
<html>
<head>
<style>
.specificity-pass { border-left: 5px solid #3b82f6; border-radius: 8px; }
div.specificity-pass { border-left-color: #d1d5db; }
.source-order-flag { border-left: 5px solid #d1d5db; border-radius: 8px; }
.source-order-flag { border-left-color: #ef4444; }
.important-pass { border-left: 5px solid #d1d5db !important; border-radius: 8px; }
.important-pass { border-left-color: #3b82f6; }
</style>
</head>
<body>
<div class="specificity-pass">Specificity neutral pass</div>
<div class="source-order-flag">Source order chromatic flag</div>
<div class="important-pass">Important neutral pass</div>
<div style="border-left: 5px solid #06b6d4; border-radius: 8px;">Inline chromatic flag</div>
</body>
</html>`,
}, async ({ file }) => {
const f = await detectHtml(file);
expect(findingIds(f).filter(id => id === 'side-tab')).toHaveLength(2);
});
});
test('expands background, border, font, transition, and animation shorthands', async () => {
await withStaticFixture({
'index.html': `<!DOCTYPE html>
<html>
<head>
<style>
.font-short {
font: italic 700 11px/1.05 Arial, sans-serif;
}
.background-short {
background: #000;
color: #111;
font-size: 16px;
}
.border-short {
border: 1px solid #d1d5db;
border-left: 5px solid #3b82f6;
border-radius: 8px;
}
.motion-short {
transition: width 250ms cubic-bezier(.68,-.55,.27,1.55);
animation: bounce 1s cubic-bezier(.68,-.55,.27,1.55) infinite;
}
</style>
</head>
<body>
<p class="font-short">This tiny paragraph is long enough to trigger both the static font shorthand size and line-height checks.</p>
<button class="background-short">Low contrast button text</button>
<div class="border-short">Border shorthand side tab</div>
<div class="motion-short">Motion shorthand easing</div>
</body>
</html>`,
}, async ({ file }) => {
const ids = findingIds(await detectHtml(file));
expect(ids).toContain('tiny-text');
expect(ids).toContain('tight-leading');
expect(ids).toContain('low-contrast');
expect(ids).toContain('side-tab');
expect(ids).toContain('bounce-easing');
expect(ids).toContain('layout-transition');
});
});
});
// ---------------------------------------------------------------------------
// ANTIPATTERNS registry
@@ -559,6 +694,12 @@ describe('CLI', () => {
expect(stdout).toContain('Usage:');
});
test('detect subcommand is not treated as a scan target', () => {
const { stderr, code } = run('detect', '--json', path.join(FIXTURES, 'should-pass.html'));
expect(code).toBe(0);
expect(stderr).not.toContain('cannot access detect');
});
test('should-pass exits 0', () => {
const { code } = run(path.join(FIXTURES, 'should-pass.html'));
expect(code).toBe(0);
@@ -598,7 +739,7 @@ describe('CLI', () => {
expect(code).toBe(2);
});
test('linked stylesheet detected (jsdom default)', () => {
test('linked stylesheet detected (static HTML/CSS default)', () => {
const { code, stderr } = run(path.join(FIXTURES, 'linked-stylesheet.html'));
expect(code).toBe(2);
expect(stderr).toContain('side-tab');
@@ -610,6 +751,39 @@ describe('CLI', () => {
});
});
// ---------------------------------------------------------------------------
// Detector benchmark smoke test
// ---------------------------------------------------------------------------
describe('benchmark-detector', () => {
test('--quick --json emits timing schema', () => {
const result = spawnSync('node', [BENCH_SCRIPT, '--quick', '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
expect(result.status).toBe(0);
const parsed = JSON.parse(result.stdout.trim());
expect(parsed.version).toBe(1);
expect(parsed.quick).toBe(true);
expect(parsed.browser).toBe(false);
expect(parsed.cases).toBeArray();
expect(parsed.cases.length).toBeGreaterThan(0);
expect(parsed.summary).toBeArray();
expect(parsed.summary.length).toBeGreaterThan(0);
const okCase = parsed.cases.find(c => c.status === 'ok');
expect(okCase).toBeTruthy();
expect(okCase).toHaveProperty('totalMs');
expect(okCase).toHaveProperty('findings');
expect(okCase.profile).toBeArray();
const row = parsed.summary[0];
for (const key of ['engine', 'phase', 'ruleId', 'target', 'calls', 'totalMs', 'avgMs', 'p50', 'p95', 'findings']) {
expect(row).toHaveProperty(key);
}
});
});
// ---------------------------------------------------------------------------
// Tier 1: Vue/Svelte <style> block extraction
// ---------------------------------------------------------------------------
+5 -3
View File
@@ -17,14 +17,16 @@
.container-good { max-width: 720px; margin: 0 auto; padding: 16px 32px; }
.container-tight { padding: 16px 32px; }
.full-bleed-section { background: #fef3c7; padding: 24px 16px; }
nav.site-nav { padding: 12px 0; background: #f5f5f5; }
header.banner { padding: 12px 0; background: #e5e7eb; }
nav.site-nav { padding: 12px 24px; background: #f5f5f5; }
header.banner { padding: 12px 24px; background: #e5e7eb; }
/* Override the grid container's own padding so we can demonstrate
a paragraph that genuinely bleeds to the viewport edge inside
the flag column — the col itself shouldn't reset its padding. */
.flag-fullwidth-p { /* no container; placed directly in body via .escape */ }
.escape { position: relative; left: 50%; right: 50%; margin-left: -50vw; margin-right: -50vw; width: 100vw; padding: 0; }
.edge-list { margin: 0; padding: 0; list-style-position: inside; }
.edge-list li { margin: 0; }
</style>
</head>
<body>
@@ -41,7 +43,7 @@
<p>This second flush-to-edge paragraph confirms the rule fires on every body paragraph that touches the viewport, not just one. The rendered width should be the full viewport, with text starting essentially at x=0.</p>
</div>
<div class="escape">
<ul>
<ul class="edge-list">
<li>This is a list item whose text content is substantially long enough to qualify as body content. It also runs flush against the viewport edge with no left padding.</li>
</ul>
</div>
+213
View File
@@ -0,0 +1,213 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Border Anti-Patterns — Should Flag vs Should Pass</title>
<style>
body {
margin: 0;
padding: 24px;
background: #f8fafc;
color: #111827;
font: 14px/1.5 system-ui, sans-serif;
}
h1 {
max-width: 1180px;
margin: 0 auto 8px;
font-size: 34px;
line-height: 1.05;
font-weight: 720;
}
.intro {
max-width: 1180px;
margin: 0 auto 24px;
color: #4b5563;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 32px;
max-width: 1180px;
margin: 0 auto;
}
.col h2 {
margin: 0 0 14px;
color: #475569;
font-size: 13px;
font-weight: 760;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.case {
margin: 0 0 14px;
padding: 16px;
background: #fdfdfd;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.case h3 {
margin: 0 0 4px;
font-size: 15px;
line-height: 1.25;
}
.case p {
margin: 0;
color: #475569;
}
.flag-left {
border-left: 4px solid #2563eb;
border-radius: 10px;
}
.flag-right {
border-right: 5px solid #dc2626;
border-radius: 10px;
}
.flag-left-plain {
border-left: 4px solid #0f766e;
}
.flag-top {
border-top: 4px solid #7c3aed;
border-radius: 10px;
}
.flag-bottom {
border-bottom: 3px solid #ea580c;
border-radius: 10px;
}
.flag-dark {
background: #141820;
color: #f4f7fb;
border-left: 4px solid #38bdf8;
border-radius: 10px;
}
.flag-dark p {
color: #cbd5e1;
}
.pass-full-border {
border: 1px solid #cbd5e1;
border-radius: 10px;
}
.pass-neutral-side {
border-left: 4px solid #d1d5db;
border-radius: 10px;
}
.pass-thin-side {
border-left: 1px solid #2563eb;
border-radius: 10px;
}
.pass-square-top {
border-top: 4px solid #7c3aed;
border-radius: 0;
}
.pass-square-bottom {
border-bottom: 3px solid #ea580c;
border-radius: 0;
}
.pass-dark-uniform {
background: #161a22;
color: #f4f7fb;
border: 1px solid #334155;
border-radius: 10px;
}
.pass-dark-uniform p {
color: #cbd5e1;
}
</style>
</head>
<body>
<h1>Border side-tab detector cases</h1>
<p class="intro">A paired fixture for colored side borders and rounded accent borders. The left column should produce border findings; the right column should stay clean.</p>
<div class="grid">
<section class="col" data-col="flag">
<h2>Should flag</h2>
<div class="case flag-left">
<h3>Rounded card with colored left border</h3>
<p>Classic side tab: border-left is thick, colored, and paired with rounded corners.</p>
</div>
<div class="case flag-right">
<h3>Rounded card with colored right border</h3>
<p>The right-side version is the same visual trope mirrored.</p>
</div>
<div class="case flag-left-plain">
<h3>Thick colored side border without radius</h3>
<p>A thick chromatic side stripe still reads as a tab even without rounded corners.</p>
</div>
<div class="case flag-top">
<h3>Rounded card with top accent border</h3>
<p>A heavy top accent on a rounded card is the horizontal variant of the same pattern.</p>
</div>
<div class="case flag-bottom">
<h3>Rounded card with bottom accent border</h3>
<p>The bottom accent is also a decorative stripe on a card surface.</p>
</div>
<div class="case flag-dark">
<h3>Dark card with bright side stripe</h3>
<p>Dark surfaces can hide the cliche, but the thick colored side stripe remains.</p>
</div>
</section>
<section class="col" data-col="pass">
<h2>Should pass</h2>
<div class="case pass-full-border">
<h3>Uniform full border</h3>
<p>A quiet all-around border frames the card instead of creating a side tab.</p>
</div>
<div class="case pass-neutral-side">
<h3>Neutral structural side rule</h3>
<p>A gray structural rule can be a legitimate divider when it is not chromatic emphasis.</p>
</div>
<div class="case pass-thin-side">
<h3>Thin colored side rule</h3>
<p>A 1px colored rule is below the decorative side-tab threshold.</p>
</div>
<div class="case pass-square-top">
<h3>Square top border</h3>
<p>A top rule without rounded card corners is treated as a structural divider.</p>
</div>
<div class="case pass-square-bottom">
<h3>Square bottom border</h3>
<p>A bottom rule without rounded card corners is also allowed.</p>
</div>
<div class="case pass-dark-uniform">
<h3>Dark card with uniform border</h3>
<p>The dark surface has a full neutral outline, not a chromatic side stripe.</p>
</div>
</section>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+6 -4
View File
@@ -28,13 +28,10 @@
<div class="col" data-col="flag">
<h2>Should flag</h2>
<h3>Pure black &amp; white</h3>
<h3>Pure black surfaces</h3>
<div class="card" style="background: #000000; color: white;">
<p>Pure #000 background</p>
</div>
<div class="card" style="background: #ffffff;">
<p style="color: #000000;">Pure #000 text on pure #fff background</p>
</div>
<h3>Gray on color</h3>
<div class="card" style="background: rgb(59, 130, 246);">
@@ -103,6 +100,11 @@
<p style="color: rgb(20, 20, 25);">Near-white bg, near-black text — good contrast, tinted</p>
</div>
<h3>Pure white surface is acceptable</h3>
<div class="card" style="background: #ffffff;">
<p style="color: #111827;">Pure #fff background with dark text — acceptable contrast</p>
</div>
<h3>Good contrast</h3>
<div class="card" style="background: rgb(30, 64, 175);">
<p style="color: rgb(240, 240, 245);">Near-white text on dark blue — high contrast, not pure white</p>
+18
View File
@@ -31,3 +31,21 @@
border: 1px solid #e5e7eb;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* Neutral side rule — should NOT flag */
.external-neutral-side {
background: white;
padding: 1rem;
border-radius: 12px;
border-left: 4px solid #d1d5db;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* Top rule without rounded card corners — should NOT flag */
.external-square-top {
background: white;
padding: 1rem;
border-radius: 0;
border-top: 4px solid #8b5cf6;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
+4 -4
View File
@@ -38,8 +38,8 @@
<div class="demo">
<div style="margin-bottom: 1rem;">
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">Email</label>
<input type="email" value="not-an-email" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #fca5a5; border-left: 3px solid #ef4444; border-radius: 6px; outline: none; font-size: 0.875rem;">
<p style="color: #ef4444; font-size: 0.75rem; margin-top: 0.25rem;">Please enter a valid email address.</p>
<input type="email" value="not-an-email" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #fca5a5; border-left: 3px solid #b91c1c; border-radius: 6px; outline: none; font-size: 0.875rem;">
<p style="color: #b91c1c; font-size: 0.75rem; margin-top: 0.25rem;">Please enter a valid email address.</p>
</div>
</div>
@@ -59,7 +59,7 @@
</div>
<div style="position: relative;">
<div style="position: absolute; left: -2.05rem; top: 0.125rem; width: 10px; height: 10px; border-radius: 50%; background: #d1d5db; border: 2px solid white;"></div>
<p style="font-weight: 500; margin: 0; color: #9ca3af;">Delivered</p>
<p style="font-weight: 500; margin: 0; color: #4b5563;">Delivered</p>
<p style="font-size: 0.75rem; color: #6b7280; margin: 0.125rem 0 0;">Expected March 18</p>
</div>
</div>
@@ -104,7 +104,7 @@
<!-- 8. ALERT BANNER — full-width, not a card -->
<h2>Alert Banner</h2>
<div class="demo">
<div style="border-left: 4px solid #f59e0b; background: #fffbeb; padding: 0.75rem 1rem; font-size: 0.875rem; color: #92400e;">
<div style="border: 1px solid #f59e0b; background: #fffbeb; padding: 0.75rem 1rem; font-size: 0.875rem; color: #92400e;">
<strong>Warning:</strong> Your trial expires in 3 days. <a href="#" style="color: #d97706;">Upgrade now</a>
</div>
</div>
+85 -25
View File
@@ -6,11 +6,58 @@
<title>Anti-Patterns From Linked Stylesheet</title>
<link rel="stylesheet" href="external-styles.css">
<style>
body { font-family: system-ui, sans-serif; background: #f9fafb; padding: 2rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
h2 { font-size: 1.125rem; margin: 2rem 0 0.75rem; color: #6b7280; }
p.intro { color: #6b7280; margin-bottom: 2rem; max-width: 36rem; }
.cards { display: grid; gap: 1rem; max-width: 28rem; }
body {
margin: 0;
padding: 24px;
background: #f9fafb;
color: #111827;
font: 14px/1.5 system-ui, sans-serif;
}
h1 {
max-width: 1080px;
margin: 0 auto 8px;
font-size: 34px;
line-height: 1.05;
}
p.intro {
max-width: 1080px;
margin: 0 auto 24px;
color: #4b5563;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 32px;
max-width: 1080px;
margin: 0 auto;
}
.col h2 {
margin: 0 0 14px;
color: #475569;
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.cards {
display: grid;
gap: 14px;
}
h3 {
margin: 0 0 4px;
font-size: 15px;
line-height: 1.25;
}
.cards p {
margin: 0;
color: #4b5563;
}
</style>
</head>
<body>
@@ -20,28 +67,41 @@
Regex-only scanning misses these — only computed style analysis catches them.
</p>
<h2>Side-Tab (from external CSS)</h2>
<div class="cards">
<div class="external-side-tab">
<h3 style="font-weight: 600;">External side-tab class</h3>
<p style="font-size: 0.875rem; color: #6b7280;">border-left + border-radius from linked stylesheet.</p>
</div>
</div>
<div class="grid">
<section class="col" data-col="flag">
<h2>Should flag</h2>
<div class="cards">
<div class="external-side-tab">
<h3>External side-tab class</h3>
<p>border-left plus border-radius from a linked stylesheet.</p>
</div>
<h2>Top Accent + Rounded (from external CSS)</h2>
<div class="cards">
<div class="external-top-accent">
<h3 style="font-weight: 600;">External top accent class</h3>
<p style="font-size: 0.875rem; color: #6b7280;">border-top + border-radius from linked stylesheet.</p>
</div>
</div>
<div class="external-top-accent">
<h3>External top accent class</h3>
<p>border-top plus border-radius from a linked stylesheet.</p>
</div>
</div>
</section>
<h2>Clean Card (from external CSS)</h2>
<div class="cards">
<div class="external-clean">
<h3 style="font-weight: 600;">External clean card</h3>
<p style="font-size: 0.875rem; color: #6b7280;">Uniform 1px border — should NOT flag.</p>
</div>
<section class="col" data-col="pass">
<h2>Should pass</h2>
<div class="cards">
<div class="external-clean">
<h3>External clean card</h3>
<p>Uniform 1px border from the linked stylesheet.</p>
</div>
<div class="external-neutral-side">
<h3>External neutral side rule</h3>
<p>Thick side border, but the color is structural gray.</p>
</div>
<div class="external-square-top">
<h3>External square top rule</h3>
<p>Heavy top border with no rounded card corners.</p>
</div>
</div>
</section>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
+2 -25
View File
@@ -7,8 +7,8 @@
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: system-ui, sans-serif; background: #f9fafb; padding: 2rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
h2 { font-size: 1.125rem; margin: 2.5rem 0 0.75rem; color: #6b7280; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.5rem; }
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
h2 { font-size: 0.875rem; margin: 2.5rem 0 0.75rem; color: #4b5563; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.08em; }
p.intro { color: #6b7280; margin-bottom: 2rem; max-width: 36rem; }
.cards { display: grid; gap: 1rem; max-width: 28rem; }
.card { background: white; padding: 1rem; border-radius: 0.375rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
@@ -20,9 +20,6 @@
.card-shorthand-right { border-right: 5px solid #8b5cf6; }
.card-longhand-left { border-left-width: 3px; border-left-style: solid; border-left-color: #10b981; }
.card-longhand-right { border-right-width: 6px; border-right-style: solid; border-right-color: #ef4444; }
.card-logical-start { border-inline-start: 4px solid #f59e0b; }
.card-logical-end { border-inline-end: 3px solid #ec4899; }
.card-logical-start-width { border-inline-start-width: 5px; border-inline-start-style: solid; border-inline-start-color: #06b6d4; }
/* CSS top/bottom + border-radius */
.card-css-top { border-radius: 12px; border-top: 4px solid #3b82f6; }
@@ -40,18 +37,10 @@
<h3>border-l-4 + rounded-r</h3>
<p>The classic AI tell.</p>
</div>
<div class="border-l-2 border-emerald-500 bg-white p-4 rounded-r shadow-sm">
<h3>border-l-2 + rounded-r</h3>
<p>Thin but still recognizable with rounded corners.</p>
</div>
<div class="border-r-4 border-purple-500 bg-white p-4 rounded-l shadow-sm">
<h3>border-r-4 + rounded-l</h3>
<p>Right side variant.</p>
</div>
<div class="border-s-3 border-amber-500 bg-white p-4 rounded-r shadow-sm">
<h3>border-s-3 + rounded-r</h3>
<p>Logical inline-start.</p>
</div>
<div class="border-e-8 border-red-500 bg-white p-4 rounded-l shadow-sm">
<h3>border-e-8 + rounded-l</h3>
<p>Logical inline-end, extra thick.</p>
@@ -77,18 +66,6 @@
<h3>border-right-width: 6px</h3>
<p>CSS longhand right.</p>
</div>
<div class="card card-logical-start">
<h3>border-inline-start: 4px solid</h3>
<p>CSS logical start.</p>
</div>
<div class="card card-logical-end">
<h3>border-inline-end: 3px solid</h3>
<p>CSS logical end.</p>
</div>
<div class="card card-logical-start-width">
<h3>border-inline-start-width: 5px</h3>
<p>CSS logical longhand.</p>
</div>
</div>
<!-- TOP/BOTTOM + ROUNDED -->
+1 -1
View File
@@ -15,7 +15,7 @@
h2 { font-size: 16px; font-weight: 600; margin: 2rem 0 0.75rem; color: #6b7280; }
h3 { font-size: 15px; font-weight: 600; }
p { font-size: 14px; color: #6b7280; margin-top: 0.25rem; }
.caption { font-size: 13px; color: #9ca3af; }
.caption { font-size: 13px; color: #6b7280; }
</style>
</head>
<body>
+192
View File
@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Typography Anti-Patterns — Side by Side</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Newsreader:opsz,wght@6..72,400;6..72,700&family=Karla:wght@400;500;700&display=swap" rel="stylesheet">
<style>
body {
margin: 0;
padding: 24px;
background: #f9fafb;
color: #111827;
font: 16px/1.5 system-ui, sans-serif;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 32px;
max-width: 1180px;
margin: 0 auto;
}
.col {
min-width: 0;
}
.col-label {
margin: 0 0 14px;
color: #475569;
font-size: 13px;
font-weight: 760;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.flag-type {
font-family: 'Inter', sans-serif;
background: #fdfdfd;
padding: 22px;
border: 1px solid #e5e7eb;
}
.flag-type h1 {
margin: 0 0 8px;
font-size: 18px;
line-height: 1.2;
}
.flag-type h2 {
margin: 24px 0 8px;
color: #4b5563;
font-size: 16px;
line-height: 1.25;
}
.flag-type h3 {
margin: 18px 0 4px;
font-size: 15px;
line-height: 1.25;
}
.flag-type p {
margin: 0 0 10px;
color: #4b5563;
font-size: 14px;
}
.flag-type .caption {
color: #6b7280;
font-size: 13px;
}
.flag-type .tight-leading-demo {
line-height: 1.05;
}
.flag-type .tiny-body-demo {
color: #374151;
font-size: 10px;
line-height: 1.6;
}
.flag-type .caps-body-demo {
text-transform: uppercase;
}
.flag-type .tracked-body-demo {
letter-spacing: 0.08em;
}
.flag-type .justified-body-demo {
text-align: justify;
}
.pass-type {
background: #fdfdfd;
padding: 22px;
border: 1px solid #e5e7eb;
}
.pass-type h1,
.pass-type h2 {
font-family: 'Newsreader', Georgia, serif;
}
.pass-type h1 {
margin: 0 0 10px;
font-size: 48px;
line-height: 0.98;
font-weight: 700;
}
.pass-type h2 {
margin: 32px 0 10px;
font-size: 30px;
line-height: 1.08;
font-weight: 700;
}
.pass-type h3,
.pass-type p,
.pass-type .caption {
font-family: 'Karla', system-ui, sans-serif;
}
.pass-type h3 {
margin: 22px 0 6px;
font-size: 22px;
line-height: 1.15;
}
.pass-type p {
margin: 0 0 12px;
color: #374151;
font-size: 16px;
}
.pass-type .caption {
color: #4b5563;
font-size: 12px;
}
</style>
</head>
<body>
<div class="grid">
<section class="col" data-col="flag">
<h2 class="col-label">Should flag</h2>
<article class="flag-type">
<h1>Typography Anti-Patterns</h1>
<p>This side uses element-level typography failures that can be highlighted directly in the detector lab.</p>
<h2>Tight Leading</h2>
<p class="tight-leading-demo">This paragraph has a line-height that is too tight for body copy. The lines press into each other and make scanning harder, especially once the text wraps across multiple lines in a production layout.</p>
<h2>Tiny Body Text</h2>
<p class="tiny-body-demo">This body text is set at 10px, which is too small for real reading content and should be reserved for neither primary copy nor explanatory product text.</p>
<h2>All-Caps Body</h2>
<p class="caps-body-demo">This entire paragraph is transformed to uppercase, which removes useful word shapes and makes longer body copy feel like a shout instead of a readable passage.</p>
<h2>Wide Tracking</h2>
<p class="tracked-body-demo">This paragraph spreads every letter apart, a treatment that belongs on short labels at most, not on body copy that people need to read comfortably.</p>
<h2>Justified Text</h2>
<p class="justified-body-demo">This paragraph uses justified alignment without automatic hyphenation. In narrow columns it creates uneven word spacing and distracting rivers through the text block.</p>
<h2>Overused Font</h2>
<p>Inter is also the dominant face on this side of the page, so the browser page-level detector has enough real text elements to show the global overused-font banner.</p>
</article>
</section>
<section class="col" data-col="pass">
<h2 class="col-label">Should pass</h2>
<article class="pass-type">
<h1>Good Typography</h1>
<p>This side keeps readable body defaults, a real font pairing, clear scale, and visible hierarchy.</p>
<h2>Two Font Families</h2>
<p>Newsreader carries display text, while Karla handles reading surfaces and supporting labels.</p>
<h3>Strong Size Hierarchy</h3>
<p>Sizes range from 12px to 48px with distinct roles for heading, subheading, body, and caption.</p>
<p class="caption">Caption text is intentionally smaller but still readable.</p>
</article>
</section>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+156
View File
@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visual Contrast Fixture</title>
<style>
:root {
--paper: #f7f3ee;
--ink: #171717;
--muted: #566174;
}
body {
margin: 0;
padding: 32px;
background: var(--paper);
color: var(--ink);
font-family: system-ui, sans-serif;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
max-width: 980px;
margin: 0 auto;
}
.column {
display: grid;
gap: 14px;
}
.column > h2 {
margin: 0 0 2px;
color: var(--muted);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.4;
text-transform: uppercase;
}
.image-card {
position: relative;
display: grid;
align-content: end;
min-height: 180px;
padding: 28px;
border-radius: 8px;
overflow: hidden;
background-size: cover;
background-position: center;
}
.image-card p {
position: relative;
z-index: 1;
max-width: 28ch;
margin: 0;
font-size: 18px;
font-weight: 700;
line-height: 1.45;
}
.light-image {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 420 240'%3E%3Crect width='420' height='240' fill='%23ece8df'/%3E%3Ccircle cx='308' cy='58' r='132' fill='%23faf7f0'/%3E%3Cpath d='M0 186c52-22 92-26 142-8s98 18 148-8 92-22 130 0v70H0z' fill='%23f5efe4'/%3E%3C/svg%3E");
}
.pale-pattern {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 420 240'%3E%3Crect width='420' height='240' fill='%23edf2f7'/%3E%3Cg fill='%23f8fafc'%3E%3Ccircle cx='60' cy='64' r='42'/%3E%3Ccircle cx='182' cy='142' r='58'/%3E%3Ccircle cx='336' cy='84' r='74'/%3E%3C/g%3E%3Cg stroke='%23dbe4ef' stroke-width='14' opacity='.55'%3E%3Cpath d='M-20 220C74 112 150 94 232 164s138 46 214-62' fill='none'/%3E%3C/g%3E%3C/svg%3E");
}
.mist-image {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 420 240'%3E%3Crect width='420' height='240' fill='%23b9c4cf'/%3E%3Cpath d='M0 52c74-28 138-22 194 18s124 38 226-28v198H0z' fill='%23cbd3dc'/%3E%3Ccircle cx='82' cy='176' r='68' fill='%23aeb8c4'/%3E%3C/svg%3E");
}
.dark-image {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 420 240'%3E%3Crect width='420' height='240' fill='%23191b20'/%3E%3Ccircle cx='316' cy='50' r='132' fill='%232d3440'/%3E%3Cpath d='M0 182c70-48 138-42 204-6s128 24 216-58v122H0z' fill='%23101318'/%3E%3C/svg%3E");
}
.underlay {
isolation: isolate;
background: #1a1d24;
}
.underlay.light-shell {
background: #f5f2ea;
}
.underlay img,
.underlay svg {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.underlay svg {
display: block;
}
</style>
</head>
<body>
<main class="grid">
<section class="column" data-col="flag">
<h2>Should flag after pixel sampling</h2>
<article class="image-card light-image">
<p style="color: rgb(255, 255, 255);">White text on light image should be sampled by pixel contrast.</p>
</article>
<article class="image-card dark-image">
<p style="color: rgb(28, 30, 35);">Dark text on dark image should be sampled by pixel contrast.</p>
</article>
<article class="image-card pale-pattern">
<p style="color: rgba(255, 255, 255, 0.72);">Translucent white text on a pale pattern should use rendered pixels.</p>
</article>
<article class="image-card mist-image">
<p style="color: rgb(138, 146, 156);">Muted gray text on a misty image should be sampled by pixel contrast.</p>
</article>
</section>
<section class="column" data-col="pass">
<h2>Should pass after pixel sampling</h2>
<article class="image-card dark-image">
<p style="color: rgb(250, 247, 239);">White text on dark image should pass the pixel contrast fallback.</p>
</article>
<article class="image-card light-image">
<p style="color: rgb(31, 35, 42);">Dark text on light image should pass the pixel contrast fallback.</p>
</article>
<article class="image-card underlay">
<img alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 420 240'%3E%3Crect width='420' height='240' fill='%23141920'/%3E%3Ccircle cx='318' cy='70' r='126' fill='%23293442'/%3E%3C/svg%3E">
<p style="color: rgb(246, 242, 232);">White text over a dark image underlay should pass.</p>
</article>
<article class="image-card underlay light-shell">
<svg viewBox="0 0 420 240" aria-hidden="true">
<rect width="420" height="240" fill="#f3eee4"></rect>
<circle cx="86" cy="70" r="68" fill="#fffaf2"></circle>
<path d="M0 176c72-24 132-22 190 6s128 30 230-34v92H0z" fill="#e5dccd"></path>
</svg>
<p style="color: rgb(35, 31, 27);">Dark text over a light SVG underlay should pass.</p>
</article>
</section>
</main>
</body>
</html>
+2 -2
View File
@@ -51,10 +51,10 @@ describe('Windows path doubling fix (#95)', () => {
expect(thisFile).toContain('windows-path-fix.test');
});
test('source file no longer uses raw .pathname for path construction', () => {
test('URL detector source no longer uses raw .pathname for path construction', () => {
const fs = require('fs');
const src = fs.readFileSync(
path.join(__dirname, '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
path.join(__dirname, '..', 'cli', 'engine', 'engines', 'browser', 'detect-url.mjs'),
'utf-8'
);