diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs index a6126fc57..c4ed0692f 100644 --- a/cli/engine/browser/injected/index.mjs +++ b/cli/engine/browser/injected/index.mjs @@ -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) diff --git a/cli/engine/cli/main.mjs b/cli/engine/cli/main.mjs index db3e76a8c..b3edfea8d 100644 --- a/cli/engine/cli/main.mjs +++ b/cli/engine/cli/main.mjs @@ -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 Only report rules in the given design domain (type, layout). Comma-separated. --viewport 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('--')); diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 76bf4ddeb..4d2a0f85e 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -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(... 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 (` 1px, - // transparent 1px`) and the inverted end-of-tile form - // (`transparent calc(100% - 1px), 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 `/ ` 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 . 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) diff --git a/cli/engine/engines/browser/detect-url.mjs b/cli/engine/engines/browser/detect-url.mjs index 09523e541..c25d5d3df 100644 --- a/cli/engine/engines/browser/detect-url.mjs +++ b/cli/engine/engines/browser/detect-url.mjs @@ -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 = {}) { diff --git a/cli/engine/engines/regex/detect-text.mjs b/cli/engine/engines/regex/detect-text.mjs index f641d5917..48f9197e9 100644 --- a/cli/engine/engines/regex/detect-text.mjs +++ b/cli/engine/engines/regex/detect-text.mjs @@ -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