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,