Fix detector coverage for generated UI tells

Remove provider gating, share grid-background detection across source and rendered scan paths, and update the detector catalog and tests.\n\nAI-assisted: prepared by Codex at Paul's request.
This commit is contained in:
Paul Bakaus
2026-07-18 14:21:06 -07:00
parent 2b1f36c43e
commit 144cee5c36
19 changed files with 236 additions and 232 deletions
+1 -4
View File
@@ -1458,10 +1458,7 @@ if (IS_BROWSER) {
const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
const designSystem = browserDesignSystemConfig();
const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() };
// Note: provider-gated rules (--gpt / --gemini) are NOT filtered here. In a
// real browser env (detector page, live overlay, extension) running every
// check is free, so we always surface them; the gating is purely a CLI
// output concern, applied in the Node engines' detect* return paths.
// All deterministic rules run in the browser and extension path.
for (const el of document.querySelectorAll('*')) {
// Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons)
+6 -6
View File
@@ -92,8 +92,6 @@ Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--quiet In text mode, only print the final findings count
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--scope <name> Only report rules in the given design domain
(type, layout). Comma-separated.
--viewport <WxH> Browser viewport for URL scans (default 1280x800),
@@ -150,13 +148,15 @@ async function detectCli() {
'Note: --fast is deprecated and ignored. The full scan is fast now and runs every rule.\n',
);
}
if (args.includes('--gpt') || args.includes('--gemini')) {
process.stderr.write(
'Note: --gpt and --gemini are deprecated and ignored. Generated-UI tells now run by default.\n',
);
}
const configEnabled = !args.includes('--no-config');
const detectionConfig = configEnabled
? readDetectionConfig(process.cwd())
: { ignoreRules: [], ignoreFiles: [], ignoreValues: [] };
const providers = [];
if (args.includes('--gpt')) providers.push('gpt');
if (args.includes('--gemini')) providers.push('gemini');
const scopes = [];
for (let i = 0; i < args.length; i++) {
if (args[i] !== '--scope' && !args[i].startsWith('--scope=')) continue;
@@ -204,7 +204,7 @@ async function detectCli() {
// apply by default. `--no-config` (raw scan) and the dedicated
// `--no-inline-ignores` both turn them off.
const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');
const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled };
const scanOptions = { inlineIgnores: inlineIgnoresEnabled };
if (designSystem) scanOptions.designSystem = designSystem;
if (viewport) scanOptions.viewport = viewport;
const targets = args.filter(a => !a.startsWith('--'));
+95 -70
View File
@@ -594,12 +594,11 @@ const ANTIPATTERNS = [
skillGuideline: 'font size outside the project design system',
},
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
// ── Common generated-UI tells ───────────────────────────────────────────
{
id: 'gpt-thin-border-wide-shadow',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Hairline border with wide shadow',
description:
'A hairline border paired with a wide, diffuse shadow is a recurring generated-UI signature. Commit to one — a defined edge or a soft elevation — rather than both at once.',
@@ -610,7 +609,6 @@ const ANTIPATTERNS = [
id: 'repeating-stripes-gradient',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Repeating-gradient stripes',
description:
'Repeating-gradient stripes used as surface decoration are a recurring generated-UI signature. Reach for a deliberate texture or leave the surface plain.',
@@ -621,7 +619,6 @@ const ANTIPATTERNS = [
id: 'codex-grid-background',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Decorative grid-line background',
description:
'A decorative grid or line-field background drawn with hairline linear-gradient layers tiled by a fixed pixel cell is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
@@ -632,7 +629,6 @@ const ANTIPATTERNS = [
id: 'theater-slop-phrase',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Theater framing copy',
description:
'Dismissing something as "theater" is a recurring generated-copy tic. Say plainly what the thing does or does not do.',
@@ -643,7 +639,6 @@ const ANTIPATTERNS = [
id: 'image-hover-transform',
category: 'slop',
severity: 'advisory',
gated: 'gemini',
name: 'Image hover transform',
description:
'Scaling or rotating an image on hover is a recurring generated-UI signature. Let imagery sit still, or use a subtler, purposeful interaction.',
@@ -1130,27 +1125,57 @@ function isAccentColor(cssColor) {
return false;
}
function resolveHeroHeadingSizePx(value) {
const input = String(value || '').trim().toLowerCase();
if (!input) return 0;
const simpleLengthPx = (token) => {
const match = /^(-?\d*\.?\d+)\s*(px|rem|em|%)?$/.exec(String(token || '').trim());
if (!match) return null;
const amount = Number(match[1]);
if (!Number.isFinite(amount)) return null;
if (match[2] === 'rem' || match[2] === 'em') return amount * 16;
if (match[2] === '%') return amount * 0.16;
return amount;
};
const direct = simpleLengthPx(input);
if (direct !== null) return direct;
// Static CSS engines cannot resolve viewport units, but clamp's min/max
// bounds still tell us whether the heading can ever reach hero scale.
const clamp = /^clamp\((.*)\)$/.exec(input);
if (clamp) {
const parts = clamp[1].split(',');
if (parts.length === 3) {
const bounds = [simpleLengthPx(parts[0]), simpleLengthPx(parts[2])]
.filter((candidate) => candidate !== null);
if (bounds.length > 0) return Math.max(...bounds);
}
}
return 0;
}
// Sibling-relationship rule. Anchor on a hero-scale h1, look at the
// previousElementSibling, and gate on EITHER the classic tracked-
// uppercase eyebrow OR the modern accent-colored bold eyebrow.
function checkHeroEyebrow(opts) {
const {
headingTag, headingText, headingFontSize,
headingInApplicationContext,
siblingTag, siblingText, siblingTextTransform,
siblingFontSize, siblingLetterSpacing,
siblingFontWeight, siblingColor,
siblingHasAccentDashPseudo,
} = opts;
if (headingTag !== 'h1') return [];
// We previously gated on headingFontSize >= 48 to anchor "hero scale".
// But modern hero h1s use clamp() / vw / var(--text-*), none of which
// jsdom can resolve — the computed value comes back as "2em" or
// "var(--text-9xl)" and parseFloat returns 2 or NaN. The gate fails
// on virtually every Tailwind v4 / framework build. The other gates
// (sibling text 2-60 chars, font-size ≤ 14px, accent-bold OR
// tracked-caps) are tight enough to avoid false positives on non-
// hero h1s — a tiny tan label directly above any h1 is the
// antipattern regardless of how big the h1 ends up.
// This is specifically a marketing-hero cliché, not a ban on compact
// context labels in product UI (for example, a station name inside a tab
// panel). Browser-computed sizes are reliable; the static adapter also
// resolves ordinary px/rem/em and clamp() bounds before reaching here.
if (headingInApplicationContext) return [];
if (!(headingFontSize >= 48)) return [];
if (!siblingTag) return [];
// An h2 above an h1 is a different anti-pattern (heading hierarchy / dual
// headings) — never an eyebrow.
@@ -1394,6 +1419,49 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative grid or line-field backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
while ((blk = blockRe.exec(content)) !== null) {
const block = blk[1] || blk[2] || blk[3] || '';
let hairlineCount = 0;
let bgJoined = '';
let bm;
bgDeclRe.lastIndex = 0;
while ((bm = bgDeclRe.exec(block)) !== null) {
hairlineCount += (bm[1].match(hairlineRe) || []).length;
hairlineCount += (bm[1].match(invertedHairlineRe) || []).length;
bgJoined += `${bm[1]};`;
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
return [{
index: blk.index,
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
}];
}
}
return [];
}
// Decorative chromatic halo drawn as a radial-gradient background on a dark
// page: a saturated center stop dissolving to transparent. The gradient
// sibling of the dark-glow shadow tell. Mechanical gates, in order:
@@ -2204,12 +2272,12 @@ function checkHtmlPatterns(html) {
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
}
// --- Provider tells (gated): repeating-gradient stripes (GPT) ---
// --- Generated-UI tells: repeating-gradient stripes ---
if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(html)) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
}
// --- Provider tells (gated): two-axis grid-line background (Codex/GPT) ---
// --- Generated-UI tells: two-axis grid-line background ---
// The Codex grid tell is two hairline `linear-gradient(... <color> 1px,
// transparent 1px)` layers (one per axis) tiled by a repeating
// `background-size` cell. Both signals must co-occur in the SAME style block
@@ -2222,54 +2290,12 @@ function checkHtmlPatterns(html) {
// in for the second axis. Colors like `oklch(96% 0.012 82 / 0.055)` carry
// nested parens, so match the hairline stop directly rather than parsing
// whole gradient layers.
{
// Hairline stop shapes: the classic leading form (`<color> 1px,
// transparent 1px`) and the inverted end-of-tile form
// (`transparent calc(100% - 1px), <color> 1px`).
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
// Tiling cell: a background-size declaration with px values, or the
// background shorthand's `/ <size>` slot (only matched inside
// background values so border-radius slash syntax can't stand in).
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
while ((blk = blockRe.exec(html)) !== null) {
const block = blk[1] || blk[2] || blk[3] || '';
let hairlineCount = 0;
let bgJoined = '';
let bm;
bgDeclRe.lastIndex = 0;
while ((bm = bgDeclRe.exec(block)) !== null) {
hairlineCount += (bm[1].match(hairlineRe) || []).length;
hairlineCount += (bm[1].match(invertedHairlineRe) || []).length;
bgJoined += bm[1] + ';';
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
// Two hairline layers + any px tile = the classic two-axis grid.
// A single hairline layer only counts when tiled by a px pair cell
// (e.g. `/ 40px 40px`) — a page-scale repeating line field. Single
// hairlines tiled by percentage cells (`background-size: 25% 100%`)
// are structural rules on data-viz tracks/graphs and stay legal.
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
findings.push({
id: 'codex-grid-background',
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
});
break;
}
}
const gridHits = scanCssTextForGridBackground(html);
if (gridHits.length > 0) {
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
}
// --- Provider tells (gated): "X theater" framing copy (GPT) ---
// --- Generated-copy tells: "X theater" framing copy ---
// Lives here (regex-on-HTML) rather than in the text-content analyzers so it
// runs in the bundled browser path too, not just the CLI/static path.
{
@@ -2281,7 +2307,7 @@ function checkHtmlPatterns(html) {
if (tm) findings.push({ id: 'theater-slop-phrase', snippet: `"${tm[0].trim()}"` });
}
// --- Provider tells (gated): image hover transform (Gemini) ---
// --- Generated-UI tells: image hover transform ---
// A CSS `img...:hover { transform: ... }` rule, or a Tailwind hover:scale /
// hover:rotate / hover:translate utility on an <img>. Each distinct
// mechanism is its own finding.
@@ -2716,6 +2742,7 @@ function checkElementHeroEyebrowDOM(el) {
headingTag: tag,
headingText: el.textContent || '',
headingFontSize: parseFloat(headStyle.fontSize) || 0,
headingInApplicationContext: !!el.closest('[role="tabpanel"], [role="dialog"], [role="application"], dialog'),
siblingTag: sibling.tagName.toLowerCase(),
siblingText: sibling.textContent || '',
siblingTextTransform: sibStyle.textTransform || '',
@@ -4145,7 +4172,8 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
return checkHeroEyebrow({
headingTag: tag,
headingText: el.textContent || '',
headingFontSize: parseFloat(headingFontSizeRaw) || 0,
headingFontSize: resolveHeroHeadingSizePx(headingFontSizeRaw),
headingInApplicationContext: !!el.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog'),
siblingTag: sibling.tagName.toLowerCase(),
siblingText: sibling.textContent || '',
siblingTextTransform: sibStyle.textTransform || '',
@@ -4829,7 +4857,7 @@ function checkElementOversizedH1DOM(el) {
return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight });
}
// ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ────────────
// ─── Generated-UI tell: hairline border + wide diffuse shadow ────────────────
const CSS_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\b(?:black|white|transparent|currentcolor)\b/gi;
function shadowLayerAlpha(layer) {
@@ -7254,10 +7282,7 @@ if (IS_BROWSER) {
const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
const designSystem = browserDesignSystemConfig();
const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() };
// Note: provider-gated rules (--gpt / --gemini) are NOT filtered here. In a
// real browser env (detector page, live overlay, extension) running every
// check is free, so we always surface them; the gating is purely a CLI
// output concern, applied in the Node engines' detect* return paths.
// All deterministic rules run in the browser and extension path.
for (const el of document.querySelectorAll('*')) {
// Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons)
+2 -3
View File
@@ -3,7 +3,6 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { finding } from '../../findings.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
import { checkContentHiddenAtRest } from '../../rules/checks.mjs';
@@ -295,14 +294,14 @@ async function detectUrl(url, options = {}) {
}, () => browser.close());
}
}
return filterByProviders(results.map(f => {
return results.map(f => {
const item = finding(f.id, url, f.snippet);
if (f.ignoreValue) item.ignoreValue = f.ignoreValue;
// Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity;
return item;
}), options.providers);
});
}
async function createBrowserDetector(options = {}) {
+15 -4
View File
@@ -2,11 +2,10 @@ import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { scanCssTextForGlow, scanCssTextForMarquee, scanCssTextForRadialHalo } from '../../rules/checks.mjs';
import { scanCssTextForGlow, scanCssTextForGridBackground, scanCssTextForMarquee, scanCssTextForRadialHalo } from '../../rules/checks.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
import { finding } from '../../findings.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
import { profileFindings, profileStep } from '../../profile/profiler.mjs';
// ---------------------------------------------------------------------------
@@ -456,6 +455,19 @@ function detectText(content, filePath, options = {}) {
phase: 'source',
}));
// Block-level CSS checks that need multiple declarations must run over the
// complete source, not line-by-line. This covers standalone stylesheets,
// component style blocks, inline styles, and CSS-in-JS templates.
findings.push(...profileFindings(profile, {
engine: 'regex',
phase: 'source',
ruleId: 'codex-grid-background',
target: filePath,
}, () => scanCssTextForGridBackground(content).map(hit => {
const line = content.substring(0, hit.index).split('\n').length;
return finding('codex-grid-background', filePath, hit.snippet, line);
})));
// Extract and scan <style> blocks from Vue/Svelte SFCs
const styleBlocks = profile
? profileStep(profile, {
@@ -532,10 +544,9 @@ function detectText(content, filePath, options = {}) {
}
}
const byProvider = filterByProviders(deduped, options?.providers);
// Inline `impeccable-disable*` waivers travel with the file; honor them unless
// explicitly bypassed (`--no-config` / `--no-inline-ignores`).
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content);
return options?.inlineIgnores === false ? deduped : applyInlineIgnores(deduped, content);
}
export {
@@ -34,7 +34,6 @@ import {
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs';
import {
StaticDocument,
@@ -239,11 +238,10 @@ async function detectHtml(filePath, options = {}) {
}
}
const byProvider = filterByProviders(findings, options.providers);
// Static-HTML findings carry no line number, so only whole-file
// `impeccable-disable` directives apply here — exactly the standalone-document
// waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`.
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html);
return options?.inlineIgnores === false ? findings : applyInlineIgnores(findings, html);
}
export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };
+1 -27
View File
@@ -492,12 +492,11 @@ const ANTIPATTERNS = [
skillGuideline: 'font size outside the project design system',
},
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
// ── Common generated-UI tells ───────────────────────────────────────────
{
id: 'gpt-thin-border-wide-shadow',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Hairline border with wide shadow',
description:
'A hairline border paired with a wide, diffuse shadow is a recurring generated-UI signature. Commit to one — a defined edge or a soft elevation — rather than both at once.',
@@ -508,7 +507,6 @@ const ANTIPATTERNS = [
id: 'repeating-stripes-gradient',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Repeating-gradient stripes',
description:
'Repeating-gradient stripes used as surface decoration are a recurring generated-UI signature. Reach for a deliberate texture or leave the surface plain.',
@@ -519,7 +517,6 @@ const ANTIPATTERNS = [
id: 'codex-grid-background',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Decorative grid-line background',
description:
'A decorative grid or line-field background drawn with hairline linear-gradient layers tiled by a fixed pixel cell is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
@@ -530,7 +527,6 @@ const ANTIPATTERNS = [
id: 'theater-slop-phrase',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Theater framing copy',
description:
'Dismissing something as "theater" is a recurring generated-copy tic. Say plainly what the thing does or does not do.',
@@ -541,7 +537,6 @@ const ANTIPATTERNS = [
id: 'image-hover-transform',
category: 'slop',
severity: 'advisory',
gated: 'gemini',
name: 'Image hover transform',
description:
'Scaling or rotating an image on hover is a recurring generated-UI signature. Let imagery sit still, or use a subtler, purposeful interaction.',
@@ -569,25 +564,6 @@ function getRuleEngineSupport(engine) {
return RULE_ENGINE_SUPPORT[engine] || new Set();
}
// Set of provider tags that gate rules off by default (e.g. 'gpt', 'gemini').
const GATED_PROVIDERS = new Set(
ANTIPATTERNS.map(rule => rule.gated).filter(Boolean),
);
// Drop findings for rules gated behind a provider tag unless that provider
// was explicitly enabled (CLI --gpt / --gemini). Non-gated findings always
// pass through. `findings` carry the rule id on `.antipattern`.
function filterByProviders(findings, providers = []) {
const enabled = new Set(providers || []);
if (!GATED_PROVIDERS.size) return findings;
return findings.filter(f => {
const rule = getAntipattern(f.antipattern);
if (!rule || !rule.gated) return true;
return enabled.has(rule.gated);
});
}
// Set of scope tags rules can declare (e.g. 'type', 'layout'). Used by the
// CLI --scope flag to narrow output to one design domain.
const RULE_SCOPES = new Set(
@@ -609,10 +585,8 @@ export {
ANTIPATTERNS,
RULE_SCOPES,
RULE_ENGINE_SUPPORT,
GATED_PROVIDERS,
getAntipattern,
getRulesForCategory,
getRuleEngineSupport,
filterByProviders,
filterByScopes,
};
+52 -50
View File
@@ -646,6 +646,49 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative grid or line-field backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
while ((blk = blockRe.exec(content)) !== null) {
const block = blk[1] || blk[2] || blk[3] || '';
let hairlineCount = 0;
let bgJoined = '';
let bm;
bgDeclRe.lastIndex = 0;
while ((bm = bgDeclRe.exec(block)) !== null) {
hairlineCount += (bm[1].match(hairlineRe) || []).length;
hairlineCount += (bm[1].match(invertedHairlineRe) || []).length;
bgJoined += `${bm[1]};`;
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
return [{
index: blk.index,
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
}];
}
}
return [];
}
// Decorative chromatic halo drawn as a radial-gradient background on a dark
// page: a saturated center stop dissolving to transparent. The gradient
// sibling of the dark-glow shadow tell. Mechanical gates, in order:
@@ -1456,12 +1499,12 @@ function checkHtmlPatterns(html) {
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
}
// --- Provider tells (gated): repeating-gradient stripes (GPT) ---
// --- Generated-UI tells: repeating-gradient stripes ---
if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(html)) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
}
// --- Provider tells (gated): two-axis grid-line background (Codex/GPT) ---
// --- Generated-UI tells: two-axis grid-line background ---
// The Codex grid tell is two hairline `linear-gradient(... <color> 1px,
// transparent 1px)` layers (one per axis) tiled by a repeating
// `background-size` cell. Both signals must co-occur in the SAME style block
@@ -1474,54 +1517,12 @@ function checkHtmlPatterns(html) {
// in for the second axis. Colors like `oklch(96% 0.012 82 / 0.055)` carry
// nested parens, so match the hairline stop directly rather than parsing
// whole gradient layers.
{
// Hairline stop shapes: the classic leading form (`<color> 1px,
// transparent 1px`) and the inverted end-of-tile form
// (`transparent calc(100% - 1px), <color> 1px`).
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
// Tiling cell: a background-size declaration with px values, or the
// background shorthand's `/ <size>` slot (only matched inside
// background values so border-radius slash syntax can't stand in).
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
while ((blk = blockRe.exec(html)) !== null) {
const block = blk[1] || blk[2] || blk[3] || '';
let hairlineCount = 0;
let bgJoined = '';
let bm;
bgDeclRe.lastIndex = 0;
while ((bm = bgDeclRe.exec(block)) !== null) {
hairlineCount += (bm[1].match(hairlineRe) || []).length;
hairlineCount += (bm[1].match(invertedHairlineRe) || []).length;
bgJoined += bm[1] + ';';
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
// Two hairline layers + any px tile = the classic two-axis grid.
// A single hairline layer only counts when tiled by a px pair cell
// (e.g. `/ 40px 40px`) — a page-scale repeating line field. Single
// hairlines tiled by percentage cells (`background-size: 25% 100%`)
// are structural rules on data-viz tracks/graphs and stay legal.
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
findings.push({
id: 'codex-grid-background',
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
});
break;
}
}
const gridHits = scanCssTextForGridBackground(html);
if (gridHits.length > 0) {
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
}
// --- Provider tells (gated): "X theater" framing copy (GPT) ---
// --- Generated-copy tells: "X theater" framing copy ---
// Lives here (regex-on-HTML) rather than in the text-content analyzers so it
// runs in the bundled browser path too, not just the CLI/static path.
{
@@ -1533,7 +1534,7 @@ function checkHtmlPatterns(html) {
if (tm) findings.push({ id: 'theater-slop-phrase', snippet: `"${tm[0].trim()}"` });
}
// --- Provider tells (gated): image hover transform (Gemini) ---
// --- Generated-UI tells: image hover transform ---
// A CSS `img...:hover { transform: ... }` rule, or a Tailwind hover:scale /
// hover:rotate / hover:translate utility on an <img>. Each distinct
// mechanism is its own finding.
@@ -4083,7 +4084,7 @@ function checkElementOversizedH1DOM(el) {
return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight });
}
// ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ────────────
// ─── Generated-UI tell: hairline border + wide diffuse shadow ────────────────
const CSS_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\b(?:black|white|transparent|currentcolor)\b/gi;
function shadowLayerAlpha(layer) {
@@ -5065,6 +5066,7 @@ export {
checkMotion,
checkGlow,
scanCssTextForGlow,
scanCssTextForGridBackground,
scanCssTextForRadialHalo,
scanCssTextForPseudoStripe,
scanCssTextForInsetStripe,
-1
View File
@@ -24,7 +24,6 @@ const layerTitle = layer === 'browser'
<div class="rule-card-head">
<span class="rule-card-category" data-category={category}>{categoryLabel}</span>
<span class="rule-card-layer" data-layer={layer} title={layerTitle}>{layerLabel}</span>
{rule.gated && <span class="rule-card-layer" data-layer="optin" title={`Off by default. Enable with --${rule.gated}.`}>opt-in</span>}
</div>
<h3 class="rule-card-name">{rule.name}</h3>
<p class="rule-card-desc">{description}</p>
-10
View File
@@ -114,16 +114,6 @@ Use [Config and ignores](/docs/config) for the full ignore workflow, including t
</div>
</details>
<details class="docs-prose-details">
<summary>Provider-specific checks</summary>
<div>
<p>Some rules are provider-specific and opt in:</p>
<pre><code>npx impeccable detect --gpt src/
npx impeccable detect --gemini src/</code></pre>
<p>Leave them off for normal project quality checks. Turn them on when you specifically want to catch model-family fingerprints.</p>
</div>
</details>
<details class="docs-prose-details">
<summary>Where the detector fits</summary>
<div>
+1 -1
View File
@@ -147,7 +147,7 @@ import '../styles/changelog-faq-kinpaku.css';
<article id="cli-v3.2.0" class="cf-entry">
<header class="cf-entry-head"><span class="cf-version">CLI v3.2.0</span><span class="cf-date">July 1, 2026</span></header>
<ul class="cf-items">
<li><strong>New rule: Codex grid-line backgrounds.</strong> The detector flags the two-axis grid overlay drawn from hairline gradients plus a repeating <code>background-size</code>. It is a provider tell, so it stays off until you pass <code>--gpt</code>, and it counts only hairline gradients inside the background so a stray <code>mask-image</code> line does not trip it. Brings the deterministic rule set to 45.</li>
<li><strong>New rule: Codex grid-line backgrounds.</strong> The detector flags the two-axis grid overlay drawn from hairline gradients plus a repeating <code>background-size</code>, and it counts only hairline gradients inside the background so a stray <code>mask-image</code> line does not trip it. It originally shipped behind <code>--gpt</code>; provider gates were later removed and the rule now runs by default. Brings the deterministic rule set to 45.</li>
<li><strong>First install preserves an external skills symlink.</strong> Installing into a project whose <code>~/.claude/skills</code> points at an external directory no longer overwrites that symlink on the first install, so shared skill setups stay intact.</li>
</ul>
</article>
+6 -6
View File
@@ -130,15 +130,15 @@ const FIXTURES = [
},
{
file: 'gpt-tells.html',
group: 'Provider tells',
title: 'GPT tells (gated --gpt)',
summary: 'Provider-specific idioms surfaced only under --gpt: hairline border + wide shadow, repeating-gradient stripes, "X theater" copy.',
group: 'Generated-UI tells',
title: 'Recurring generated-UI tells',
summary: 'Hairline border + wide shadow, repeating-gradient stripes, decorative grid backgrounds, and "X theater" copy.',
},
{
file: 'gemini-tells.html',
group: 'Provider tells',
title: 'Gemini tells (gated --gemini)',
summary: 'Provider-specific idiom surfaced only under --gemini: image hover-zoom transforms.',
group: 'Generated-UI tells',
title: 'Image hover transforms',
summary: 'Generated-UI image hover transforms that scale, rotate, or translate imagery without a functional purpose.',
},
{
file: 'cramped-padding.html',
+1 -7
View File
@@ -8,7 +8,6 @@ import { ANTIPATTERNS } from '../../../cli/engine/registry/antipatterns.mjs';
const CRITIQUE_ONLY_COUNT = 5;
const DETECTOR_RULE_COUNT = ANTIPATTERNS.length;
const TOTAL_PATTERN_COUNT = DETECTOR_RULE_COUNT + CRITIQUE_ONLY_COUNT;
const GATED_RULE_COUNT = ANTIPATTERNS.filter((rule) => rule.gated).length;
const CATALOG_RULE_IDS = {
designSystem: ['design-system-font', 'design-system-color', 'design-system-radius', 'design-system-font-size'],
visualDetails: ['codex-grid-background'],
@@ -125,7 +124,7 @@ const catalogRules = Object.fromEntries(
<div class="anti-patterns-content slop-content">
<header class="anti-patterns-header slop-header">
<h1 class="sub-page-title">Slop</h1>
<p class="sub-page-lede">{TOTAL_PATTERN_COUNT} patterns that expose AI defaults and production defects. Watch the overlay, try it on 11 synthetic specimens, or browse the catalog. The detector now covers {DETECTOR_RULE_COUNT} rules across source and rendered pages; {GATED_RULE_COUNT} provider-specific tells are opt-in. Five broader judgments remain in <a href="/docs/critique">/impeccable critique</a>.</p>
<p class="sub-page-lede">{TOTAL_PATTERN_COUNT} patterns that expose AI defaults and production defects. Watch the overlay, try it on 11 synthetic specimens, or browse the catalog. The detector now covers {DETECTOR_RULE_COUNT} rules across source and rendered pages, all enabled by default. Five broader judgments remain in <a href="/docs/critique">/impeccable critique</a>.</p>
</header>
<section class="slop-section slop-then-now" id="see-it" aria-label="Detection overlay demo">
@@ -286,7 +285,6 @@ const catalogRules = Object.fromEntries(
<div><dt><span class="rule-card-layer" data-layer="cli">CLI</span></dt><dd>Deterministic. Runs from <code>npx impeccable detect</code> on files, no browser required.</dd></div>
<div><dt><span class="rule-card-layer" data-layer="browser">Browser</span></dt><dd>Deterministic, but needs real browser layout. Runs via the browser extension or Puppeteer, not the plain CLI.</dd></div>
<div><dt><span class="rule-card-layer" data-layer="llm">LLM only</span></dt><dd>No deterministic detector. Caught by <a href="/docs/critique">/impeccable critique</a> during its LLM design review.</dd></div>
<div><dt><span class="rule-card-layer" data-layer="optin">opt-in</span></dt><dd>Deterministic, but a provider-specific tell that is off by default. Enable with <code>--gpt</code> or <code>--gemini</code>.</dd></div>
</dl>
</div>
</details>
@@ -373,7 +371,6 @@ const catalogRules = Object.fromEntries(
<div class="rule-card-head">
<span class="rule-card-category" data-category="slop">AI slop</span>
<span class="rule-card-layer" data-layer="cli" title="Deterministic. Runs from `npx impeccable detect` on files, no browser required.">CLI</span>
<span class="rule-card-layer" data-layer="optin" title="Off by default. Enable with --gpt.">opt-in</span>
</div>
<h3 class="rule-card-name">Hairline border with wide shadow</h3>
<p class="rule-card-desc">A hairline border paired with a wide, diffuse shadow is a recurring generated-UI signature. Commit to one: a defined edge or a soft elevation, not both at once.</p>
@@ -387,7 +384,6 @@ const catalogRules = Object.fromEntries(
<div class="rule-card-head">
<span class="rule-card-category" data-category="slop">AI slop</span>
<span class="rule-card-layer" data-layer="cli" title="Deterministic. Runs from `npx impeccable detect` on files, no browser required.">CLI</span>
<span class="rule-card-layer" data-layer="optin" title="Off by default. Enable with --gpt.">opt-in</span>
</div>
<h3 class="rule-card-name">Repeating-gradient stripes</h3>
<p class="rule-card-desc">Repeating-gradient stripes used as surface decoration are a recurring generated-UI signature. Reach for a deliberate texture, or leave the surface plain.</p>
@@ -784,7 +780,6 @@ const catalogRules = Object.fromEntries(
<div class="rule-card-head">
<span class="rule-card-category" data-category="slop">AI slop</span>
<span class="rule-card-layer" data-layer="cli" title="Deterministic. Runs from `npx impeccable detect` on files, no browser required.">CLI</span>
<span class="rule-card-layer" data-layer="optin" title="Off by default. Enable with --gemini.">opt-in</span>
</div>
<h3 class="rule-card-name">Image hover transform</h3>
<p class="rule-card-desc">Scaling or rotating an image on hover is a recurring generated-UI signature. Let imagery sit still, or use a subtler, purposeful interaction.</p>
@@ -849,7 +844,6 @@ const catalogRules = Object.fromEntries(
<div class="rule-card-head">
<span class="rule-card-category" data-category="slop">AI slop</span>
<span class="rule-card-layer" data-layer="cli" title="Deterministic. Runs from `npx impeccable detect` on files, no browser required.">CLI</span>
<span class="rule-card-layer" data-layer="optin" title="Off by default. Enable with --gpt.">opt-in</span>
</div>
<h3 class="rule-card-name">Theater framing copy</h3>
<p class="rule-card-desc">Dismissing something as &quot;theater&quot; is a recurring generated-copy tic. Say plainly what the thing does or does not do.</p>
-9
View File
@@ -935,15 +935,6 @@
border-color: var(--ks-rule);
}
/* Opt-in provider tells: deterministic but off by default (--gpt / --gemini). */
.slop-kinpaku .rule-card-layer[data-layer="optin"] {
color: var(--ks-vermilion);
background: oklch(58% 0.15 35 / 0.10);
border-color: oklch(58% 0.15 35 / 0.30);
text-transform: none;
letter-spacing: 0.04em;
}
.slop-kinpaku .rule-card-name {
font-family: var(--ks-font);
font-style: normal;
+7 -22
View File
@@ -828,40 +828,25 @@ describe('detectHtml — cream-palette', () => {
});
});
describe('detectHtml — gated provider tells (--gpt / --gemini)', () => {
describe('detectHtml — generated-UI tells', () => {
const GPT_IDS = ['gpt-thin-border-wide-shadow', 'repeating-stripes-gradient', 'codex-grid-background', 'theater-slop-phrase'];
it('gpt-tells: gated OFF by default — none of the GPT idioms surface', async () => {
it('gpt-tells: each flag case surfaces by default and the pass column adds none', async () => {
const f = await detectHtml(path.join(FIXTURES, 'gpt-tells.html'));
for (const id of GPT_IDS) {
assert.equal(
f.some(r => r.antipattern === id), false,
`${id} must not surface without --gpt`,
);
}
});
it('gpt-tells: with providers:[gpt], each flag case triggers once, pass column adds none', async () => {
const f = await detectHtml(path.join(FIXTURES, 'gpt-tells.html'), { providers: ['gpt'] });
for (const id of GPT_IDS) {
assert.equal(
f.filter(r => r.antipattern === id).length, 1,
`expected exactly one ${id} finding under --gpt, got ${f.filter(r => r.antipattern === id).length}`,
`expected exactly one default ${id} finding, got ${f.filter(r => r.antipattern === id).length}`,
);
}
});
it('gemini-tells: gated OFF by default, ON under providers:[gemini]', async () => {
const off = await detectHtml(path.join(FIXTURES, 'gemini-tells.html'));
assert.equal(
off.some(r => r.antipattern === 'image-hover-transform'), false,
'image-hover-transform must not surface without --gemini',
);
const on = await detectHtml(path.join(FIXTURES, 'gemini-tells.html'), { providers: ['gemini'] });
it('gemini-tells: both flag cases surface by default and pass cases stay legal', async () => {
const findings = await detectHtml(path.join(FIXTURES, 'gemini-tells.html'));
// Two flag cases: a CSS img:hover{transform} rule and a Tailwind hover:scale on <img>.
assert.equal(
on.filter(r => r.antipattern === 'image-hover-transform').length, 2,
`expected 2 image-hover-transform findings under --gemini, got ${on.filter(r => r.antipattern === 'image-hover-transform').length}`,
findings.filter(r => r.antipattern === 'image-hover-transform').length, 2,
`expected 2 default image-hover-transform findings, got ${findings.filter(r => r.antipattern === 'image-hover-transform').length}`,
);
});
});
+39
View File
@@ -1484,6 +1484,26 @@ describe('codex-grid-background variants', () => {
background-size: 24px 24px; }`;
expect(grids(css)).toHaveLength(1);
});
test('regex source engine catches a grid in a standalone CSS file', () => {
const css = `.worlds-shell {
background-image:
linear-gradient(rgba(23, 25, 24, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(23, 25, 24, 0.04) 1px, transparent 1px);
background-size: 80px 80px;
}`;
const findings = detectText(css, 'worlds.css');
expect(findings.filter(f => f.antipattern === 'codex-grid-background')).toHaveLength(1);
});
test('regex source engine keeps structural percentage rules legal', () => {
const css = `.timeline-track {
background-image: linear-gradient(90deg, #303532 1px, transparent 1px);
background-size: 25% 100%;
}`;
const findings = detectText(css, 'timeline.css');
expect(findings.filter(f => f.antipattern === 'codex-grid-background')).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
@@ -1829,6 +1849,25 @@ describe('CLI', () => {
expect(code).toBe(0);
expect(stdout).toContain('Usage:');
expect(stdout).toContain('--quiet');
expect(stdout).not.toContain('--gpt');
expect(stdout).not.toContain('--gemini');
});
test('generated-UI tells run by default in the CLI', () => {
const { stdout, code } = run('--json', path.join(FIXTURES, 'gpt-tells.html'));
expect(code).toBe(2);
const ids = JSON.parse(stdout).map(f => f.antipattern);
expect(ids).toContain('gpt-thin-border-wide-shadow');
expect(ids).toContain('repeating-stripes-gradient');
expect(ids).toContain('codex-grid-background');
expect(ids).toContain('theater-slop-phrase');
});
test('legacy provider flags are accepted as deprecated no-ops', () => {
const { stdout, stderr, code } = run('--gpt', '--json', path.join(FIXTURES, 'gpt-tells.html'));
expect(code).toBe(2);
expect(stderr).toContain('--gpt and --gemini are deprecated and ignored');
expect(JSON.parse(stdout).some(f => f.antipattern === 'codex-grid-background')).toBe(true);
});
test('detect subcommand is not treated as a scan target', () => {
+2 -2
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Gemini provider-tell fixture (gated --gemini)</title>
<title>Image hover-transform fixture</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; color: #1a1a1a; background: #fff; }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; padding: 24px; }
@@ -15,7 +15,7 @@
</head>
<body>
<div class="cols">
<!-- FLAG column: image hover-zoom (only surfaced under --gemini) -->
<!-- FLAG column: image hover transforms -->
<section class="col flag">
<div class="flag-css-hover">
<img src="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='160'%20height='120'%3E%3Crect%20width='160'%20height='120'%20fill='%23c9b89a'/%3E%3C/svg%3E" alt="CSS hover-zoom image" />
+2 -2
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>GPT provider-tell fixture (gated --gpt)</title>
<title>Recurring generated-UI tell fixture</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; color: #1a1a1a; background: #fff; }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; padding: 24px; }
@@ -14,7 +14,7 @@
</head>
<body>
<div class="cols">
<!-- FLAG column: GPT idioms (only surfaced under --gpt) -->
<!-- FLAG column: recurring generated-UI idioms -->
<section class="col flag">
<div class="card flag-thin-border-wide-shadow" style="border: 1px solid #e5e7eb; box-shadow: 0 0 24px rgba(0,0,0,0.18)">
Thin hairline border paired with a wide diffuse shadow.
+5 -5
View File
@@ -104,7 +104,7 @@ describe('applyInlineIgnores / isInlineIgnored', () => {
});
describe('detectText honors inline directives', () => {
const opts = { providers: [] };
const opts = {};
test('disable-line suppresses a same-line finding', () => {
const flagged = detectText('.a { font-family: Inter; }', 'a.css', opts);
@@ -128,7 +128,7 @@ describe('detectText honors inline directives', () => {
test('inlineIgnores:false bypasses the directive', () => {
const content = '.a { font-family: Inter; } /* impeccable-disable-line overused-font */';
const raw = detectText(content, 'a.css', { providers: [], inlineIgnores: false });
const raw = detectText(content, 'a.css', { inlineIgnores: false });
expect(raw.some((f) => f.antipattern === 'overused-font')).toBe(true);
});
@@ -154,19 +154,19 @@ describe('detectHtml honors whole-file directives (line-less findings)', () => {
<h1>Heading</h1><h2>Sub</h2></body></html>`;
test('overused-font fires without a directive', async () => {
const flagged = await detectHtml(await writeTmp(page()), { providers: [] });
const flagged = await detectHtml(await writeTmp(page()));
expect(flagged.some((f) => f.antipattern === 'overused-font')).toBe(true);
});
test('whole-file directive in an HTML comment suppresses it', async () => {
const file = await writeTmp(page('<!-- impeccable-disable overused-font -- exported brand doc -->'));
const waived = await detectHtml(file, { providers: [] });
const waived = await detectHtml(file);
expect(waived.some((f) => f.antipattern === 'overused-font')).toBe(false);
});
test('inlineIgnores:false bypasses it', async () => {
const file = await writeTmp(page('<!-- impeccable-disable overused-font -->'));
const raw = await detectHtml(file, { providers: [], inlineIgnores: false });
const raw = await detectHtml(file, { inlineIgnores: false });
expect(raw.some((f) => f.antipattern === 'overused-font')).toBe(true);
});
});