mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
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:
@@ -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)
|
||||
|
||||
@@ -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('--'));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 "theater" is a recurring generated-copy tic. Say plainly what the thing does or does not do.</p>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user