From e3e22007a974fbb2023d36a3abf643f49dfd1fb3 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 9 Jun 2026 10:56:32 -0700 Subject: [PATCH] [codex] Improve detector false positive handling (#232) * Improve detector false positive handling * Register docs integrity test * Fix clipped overflow decorative skip --- .../detector/browser/injected/index.mjs | 2 + .../detector/detect-antipatterns-browser.js | 371 ++++++++++++++++-- .../engines/static-html/css-cascade.mjs | 27 ++ .../scripts/detector/rules/checks.mjs | 369 +++++++++++++++-- .../detector/browser/injected/index.mjs | 2 + .../detector/detect-antipatterns-browser.js | 371 ++++++++++++++++-- .../engines/static-html/css-cascade.mjs | 27 ++ .../scripts/detector/rules/checks.mjs | 369 +++++++++++++++-- .../detector/browser/injected/index.mjs | 2 + .../detector/detect-antipatterns-browser.js | 371 ++++++++++++++++-- .../engines/static-html/css-cascade.mjs | 27 ++ .../scripts/detector/rules/checks.mjs | 369 +++++++++++++++-- .../detector/browser/injected/index.mjs | 2 + .../detector/detect-antipatterns-browser.js | 371 ++++++++++++++++-- .../engines/static-html/css-cascade.mjs | 27 ++ .../scripts/detector/rules/checks.mjs | 369 +++++++++++++++-- .../detector/browser/injected/index.mjs | 2 + .../detector/detect-antipatterns-browser.js | 371 ++++++++++++++++-- .../engines/static-html/css-cascade.mjs | 27 ++ .../scripts/detector/rules/checks.mjs | 369 +++++++++++++++-- .../detector/browser/injected/index.mjs | 2 + .../detector/detect-antipatterns-browser.js | 371 ++++++++++++++++-- .../engines/static-html/css-cascade.mjs | 27 ++ .../scripts/detector/rules/checks.mjs | 369 +++++++++++++++-- .../detector/browser/injected/index.mjs | 2 + .../detector/detect-antipatterns-browser.js | 371 ++++++++++++++++-- .../engines/static-html/css-cascade.mjs | 27 ++ .../scripts/detector/rules/checks.mjs | 369 +++++++++++++++-- .../detector/browser/injected/index.mjs | 2 + .../detector/detect-antipatterns-browser.js | 371 ++++++++++++++++-- .../engines/static-html/css-cascade.mjs | 27 ++ .../scripts/detector/rules/checks.mjs | 369 +++++++++++++++-- .../detector/browser/injected/index.mjs | 2 + .../detector/detect-antipatterns-browser.js | 371 ++++++++++++++++-- .../engines/static-html/css-cascade.mjs | 27 ++ .../scripts/detector/rules/checks.mjs | 369 +++++++++++++++-- .../detector/browser/injected/index.mjs | 2 + .../detector/detect-antipatterns-browser.js | 371 ++++++++++++++++-- .../engines/static-html/css-cascade.mjs | 27 ++ .../scripts/detector/rules/checks.mjs | 369 +++++++++++++++-- .../detector/browser/injected/index.mjs | 2 + .../detector/detect-antipatterns-browser.js | 371 ++++++++++++++++-- .../engines/static-html/css-cascade.mjs | 27 ++ .../scripts/detector/rules/checks.mjs | 369 +++++++++++++++-- .../detector/browser/injected/index.mjs | 2 + .../detector/detect-antipatterns-browser.js | 371 ++++++++++++++++-- .../engines/static-html/css-cascade.mjs | 27 ++ .../scripts/detector/rules/checks.mjs | 369 +++++++++++++++-- cli/engine/browser/injected/index.mjs | 2 + cli/engine/detect-antipatterns-browser.js | 371 ++++++++++++++++-- .../engines/static-html/css-cascade.mjs | 27 ++ cli/engine/rules/checks.mjs | 369 +++++++++++++++-- .../detector/browser/injected/index.mjs | 2 + .../detector/detect-antipatterns-browser.js | 371 ++++++++++++++++-- .../engines/static-html/css-cascade.mjs | 27 ++ .../scripts/detector/rules/checks.mjs | 369 +++++++++++++++-- site/scripts/components/live-demo.js | 3 - site/styles/kinpaku-kit.css | 4 +- site/styles/live-mode.css | 8 +- tests/detect-antipatterns-browser.test.mjs | 53 ++- tests/detect-antipatterns-fixtures.test.mjs | 38 +- .../clipped-overflow-container.html | 62 ++- .../antipatterns/cramped-padding.html | 14 + .../antipatterns/flush-against-border.html | 130 ++++++ tests/fixtures/antipatterns/gpt-tells.html | 6 + .../antipatterns/oversized-h1-browser.html | 27 ++ tests/fixtures/antipatterns/quality.html | 19 + .../repeated-section-kickers.html | 117 ++++++ .../fixtures/antipatterns/text-overflow.html | 12 + .../antipatterns/visual-contrast.html | 4 + 70 files changed, 10042 insertions(+), 1221 deletions(-) create mode 100644 tests/fixtures/antipatterns/oversized-h1-browser.html diff --git a/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs index 29b20f5ee..12aec29ce 100644 --- a/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -660,6 +660,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -1091,6 +1092,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index fcfaf6240..00b3ef81d 100644 --- a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -1544,11 +1544,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1577,9 +1582,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1589,6 +1604,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1602,6 +1622,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1623,6 +1644,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1805,6 +1827,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1834,7 +1934,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1842,7 +1943,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1890,10 +1991,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1901,10 +1998,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1924,8 +2021,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1953,13 +2050,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1967,15 +2058,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -2069,7 +2182,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2677,17 +2790,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2705,31 +2829,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2744,13 +2891,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2763,17 +2919,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2852,9 +3122,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); @@ -3543,6 +3826,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -3974,6 +4258,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index a7332fc35..0236b6caa 100644 --- a/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -267,7 +267,16 @@ const STATIC_DEFAULT_STYLE = { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', position: 'static', + top: 'auto', + right: 'auto', + bottom: 'auto', + left: 'auto', + inset: '', display: '', overflow: 'visible', overflowX: 'visible', @@ -312,7 +321,16 @@ const STATIC_PROP_MAP = { 'padding-right': 'paddingRight', 'padding-bottom': 'paddingBottom', 'padding-left': 'paddingLeft', + 'margin-top': 'marginTop', + 'margin-right': 'marginRight', + 'margin-bottom': 'marginBottom', + 'margin-left': 'marginLeft', 'position': 'position', + 'top': 'top', + 'right': 'right', + 'bottom': 'bottom', + 'left': 'left', + 'inset': 'inset', 'display': 'display', 'overflow': 'overflow', 'overflow-x': 'overflowX', @@ -579,6 +597,15 @@ function expandStaticDeclaration(prop, value) { ['paddingLeft', vals[3]], ]; } + if (p === 'margin') { + const vals = expandStaticBoxValues(splitCssTokens(v)); + return [ + ['marginTop', vals[0]], + ['marginRight', vals[1]], + ['marginBottom', vals[2]], + ['marginLeft', vals[3]], + ]; + } if (p === 'font') return parseStaticFont(v); if (p === 'transition') { const parsed = parseStaticTransition(v); diff --git a/.agents/skills/impeccable/scripts/detector/rules/checks.mjs b/.agents/skills/impeccable/scripts/detector/rules/checks.mjs index 0ffdf185c..4ef1352d0 100644 --- a/.agents/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.agents/skills/impeccable/scripts/detector/rules/checks.mjs @@ -974,11 +974,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1007,9 +1012,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1019,6 +1034,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1032,6 +1052,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1053,6 +1074,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1235,6 +1257,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1264,7 +1364,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1272,7 +1373,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1320,10 +1421,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1331,10 +1428,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1354,8 +1451,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1383,13 +1480,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1397,15 +1488,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -1499,7 +1612,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2107,17 +2220,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2135,31 +2259,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2174,13 +2321,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2193,17 +2349,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2282,9 +2552,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); diff --git a/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs index 29b20f5ee..12aec29ce 100644 --- a/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -660,6 +660,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -1091,6 +1092,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index fcfaf6240..00b3ef81d 100644 --- a/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -1544,11 +1544,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1577,9 +1582,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1589,6 +1604,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1602,6 +1622,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1623,6 +1644,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1805,6 +1827,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1834,7 +1934,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1842,7 +1943,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1890,10 +1991,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1901,10 +1998,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1924,8 +2021,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1953,13 +2050,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1967,15 +2058,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -2069,7 +2182,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2677,17 +2790,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2705,31 +2829,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2744,13 +2891,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2763,17 +2919,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2852,9 +3122,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); @@ -3543,6 +3826,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -3974,6 +4258,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index a7332fc35..0236b6caa 100644 --- a/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -267,7 +267,16 @@ const STATIC_DEFAULT_STYLE = { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', position: 'static', + top: 'auto', + right: 'auto', + bottom: 'auto', + left: 'auto', + inset: '', display: '', overflow: 'visible', overflowX: 'visible', @@ -312,7 +321,16 @@ const STATIC_PROP_MAP = { 'padding-right': 'paddingRight', 'padding-bottom': 'paddingBottom', 'padding-left': 'paddingLeft', + 'margin-top': 'marginTop', + 'margin-right': 'marginRight', + 'margin-bottom': 'marginBottom', + 'margin-left': 'marginLeft', 'position': 'position', + 'top': 'top', + 'right': 'right', + 'bottom': 'bottom', + 'left': 'left', + 'inset': 'inset', 'display': 'display', 'overflow': 'overflow', 'overflow-x': 'overflowX', @@ -579,6 +597,15 @@ function expandStaticDeclaration(prop, value) { ['paddingLeft', vals[3]], ]; } + if (p === 'margin') { + const vals = expandStaticBoxValues(splitCssTokens(v)); + return [ + ['marginTop', vals[0]], + ['marginRight', vals[1]], + ['marginBottom', vals[2]], + ['marginLeft', vals[3]], + ]; + } if (p === 'font') return parseStaticFont(v); if (p === 'transition') { const parsed = parseStaticTransition(v); diff --git a/.claude/skills/impeccable/scripts/detector/rules/checks.mjs b/.claude/skills/impeccable/scripts/detector/rules/checks.mjs index 0ffdf185c..4ef1352d0 100644 --- a/.claude/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.claude/skills/impeccable/scripts/detector/rules/checks.mjs @@ -974,11 +974,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1007,9 +1012,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1019,6 +1034,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1032,6 +1052,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1053,6 +1074,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1235,6 +1257,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1264,7 +1364,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1272,7 +1373,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1320,10 +1421,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1331,10 +1428,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1354,8 +1451,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1383,13 +1480,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1397,15 +1488,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -1499,7 +1612,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2107,17 +2220,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2135,31 +2259,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2174,13 +2321,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2193,17 +2349,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2282,9 +2552,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); diff --git a/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs index 29b20f5ee..12aec29ce 100644 --- a/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -660,6 +660,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -1091,6 +1092,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index fcfaf6240..00b3ef81d 100644 --- a/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -1544,11 +1544,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1577,9 +1582,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1589,6 +1604,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1602,6 +1622,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1623,6 +1644,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1805,6 +1827,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1834,7 +1934,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1842,7 +1943,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1890,10 +1991,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1901,10 +1998,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1924,8 +2021,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1953,13 +2050,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1967,15 +2058,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -2069,7 +2182,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2677,17 +2790,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2705,31 +2829,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2744,13 +2891,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2763,17 +2919,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2852,9 +3122,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); @@ -3543,6 +3826,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -3974,6 +4258,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index a7332fc35..0236b6caa 100644 --- a/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -267,7 +267,16 @@ const STATIC_DEFAULT_STYLE = { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', position: 'static', + top: 'auto', + right: 'auto', + bottom: 'auto', + left: 'auto', + inset: '', display: '', overflow: 'visible', overflowX: 'visible', @@ -312,7 +321,16 @@ const STATIC_PROP_MAP = { 'padding-right': 'paddingRight', 'padding-bottom': 'paddingBottom', 'padding-left': 'paddingLeft', + 'margin-top': 'marginTop', + 'margin-right': 'marginRight', + 'margin-bottom': 'marginBottom', + 'margin-left': 'marginLeft', 'position': 'position', + 'top': 'top', + 'right': 'right', + 'bottom': 'bottom', + 'left': 'left', + 'inset': 'inset', 'display': 'display', 'overflow': 'overflow', 'overflow-x': 'overflowX', @@ -579,6 +597,15 @@ function expandStaticDeclaration(prop, value) { ['paddingLeft', vals[3]], ]; } + if (p === 'margin') { + const vals = expandStaticBoxValues(splitCssTokens(v)); + return [ + ['marginTop', vals[0]], + ['marginRight', vals[1]], + ['marginBottom', vals[2]], + ['marginLeft', vals[3]], + ]; + } if (p === 'font') return parseStaticFont(v); if (p === 'transition') { const parsed = parseStaticTransition(v); diff --git a/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs b/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs index 0ffdf185c..4ef1352d0 100644 --- a/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs @@ -974,11 +974,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1007,9 +1012,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1019,6 +1034,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1032,6 +1052,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1053,6 +1074,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1235,6 +1257,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1264,7 +1364,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1272,7 +1373,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1320,10 +1421,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1331,10 +1428,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1354,8 +1451,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1383,13 +1480,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1397,15 +1488,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -1499,7 +1612,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2107,17 +2220,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2135,31 +2259,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2174,13 +2321,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2193,17 +2349,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2282,9 +2552,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); diff --git a/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs index 29b20f5ee..12aec29ce 100644 --- a/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -660,6 +660,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -1091,6 +1092,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index fcfaf6240..00b3ef81d 100644 --- a/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -1544,11 +1544,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1577,9 +1582,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1589,6 +1604,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1602,6 +1622,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1623,6 +1644,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1805,6 +1827,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1834,7 +1934,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1842,7 +1943,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1890,10 +1991,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1901,10 +1998,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1924,8 +2021,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1953,13 +2050,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1967,15 +2058,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -2069,7 +2182,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2677,17 +2790,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2705,31 +2829,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2744,13 +2891,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2763,17 +2919,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2852,9 +3122,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); @@ -3543,6 +3826,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -3974,6 +4258,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index a7332fc35..0236b6caa 100644 --- a/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -267,7 +267,16 @@ const STATIC_DEFAULT_STYLE = { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', position: 'static', + top: 'auto', + right: 'auto', + bottom: 'auto', + left: 'auto', + inset: '', display: '', overflow: 'visible', overflowX: 'visible', @@ -312,7 +321,16 @@ const STATIC_PROP_MAP = { 'padding-right': 'paddingRight', 'padding-bottom': 'paddingBottom', 'padding-left': 'paddingLeft', + 'margin-top': 'marginTop', + 'margin-right': 'marginRight', + 'margin-bottom': 'marginBottom', + 'margin-left': 'marginLeft', 'position': 'position', + 'top': 'top', + 'right': 'right', + 'bottom': 'bottom', + 'left': 'left', + 'inset': 'inset', 'display': 'display', 'overflow': 'overflow', 'overflow-x': 'overflowX', @@ -579,6 +597,15 @@ function expandStaticDeclaration(prop, value) { ['paddingLeft', vals[3]], ]; } + if (p === 'margin') { + const vals = expandStaticBoxValues(splitCssTokens(v)); + return [ + ['marginTop', vals[0]], + ['marginRight', vals[1]], + ['marginBottom', vals[2]], + ['marginLeft', vals[3]], + ]; + } if (p === 'font') return parseStaticFont(v); if (p === 'transition') { const parsed = parseStaticTransition(v); diff --git a/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs b/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs index 0ffdf185c..4ef1352d0 100644 --- a/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs @@ -974,11 +974,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1007,9 +1012,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1019,6 +1034,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1032,6 +1052,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1053,6 +1074,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1235,6 +1257,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1264,7 +1364,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1272,7 +1373,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1320,10 +1421,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1331,10 +1428,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1354,8 +1451,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1383,13 +1480,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1397,15 +1488,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -1499,7 +1612,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2107,17 +2220,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2135,31 +2259,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2174,13 +2321,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2193,17 +2349,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2282,9 +2552,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); diff --git a/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs index 29b20f5ee..12aec29ce 100644 --- a/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -660,6 +660,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -1091,6 +1092,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index fcfaf6240..00b3ef81d 100644 --- a/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -1544,11 +1544,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1577,9 +1582,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1589,6 +1604,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1602,6 +1622,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1623,6 +1644,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1805,6 +1827,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1834,7 +1934,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1842,7 +1943,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1890,10 +1991,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1901,10 +1998,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1924,8 +2021,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1953,13 +2050,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1967,15 +2058,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -2069,7 +2182,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2677,17 +2790,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2705,31 +2829,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2744,13 +2891,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2763,17 +2919,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2852,9 +3122,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); @@ -3543,6 +3826,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -3974,6 +4258,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index a7332fc35..0236b6caa 100644 --- a/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -267,7 +267,16 @@ const STATIC_DEFAULT_STYLE = { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', position: 'static', + top: 'auto', + right: 'auto', + bottom: 'auto', + left: 'auto', + inset: '', display: '', overflow: 'visible', overflowX: 'visible', @@ -312,7 +321,16 @@ const STATIC_PROP_MAP = { 'padding-right': 'paddingRight', 'padding-bottom': 'paddingBottom', 'padding-left': 'paddingLeft', + 'margin-top': 'marginTop', + 'margin-right': 'marginRight', + 'margin-bottom': 'marginBottom', + 'margin-left': 'marginLeft', 'position': 'position', + 'top': 'top', + 'right': 'right', + 'bottom': 'bottom', + 'left': 'left', + 'inset': 'inset', 'display': 'display', 'overflow': 'overflow', 'overflow-x': 'overflowX', @@ -579,6 +597,15 @@ function expandStaticDeclaration(prop, value) { ['paddingLeft', vals[3]], ]; } + if (p === 'margin') { + const vals = expandStaticBoxValues(splitCssTokens(v)); + return [ + ['marginTop', vals[0]], + ['marginRight', vals[1]], + ['marginBottom', vals[2]], + ['marginLeft', vals[3]], + ]; + } if (p === 'font') return parseStaticFont(v); if (p === 'transition') { const parsed = parseStaticTransition(v); diff --git a/.github/skills/impeccable/scripts/detector/rules/checks.mjs b/.github/skills/impeccable/scripts/detector/rules/checks.mjs index 0ffdf185c..4ef1352d0 100644 --- a/.github/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.github/skills/impeccable/scripts/detector/rules/checks.mjs @@ -974,11 +974,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1007,9 +1012,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1019,6 +1034,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1032,6 +1052,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1053,6 +1074,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1235,6 +1257,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1264,7 +1364,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1272,7 +1373,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1320,10 +1421,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1331,10 +1428,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1354,8 +1451,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1383,13 +1480,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1397,15 +1488,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -1499,7 +1612,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2107,17 +2220,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2135,31 +2259,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2174,13 +2321,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2193,17 +2349,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2282,9 +2552,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); diff --git a/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs index 29b20f5ee..12aec29ce 100644 --- a/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -660,6 +660,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -1091,6 +1092,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index fcfaf6240..00b3ef81d 100644 --- a/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -1544,11 +1544,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1577,9 +1582,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1589,6 +1604,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1602,6 +1622,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1623,6 +1644,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1805,6 +1827,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1834,7 +1934,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1842,7 +1943,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1890,10 +1991,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1901,10 +1998,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1924,8 +2021,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1953,13 +2050,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1967,15 +2058,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -2069,7 +2182,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2677,17 +2790,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2705,31 +2829,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2744,13 +2891,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2763,17 +2919,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2852,9 +3122,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); @@ -3543,6 +3826,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -3974,6 +4258,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index a7332fc35..0236b6caa 100644 --- a/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -267,7 +267,16 @@ const STATIC_DEFAULT_STYLE = { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', position: 'static', + top: 'auto', + right: 'auto', + bottom: 'auto', + left: 'auto', + inset: '', display: '', overflow: 'visible', overflowX: 'visible', @@ -312,7 +321,16 @@ const STATIC_PROP_MAP = { 'padding-right': 'paddingRight', 'padding-bottom': 'paddingBottom', 'padding-left': 'paddingLeft', + 'margin-top': 'marginTop', + 'margin-right': 'marginRight', + 'margin-bottom': 'marginBottom', + 'margin-left': 'marginLeft', 'position': 'position', + 'top': 'top', + 'right': 'right', + 'bottom': 'bottom', + 'left': 'left', + 'inset': 'inset', 'display': 'display', 'overflow': 'overflow', 'overflow-x': 'overflowX', @@ -579,6 +597,15 @@ function expandStaticDeclaration(prop, value) { ['paddingLeft', vals[3]], ]; } + if (p === 'margin') { + const vals = expandStaticBoxValues(splitCssTokens(v)); + return [ + ['marginTop', vals[0]], + ['marginRight', vals[1]], + ['marginBottom', vals[2]], + ['marginLeft', vals[3]], + ]; + } if (p === 'font') return parseStaticFont(v); if (p === 'transition') { const parsed = parseStaticTransition(v); diff --git a/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs b/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs index 0ffdf185c..4ef1352d0 100644 --- a/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs @@ -974,11 +974,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1007,9 +1012,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1019,6 +1034,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1032,6 +1052,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1053,6 +1074,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1235,6 +1257,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1264,7 +1364,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1272,7 +1373,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1320,10 +1421,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1331,10 +1428,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1354,8 +1451,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1383,13 +1480,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1397,15 +1488,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -1499,7 +1612,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2107,17 +2220,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2135,31 +2259,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2174,13 +2321,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2193,17 +2349,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2282,9 +2552,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); diff --git a/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs index 29b20f5ee..12aec29ce 100644 --- a/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -660,6 +660,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -1091,6 +1092,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index fcfaf6240..00b3ef81d 100644 --- a/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -1544,11 +1544,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1577,9 +1582,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1589,6 +1604,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1602,6 +1622,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1623,6 +1644,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1805,6 +1827,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1834,7 +1934,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1842,7 +1943,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1890,10 +1991,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1901,10 +1998,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1924,8 +2021,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1953,13 +2050,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1967,15 +2058,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -2069,7 +2182,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2677,17 +2790,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2705,31 +2829,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2744,13 +2891,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2763,17 +2919,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2852,9 +3122,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); @@ -3543,6 +3826,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -3974,6 +4258,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index a7332fc35..0236b6caa 100644 --- a/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -267,7 +267,16 @@ const STATIC_DEFAULT_STYLE = { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', position: 'static', + top: 'auto', + right: 'auto', + bottom: 'auto', + left: 'auto', + inset: '', display: '', overflow: 'visible', overflowX: 'visible', @@ -312,7 +321,16 @@ const STATIC_PROP_MAP = { 'padding-right': 'paddingRight', 'padding-bottom': 'paddingBottom', 'padding-left': 'paddingLeft', + 'margin-top': 'marginTop', + 'margin-right': 'marginRight', + 'margin-bottom': 'marginBottom', + 'margin-left': 'marginLeft', 'position': 'position', + 'top': 'top', + 'right': 'right', + 'bottom': 'bottom', + 'left': 'left', + 'inset': 'inset', 'display': 'display', 'overflow': 'overflow', 'overflow-x': 'overflowX', @@ -579,6 +597,15 @@ function expandStaticDeclaration(prop, value) { ['paddingLeft', vals[3]], ]; } + if (p === 'margin') { + const vals = expandStaticBoxValues(splitCssTokens(v)); + return [ + ['marginTop', vals[0]], + ['marginRight', vals[1]], + ['marginBottom', vals[2]], + ['marginLeft', vals[3]], + ]; + } if (p === 'font') return parseStaticFont(v); if (p === 'transition') { const parsed = parseStaticTransition(v); diff --git a/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs b/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs index 0ffdf185c..4ef1352d0 100644 --- a/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs @@ -974,11 +974,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1007,9 +1012,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1019,6 +1034,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1032,6 +1052,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1053,6 +1074,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1235,6 +1257,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1264,7 +1364,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1272,7 +1373,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1320,10 +1421,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1331,10 +1428,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1354,8 +1451,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1383,13 +1480,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1397,15 +1488,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -1499,7 +1612,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2107,17 +2220,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2135,31 +2259,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2174,13 +2321,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2193,17 +2349,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2282,9 +2552,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); diff --git a/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs index 29b20f5ee..12aec29ce 100644 --- a/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -660,6 +660,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -1091,6 +1092,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index fcfaf6240..00b3ef81d 100644 --- a/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -1544,11 +1544,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1577,9 +1582,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1589,6 +1604,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1602,6 +1622,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1623,6 +1644,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1805,6 +1827,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1834,7 +1934,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1842,7 +1943,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1890,10 +1991,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1901,10 +1998,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1924,8 +2021,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1953,13 +2050,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1967,15 +2058,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -2069,7 +2182,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2677,17 +2790,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2705,31 +2829,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2744,13 +2891,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2763,17 +2919,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2852,9 +3122,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); @@ -3543,6 +3826,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -3974,6 +4258,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index a7332fc35..0236b6caa 100644 --- a/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -267,7 +267,16 @@ const STATIC_DEFAULT_STYLE = { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', position: 'static', + top: 'auto', + right: 'auto', + bottom: 'auto', + left: 'auto', + inset: '', display: '', overflow: 'visible', overflowX: 'visible', @@ -312,7 +321,16 @@ const STATIC_PROP_MAP = { 'padding-right': 'paddingRight', 'padding-bottom': 'paddingBottom', 'padding-left': 'paddingLeft', + 'margin-top': 'marginTop', + 'margin-right': 'marginRight', + 'margin-bottom': 'marginBottom', + 'margin-left': 'marginLeft', 'position': 'position', + 'top': 'top', + 'right': 'right', + 'bottom': 'bottom', + 'left': 'left', + 'inset': 'inset', 'display': 'display', 'overflow': 'overflow', 'overflow-x': 'overflowX', @@ -579,6 +597,15 @@ function expandStaticDeclaration(prop, value) { ['paddingLeft', vals[3]], ]; } + if (p === 'margin') { + const vals = expandStaticBoxValues(splitCssTokens(v)); + return [ + ['marginTop', vals[0]], + ['marginRight', vals[1]], + ['marginBottom', vals[2]], + ['marginLeft', vals[3]], + ]; + } if (p === 'font') return parseStaticFont(v); if (p === 'transition') { const parsed = parseStaticTransition(v); diff --git a/.pi/skills/impeccable/scripts/detector/rules/checks.mjs b/.pi/skills/impeccable/scripts/detector/rules/checks.mjs index 0ffdf185c..4ef1352d0 100644 --- a/.pi/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.pi/skills/impeccable/scripts/detector/rules/checks.mjs @@ -974,11 +974,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1007,9 +1012,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1019,6 +1034,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1032,6 +1052,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1053,6 +1074,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1235,6 +1257,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1264,7 +1364,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1272,7 +1373,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1320,10 +1421,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1331,10 +1428,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1354,8 +1451,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1383,13 +1480,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1397,15 +1488,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -1499,7 +1612,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2107,17 +2220,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2135,31 +2259,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2174,13 +2321,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2193,17 +2349,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2282,9 +2552,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); diff --git a/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs index 29b20f5ee..12aec29ce 100644 --- a/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -660,6 +660,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -1091,6 +1092,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index fcfaf6240..00b3ef81d 100644 --- a/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -1544,11 +1544,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1577,9 +1582,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1589,6 +1604,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1602,6 +1622,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1623,6 +1644,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1805,6 +1827,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1834,7 +1934,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1842,7 +1943,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1890,10 +1991,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1901,10 +1998,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1924,8 +2021,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1953,13 +2050,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1967,15 +2058,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -2069,7 +2182,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2677,17 +2790,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2705,31 +2829,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2744,13 +2891,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2763,17 +2919,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2852,9 +3122,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); @@ -3543,6 +3826,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -3974,6 +4258,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index a7332fc35..0236b6caa 100644 --- a/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -267,7 +267,16 @@ const STATIC_DEFAULT_STYLE = { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', position: 'static', + top: 'auto', + right: 'auto', + bottom: 'auto', + left: 'auto', + inset: '', display: '', overflow: 'visible', overflowX: 'visible', @@ -312,7 +321,16 @@ const STATIC_PROP_MAP = { 'padding-right': 'paddingRight', 'padding-bottom': 'paddingBottom', 'padding-left': 'paddingLeft', + 'margin-top': 'marginTop', + 'margin-right': 'marginRight', + 'margin-bottom': 'marginBottom', + 'margin-left': 'marginLeft', 'position': 'position', + 'top': 'top', + 'right': 'right', + 'bottom': 'bottom', + 'left': 'left', + 'inset': 'inset', 'display': 'display', 'overflow': 'overflow', 'overflow-x': 'overflowX', @@ -579,6 +597,15 @@ function expandStaticDeclaration(prop, value) { ['paddingLeft', vals[3]], ]; } + if (p === 'margin') { + const vals = expandStaticBoxValues(splitCssTokens(v)); + return [ + ['marginTop', vals[0]], + ['marginRight', vals[1]], + ['marginBottom', vals[2]], + ['marginLeft', vals[3]], + ]; + } if (p === 'font') return parseStaticFont(v); if (p === 'transition') { const parsed = parseStaticTransition(v); diff --git a/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs b/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs index 0ffdf185c..4ef1352d0 100644 --- a/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs @@ -974,11 +974,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1007,9 +1012,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1019,6 +1034,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1032,6 +1052,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1053,6 +1074,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1235,6 +1257,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1264,7 +1364,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1272,7 +1373,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1320,10 +1421,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1331,10 +1428,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1354,8 +1451,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1383,13 +1480,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1397,15 +1488,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -1499,7 +1612,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2107,17 +2220,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2135,31 +2259,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2174,13 +2321,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2193,17 +2349,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2282,9 +2552,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); diff --git a/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs index 29b20f5ee..12aec29ce 100644 --- a/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -660,6 +660,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -1091,6 +1092,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index fcfaf6240..00b3ef81d 100644 --- a/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -1544,11 +1544,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1577,9 +1582,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1589,6 +1604,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1602,6 +1622,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1623,6 +1644,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1805,6 +1827,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1834,7 +1934,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1842,7 +1943,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1890,10 +1991,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1901,10 +1998,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1924,8 +2021,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1953,13 +2050,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1967,15 +2058,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -2069,7 +2182,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2677,17 +2790,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2705,31 +2829,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2744,13 +2891,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2763,17 +2919,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2852,9 +3122,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); @@ -3543,6 +3826,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -3974,6 +4258,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index a7332fc35..0236b6caa 100644 --- a/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -267,7 +267,16 @@ const STATIC_DEFAULT_STYLE = { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', position: 'static', + top: 'auto', + right: 'auto', + bottom: 'auto', + left: 'auto', + inset: '', display: '', overflow: 'visible', overflowX: 'visible', @@ -312,7 +321,16 @@ const STATIC_PROP_MAP = { 'padding-right': 'paddingRight', 'padding-bottom': 'paddingBottom', 'padding-left': 'paddingLeft', + 'margin-top': 'marginTop', + 'margin-right': 'marginRight', + 'margin-bottom': 'marginBottom', + 'margin-left': 'marginLeft', 'position': 'position', + 'top': 'top', + 'right': 'right', + 'bottom': 'bottom', + 'left': 'left', + 'inset': 'inset', 'display': 'display', 'overflow': 'overflow', 'overflow-x': 'overflowX', @@ -579,6 +597,15 @@ function expandStaticDeclaration(prop, value) { ['paddingLeft', vals[3]], ]; } + if (p === 'margin') { + const vals = expandStaticBoxValues(splitCssTokens(v)); + return [ + ['marginTop', vals[0]], + ['marginRight', vals[1]], + ['marginBottom', vals[2]], + ['marginLeft', vals[3]], + ]; + } if (p === 'font') return parseStaticFont(v); if (p === 'transition') { const parsed = parseStaticTransition(v); diff --git a/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs b/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs index 0ffdf185c..4ef1352d0 100644 --- a/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs @@ -974,11 +974,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1007,9 +1012,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1019,6 +1034,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1032,6 +1052,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1053,6 +1074,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1235,6 +1257,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1264,7 +1364,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1272,7 +1373,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1320,10 +1421,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1331,10 +1428,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1354,8 +1451,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1383,13 +1480,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1397,15 +1488,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -1499,7 +1612,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2107,17 +2220,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2135,31 +2259,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2174,13 +2321,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2193,17 +2349,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2282,9 +2552,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); diff --git a/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs index 29b20f5ee..12aec29ce 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -660,6 +660,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -1091,6 +1092,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index fcfaf6240..00b3ef81d 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -1544,11 +1544,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1577,9 +1582,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1589,6 +1604,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1602,6 +1622,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1623,6 +1644,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1805,6 +1827,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1834,7 +1934,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1842,7 +1943,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1890,10 +1991,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1901,10 +1998,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1924,8 +2021,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1953,13 +2050,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1967,15 +2058,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -2069,7 +2182,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2677,17 +2790,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2705,31 +2829,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2744,13 +2891,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2763,17 +2919,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2852,9 +3122,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); @@ -3543,6 +3826,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -3974,6 +4258,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index a7332fc35..0236b6caa 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -267,7 +267,16 @@ const STATIC_DEFAULT_STYLE = { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', position: 'static', + top: 'auto', + right: 'auto', + bottom: 'auto', + left: 'auto', + inset: '', display: '', overflow: 'visible', overflowX: 'visible', @@ -312,7 +321,16 @@ const STATIC_PROP_MAP = { 'padding-right': 'paddingRight', 'padding-bottom': 'paddingBottom', 'padding-left': 'paddingLeft', + 'margin-top': 'marginTop', + 'margin-right': 'marginRight', + 'margin-bottom': 'marginBottom', + 'margin-left': 'marginLeft', 'position': 'position', + 'top': 'top', + 'right': 'right', + 'bottom': 'bottom', + 'left': 'left', + 'inset': 'inset', 'display': 'display', 'overflow': 'overflow', 'overflow-x': 'overflowX', @@ -579,6 +597,15 @@ function expandStaticDeclaration(prop, value) { ['paddingLeft', vals[3]], ]; } + if (p === 'margin') { + const vals = expandStaticBoxValues(splitCssTokens(v)); + return [ + ['marginTop', vals[0]], + ['marginRight', vals[1]], + ['marginBottom', vals[2]], + ['marginLeft', vals[3]], + ]; + } if (p === 'font') return parseStaticFont(v); if (p === 'transition') { const parsed = parseStaticTransition(v); diff --git a/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs b/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs index 0ffdf185c..4ef1352d0 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs @@ -974,11 +974,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1007,9 +1012,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1019,6 +1034,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1032,6 +1052,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1053,6 +1074,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1235,6 +1257,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1264,7 +1364,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1272,7 +1373,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1320,10 +1421,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1331,10 +1428,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1354,8 +1451,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1383,13 +1480,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1397,15 +1488,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -1499,7 +1612,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2107,17 +2220,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2135,31 +2259,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2174,13 +2321,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2193,17 +2349,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2282,9 +2552,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); diff --git a/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs index 29b20f5ee..12aec29ce 100644 --- a/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -660,6 +660,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -1091,6 +1092,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index fcfaf6240..00b3ef81d 100644 --- a/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -1544,11 +1544,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1577,9 +1582,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1589,6 +1604,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1602,6 +1622,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1623,6 +1644,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1805,6 +1827,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1834,7 +1934,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1842,7 +1943,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1890,10 +1991,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1901,10 +1998,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1924,8 +2021,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1953,13 +2050,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1967,15 +2058,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -2069,7 +2182,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2677,17 +2790,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2705,31 +2829,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2744,13 +2891,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2763,17 +2919,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2852,9 +3122,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); @@ -3543,6 +3826,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -3974,6 +4258,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index a7332fc35..0236b6caa 100644 --- a/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -267,7 +267,16 @@ const STATIC_DEFAULT_STYLE = { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', position: 'static', + top: 'auto', + right: 'auto', + bottom: 'auto', + left: 'auto', + inset: '', display: '', overflow: 'visible', overflowX: 'visible', @@ -312,7 +321,16 @@ const STATIC_PROP_MAP = { 'padding-right': 'paddingRight', 'padding-bottom': 'paddingBottom', 'padding-left': 'paddingLeft', + 'margin-top': 'marginTop', + 'margin-right': 'marginRight', + 'margin-bottom': 'marginBottom', + 'margin-left': 'marginLeft', 'position': 'position', + 'top': 'top', + 'right': 'right', + 'bottom': 'bottom', + 'left': 'left', + 'inset': 'inset', 'display': 'display', 'overflow': 'overflow', 'overflow-x': 'overflowX', @@ -579,6 +597,15 @@ function expandStaticDeclaration(prop, value) { ['paddingLeft', vals[3]], ]; } + if (p === 'margin') { + const vals = expandStaticBoxValues(splitCssTokens(v)); + return [ + ['marginTop', vals[0]], + ['marginRight', vals[1]], + ['marginBottom', vals[2]], + ['marginLeft', vals[3]], + ]; + } if (p === 'font') return parseStaticFont(v); if (p === 'transition') { const parsed = parseStaticTransition(v); diff --git a/.trae/skills/impeccable/scripts/detector/rules/checks.mjs b/.trae/skills/impeccable/scripts/detector/rules/checks.mjs index 0ffdf185c..4ef1352d0 100644 --- a/.trae/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.trae/skills/impeccable/scripts/detector/rules/checks.mjs @@ -974,11 +974,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1007,9 +1012,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1019,6 +1034,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1032,6 +1052,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1053,6 +1074,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1235,6 +1257,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1264,7 +1364,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1272,7 +1373,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1320,10 +1421,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1331,10 +1428,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1354,8 +1451,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1383,13 +1480,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1397,15 +1488,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -1499,7 +1612,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2107,17 +2220,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2135,31 +2259,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2174,13 +2321,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2193,17 +2349,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2282,9 +2552,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs index 29b20f5ee..12aec29ce 100644 --- a/cli/engine/browser/injected/index.mjs +++ b/cli/engine/browser/injected/index.mjs @@ -660,6 +660,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -1091,6 +1092,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index fcfaf6240..00b3ef81d 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -1544,11 +1544,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1577,9 +1582,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1589,6 +1604,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1602,6 +1622,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1623,6 +1644,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1805,6 +1827,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1834,7 +1934,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1842,7 +1943,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1890,10 +1991,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1901,10 +1998,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1924,8 +2021,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1953,13 +2050,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1967,15 +2058,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -2069,7 +2182,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2677,17 +2790,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2705,31 +2829,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2744,13 +2891,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2763,17 +2919,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2852,9 +3122,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); @@ -3543,6 +3826,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -3974,6 +4258,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/cli/engine/engines/static-html/css-cascade.mjs b/cli/engine/engines/static-html/css-cascade.mjs index a7332fc35..0236b6caa 100644 --- a/cli/engine/engines/static-html/css-cascade.mjs +++ b/cli/engine/engines/static-html/css-cascade.mjs @@ -267,7 +267,16 @@ const STATIC_DEFAULT_STYLE = { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', position: 'static', + top: 'auto', + right: 'auto', + bottom: 'auto', + left: 'auto', + inset: '', display: '', overflow: 'visible', overflowX: 'visible', @@ -312,7 +321,16 @@ const STATIC_PROP_MAP = { 'padding-right': 'paddingRight', 'padding-bottom': 'paddingBottom', 'padding-left': 'paddingLeft', + 'margin-top': 'marginTop', + 'margin-right': 'marginRight', + 'margin-bottom': 'marginBottom', + 'margin-left': 'marginLeft', 'position': 'position', + 'top': 'top', + 'right': 'right', + 'bottom': 'bottom', + 'left': 'left', + 'inset': 'inset', 'display': 'display', 'overflow': 'overflow', 'overflow-x': 'overflowX', @@ -579,6 +597,15 @@ function expandStaticDeclaration(prop, value) { ['paddingLeft', vals[3]], ]; } + if (p === 'margin') { + const vals = expandStaticBoxValues(splitCssTokens(v)); + return [ + ['marginTop', vals[0]], + ['marginRight', vals[1]], + ['marginBottom', vals[2]], + ['marginLeft', vals[3]], + ]; + } if (p === 'font') return parseStaticFont(v); if (p === 'transition') { const parsed = parseStaticTransition(v); diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index 0ffdf185c..4ef1352d0 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -974,11 +974,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1007,9 +1012,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1019,6 +1034,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1032,6 +1052,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1053,6 +1074,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1235,6 +1257,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1264,7 +1364,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1272,7 +1373,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1320,10 +1421,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1331,10 +1428,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1354,8 +1451,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1383,13 +1480,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1397,15 +1488,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -1499,7 +1612,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2107,17 +2220,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2135,31 +2259,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2174,13 +2321,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2193,17 +2349,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2282,9 +2552,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); diff --git a/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs b/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs index 29b20f5ee..12aec29ce 100644 --- a/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -660,6 +660,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -1091,6 +1092,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index fcfaf6240..00b3ef81d 100644 --- a/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -1544,11 +1544,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1577,9 +1582,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1589,6 +1604,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1602,6 +1622,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1623,6 +1644,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1805,6 +1827,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1834,7 +1934,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1842,7 +1943,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1890,10 +1991,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1901,10 +1998,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1924,8 +2021,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1953,13 +2050,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1967,15 +2058,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -2069,7 +2182,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2677,17 +2790,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2705,31 +2829,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2744,13 +2891,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2763,17 +2919,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2852,9 +3122,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); @@ -3543,6 +3826,7 @@ if (IS_BROWSER) { if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; if (el.closest('[id^="impeccable-live-"]')) continue; if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; const tag = el.tagName.toLowerCase(); const style = getComputedStyle(el); @@ -3974,6 +4258,7 @@ if (IS_BROWSER) { return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; } if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; const blockingReason = (candidate.reasons || []).find(reason => reason === 'background-clip text' || diff --git a/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index a7332fc35..0236b6caa 100644 --- a/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -267,7 +267,16 @@ const STATIC_DEFAULT_STYLE = { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px', position: 'static', + top: 'auto', + right: 'auto', + bottom: 'auto', + left: 'auto', + inset: '', display: '', overflow: 'visible', overflowX: 'visible', @@ -312,7 +321,16 @@ const STATIC_PROP_MAP = { 'padding-right': 'paddingRight', 'padding-bottom': 'paddingBottom', 'padding-left': 'paddingLeft', + 'margin-top': 'marginTop', + 'margin-right': 'marginRight', + 'margin-bottom': 'marginBottom', + 'margin-left': 'marginLeft', 'position': 'position', + 'top': 'top', + 'right': 'right', + 'bottom': 'bottom', + 'left': 'left', + 'inset': 'inset', 'display': 'display', 'overflow': 'overflow', 'overflow-x': 'overflowX', @@ -579,6 +597,15 @@ function expandStaticDeclaration(prop, value) { ['paddingLeft', vals[3]], ]; } + if (p === 'margin') { + const vals = expandStaticBoxValues(splitCssTokens(v)); + return [ + ['marginTop', vals[0]], + ['marginRight', vals[1]], + ['marginBottom', vals[2]], + ['marginLeft', vals[3]], + ]; + } if (p === 'font') return parseStaticFont(v); if (p === 'transition') { const parsed = parseStaticTransition(v); diff --git a/plugin/skills/impeccable/scripts/detector/rules/checks.mjs b/plugin/skills/impeccable/scripts/detector/rules/checks.mjs index 0ffdf185c..4ef1352d0 100644 --- a/plugin/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/plugin/skills/impeccable/scripts/detector/rules/checks.mjs @@ -974,11 +974,16 @@ function parseAnyColor(s) { // OKLCH parser. Tailwind v4's CSS minifier squishes the space after // `%` ("21.5%.02 50"), so the separator between L and C may be absent. // Match L (with optional %), then C and H separated permissively. - m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); if (m) { const Lnum = parseFloat(m[1]); const L = m[2] === '%' ? Lnum / 100 : Lnum; - return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); + if (m[5] !== undefined) { + const alpha = parseFloat(m[5]); + rgb.a = m[6] === '%' ? alpha / 100 : alpha; + } + return rgb; } return null; } @@ -1007,9 +1012,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [ '[role="navigation"]', '[aria-label*="breadcrumb" i]', '[class*="breadcrumb" i]', + '[aria-hidden="true"]', '[data-impeccable-allow-kickers]', ].join(','); +const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ + 'article', + 'button', + 'a', + 'li', + '[role="listitem"]', + '[role="option"]', +].join(','); + function cleanInlineText(el) { return [...el.childNodes] .filter(n => n.nodeType === 3) @@ -1019,6 +1034,11 @@ function cleanInlineText(el) { .trim(); } +function isRepeatedKickerCardContext(heading, kicker) { + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); + return Boolean(item && (!item.contains || item.contains(kicker))); +} + function isRepeatedKickerCandidate(opts) { const { headingTag, @@ -1032,6 +1052,7 @@ function isRepeatedKickerCandidate(opts) { } = opts; if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; if (!headingText || headingText.length < 3) return false; + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; if (!(headingFontSize >= 20)) return false; if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; @@ -1053,6 +1074,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; const kicker = heading.previousElementSibling; if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; + if (isRepeatedKickerCardContext(heading, kicker)) continue; const headingStyle = getStyle(heading); const kickerStyle = getStyle(kicker); @@ -1235,6 +1257,84 @@ function resolveLengthPx(value, fontSizePx) { return num * fontSizePx; } +function cssColorIsTransparent(value) { + if (!value) return true; + const str = String(value).trim().toLowerCase(); + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; + const parsed = parseAnyColor(str); + if (parsed) return (parsed.a ?? 1) <= 0.05; + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); +} + +function colorsNearlyMatch(a, b) { + const ca = parseAnyColor(a); + const cb = parseAnyColor(b); + if (!ca || !cb) return false; + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); + const channelDelta = Math.max( + Math.abs(ca.r - cb.r), + Math.abs(ca.g - cb.g), + Math.abs(ca.b - cb.b), + ); + return alphaDelta <= 0.03 && channelDelta <= 3; +} + +function getComputedStyleFor(win, el) { + if (win && typeof win.getComputedStyle === 'function') { + try { return win.getComputedStyle(el); } catch {} + } + if (typeof getComputedStyle === 'function') { + try { return getComputedStyle(el); } catch {} + } + return null; +} + +function hasVisibleBackgroundBoundary(style, el, win) { + const bg = style?.backgroundColor || ''; + if (cssColorIsTransparent(bg)) return false; + + let parent = el?.parentElement || null; + while (parent) { + const parentStyle = getComputedStyleFor(win, parent); + const parentBg = parentStyle?.backgroundColor || ''; + if (!cssColorIsTransparent(parentBg)) { + return !colorsNearlyMatch(bg, parentBg); + } + parent = parent.parentElement; + } + + return true; +} + +const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); + +function hasMeaningfulDirectText(node) { + if (!node?.childNodes) return false; + for (const child of node.childNodes) { + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; + } + return false; +} + +function textDescendantsFlushSides(el, rect) { + const flush = { top: false, right: false, bottom: false, left: false }; + if (!rect || !el?.querySelectorAll) return flush; + const TEXT_EDGE_THRESHOLD = 4; + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); + for (const node of candidates) { + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; + let nodeRect = null; + try { nodeRect = node.getBoundingClientRect(); } catch {} + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; + } + return flush; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -1264,7 +1364,8 @@ function checkQuality(opts) { // font-size — bigger text demands proportionally more padding. // vertical: max(4px, fontSize × 0.3) // horizontal: max(8px, fontSize × 0.5) - if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { const borders = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1272,7 +1373,7 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderCount = Object.values(borders).filter(w => w > 0).length; - const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBg = hasVisibleBackgroundBoundary(style, el, win); if (borderCount >= 2 || hasBg) { const vPads = [], hPads = []; if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); @@ -1320,10 +1421,6 @@ function checkQuality(opts) { !['fixed', 'absolute'].includes(elPosition) && el.children && el.children.length > 0 ) { - const isTransparent = (c) => - !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || - /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); - const borderW = { top: parseFloat(style.borderTopWidth) || 0, right: parseFloat(style.borderRightWidth) || 0, @@ -1331,10 +1428,10 @@ function checkQuality(opts) { left: parseFloat(style.borderLeftWidth) || 0, }; const borderVisible = { - top: borderW.top > 0 && !isTransparent(style.borderTopColor), - right: borderW.right > 0 && !isTransparent(style.borderRightColor), - bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), - left: borderW.left > 0 && !isTransparent(style.borderLeftColor), + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), }; // Outline detection. jsdom decomposes `border` shorthand into // border{Top,…}Width/Color but does NOT decompose `outline` — @@ -1354,8 +1451,8 @@ function checkQuality(opts) { if (cMatch) outlineColorVal = cMatch[1]; } } - const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; - const bgVisible = !isTransparent(style.backgroundColor); + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; if (anyVisible) { @@ -1383,13 +1480,7 @@ function checkQuality(opts) { const CHILD_INSULATE_THRESHOLD = 4; const childrenInsulate = { top: false, right: false, bottom: false, left: false }; for (const child of el.children) { - let childStyle = null; - if (win && typeof win.getComputedStyle === 'function') { - try { childStyle = win.getComputedStyle(child); } catch {} - } - if (!childStyle && typeof getComputedStyle === 'function') { - try { childStyle = getComputedStyle(child); } catch {} - } + let childStyle = getComputedStyleFor(win, child); if (!childStyle) continue; const childPad = { top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, @@ -1397,15 +1488,37 @@ function checkQuality(opts) { bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, }; + const childMargin = { + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, + }; + if (rect && typeof child.getBoundingClientRect === 'function') { + try { + const childRect = child.getBoundingClientRect(); + if (childRect && childRect.width > 0 && childRect.height > 0) { + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; + } + } catch {} + } for (const s of ['top', 'right', 'bottom', 'left']) { - if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { + childrenInsulate[s] = true; + } } } + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; const flushSides = []; for (const side of ['top', 'right', 'bottom', 'left']) { - const sideBounded = borderVisible[side] || outlineVisible || bgVisible; - if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { flushSides.push(side); } } @@ -1499,7 +1612,7 @@ function checkQuality(opts) { // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; - const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); const isUppercase = style.textTransform === 'uppercase'; if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); @@ -2107,17 +2220,28 @@ function checkCreamPalette(doc, win) { } // ─── Oversized hero headline ──────────────────────────────────────────────── -// Fires when a *long* headline is set at display size, so a full sentence ends -// up dominating the viewport. A punchy one- or two-word headline at the same -// size is a legitimate stylistic choice and must pass — length, not size -// alone, is the tell. +// Fires when a *long* headline is set at display size and actually dominates +// the viewport. A punchy one- or two-word headline at the same size is a +// legitimate stylistic choice, and a large-but-contained two-line hero should +// pass too — length and viewport share together are the tell. const OVERSIZED_H1_FONT_PX = 72; const OVERSIZED_H1_MIN_CHARS = 40; -function checkOversizedH1({ tag, fontSize, headingText }) { +const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; +const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; +function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { if (tag !== 'h1') return []; const textLen = headingText.length; if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { - return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; + let viewportDetail = ''; + if (rect && viewportWidth > 0 && viewportHeight > 0) { + const heightRatio = rect.height / viewportHeight; + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; + if (!dominatesViewport) return []; + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; + } + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; } return []; } @@ -2135,31 +2259,54 @@ function checkElementOversizedH1DOM(el) { const style = getComputedStyle(el); const fontSize = parseFloat(style.fontSize) || 0; const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); - return checkOversizedH1({ tag, fontSize, headingText }); + const rect = el.getBoundingClientRect(); + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); } // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── -function shadowMaxBlurPx(boxShadow) { +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) { + CSS_COLOR_TOKEN_RE.lastIndex = 0; + const match = CSS_COLOR_TOKEN_RE.exec(layer); + if (!match) return 1; + if (match[0].toLowerCase() === 'transparent') return 0; + const parsed = parseAnyColor(match[0]); + return parsed ? (parsed.a ?? 1) : 1; +} + +function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { if (!boxShadow || boxShadow === 'none') return 0; let maxBlur = 0; // Split into layers on commas not inside parentheses (rgba(...) etc.). for (const layer of boxShadow.split(/,(?![^()]*\))/)) { + if (shadowLayerAlpha(layer) < minAlpha) continue; // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — // both reduce to the same numbers here. - const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); } return maxBlur; } -function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { - const maxBorder = Math.max(0, ...borderWidths); - const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; - const blur = shadowMaxBlurPx(boxShadow); - if (hasThinBorder && blur >= 16) { +function cssColorAlpha(value) { + if (cssColorIsTransparent(value)) return 0; + const parsed = parseAnyColor(value); + return parsed ? (parsed.a ?? 1) : 1; +} + +function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { + const visibleThinBorders = borderWidths + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); + if (visibleThinBorders.length >= 2 && blur >= 16) { return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; } return []; @@ -2174,13 +2321,22 @@ function borderWidthsFromStyle(style) { ]; } +function borderColorsFromStyle(style) { + return [ + style.borderTopColor || '', + style.borderRightColor || '', + style.borderBottomColor || '', + style.borderLeftColor || '', + ]; +} + function checkElementGptBorderShadow(el, style) { - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } function checkElementGptBorderShadowDOM(el) { const style = getComputedStyle(el); - return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); } // ─── Clipped overflow container ─────────────────────────────────────────────── @@ -2193,17 +2349,131 @@ function classSelector(el) { return tokens.length ? `${tag}.${tokens.join('.')}` : tag; } +function positionedChildIsDecorative(child) { + if (!child || typeof child.getAttribute !== 'function') return false; + if (child.closest?.('[aria-hidden="true"]')) return true; + const role = (child.getAttribute('role') || '').toLowerCase(); + if (role === 'none' || role === 'presentation') return true; + const tag = child.tagName ? child.tagName.toLowerCase() : ''; + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; + if ( + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && + !positionedChildHasSubstantiveContent(child) + ) { + return true; + } + return false; +} + +const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ + 'a[href]', + 'button', + 'input', + 'select', + 'summary', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="dialog"]', + '[role="link"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="option"]', + '[role="tooltip"]', +].join(','); + +function positionedChildHasSubstantiveContent(child) { + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); + if (text.length > 0) return true; + if (typeof child.matches === 'function') { + try { + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + if (typeof child.querySelector === 'function') { + try { + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; + } catch {} + } + return false; +} + +function clippingContainerIsIntentionalViewport(el) { + if (!el || typeof el.getAttribute !== 'function') return false; + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); +} + +function elementRect(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + try { + const rect = el.getBoundingClientRect(); + if (!rect) return null; + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; + if (!values.every(Number.isFinite)) return null; + if (rect.width <= 0 && rect.height <= 0) return null; + return rect; + } catch { + return null; + } +} + +function positionedStyleImpliesEscape(style) { + const values = [ + style.top, + style.right, + style.bottom, + style.left, + style.inset, + style.insetBlock, + style.insetInline, + style.insetBlockStart, + style.insetBlockEnd, + style.insetInlineStart, + style.insetInlineEnd, + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); + for (const value of values) { + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; + } + return false; +} + +function positionedChildEscapesClip(el, child, clipX, clipY) { + const parentRect = elementRect(el); + const childRect = elementRect(child); + if (!parentRect || !childRect) return null; + const threshold = 2; + return Boolean( + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) + ); +} + function checkClippedOverflow(el, style, getStyle) { const clips = (v) => v === 'hidden' || v === 'clip'; const scrolls = (v) => v === 'auto' || v === 'scroll'; const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; - const anyClip = clips(ox) || clips(oy) || clips(ov); + const clipX = clips(ox) || clips(ov); + const clipY = clips(oy) || clips(ov); + const anyClip = clipX || clipY; const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); if (!anyClip || anyScroll) return []; + if (clippingContainerIsIntentionalViewport(el)) return []; if (!el.querySelectorAll) return []; for (const child of el.querySelectorAll('*')) { - const pos = (getStyle(child).position) || ''; + const childStyle = getStyle(child); + const pos = childStyle.position || ''; if (pos === 'absolute' || pos === 'fixed') { + if (positionedChildIsDecorative(child)) continue; + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); + if (escapes === false) continue; + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; } } @@ -2282,9 +2552,22 @@ function isScreenReaderOnlyTextStyle(style, metrics = {}) { return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); } +function isRenderedForBrowserRule(el) { + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { + if (cur.getAttribute?.('aria-hidden') === 'true') return false; + const style = getComputedStyle(cur); + const visibility = String(style.visibility || '').toLowerCase(); + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; + } + return true; +} + function checkElementTextOverflowDOM(el) { const tag = el.tagName.toLowerCase(); if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; + if (!isRenderedForBrowserRule(el)) return []; // Only the element that actually owns overflowing text — not its ancestors, // which inherit a wider scrollWidth from the spilling descendant. const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); diff --git a/site/scripts/components/live-demo.js b/site/scripts/components/live-demo.js index e783f7f65..54228c98d 100644 --- a/site/scripts/components/live-demo.js +++ b/site/scripts/components/live-demo.js @@ -339,7 +339,6 @@ export function initLiveDemo() { /** Collapsed page-chat pill on marketing gbars — mirrors live-browser.js expand UX. */ export function initGbarPageChat() { - const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; document.querySelectorAll('[data-demo-gbar-chat]').forEach((chat) => { const input = chat.querySelector('.live-demo-gbar-chat-input'); const hint = chat.querySelector('.live-demo-gbar-chat-hint'); @@ -371,8 +370,6 @@ export function initGbarPageChat() { } }; - chat.style.transition = `width 0.28s ${EASE}, border-color 0.15s ease`; - input.addEventListener('input', syncVisual); chat.addEventListener('click', (e) => { diff --git a/site/styles/kinpaku-kit.css b/site/styles/kinpaku-kit.css index 7440dbb87..e3558a221 100644 --- a/site/styles/kinpaku-kit.css +++ b/site/styles/kinpaku-kit.css @@ -1202,9 +1202,7 @@ overflow: hidden; cursor: pointer; flex-shrink: 0; - transition: - width 0.28s cubic-bezier(0.22, 1, 0.36, 1), - border-color 0.15s ease; + transition: border-color 0.15s ease; } .home-kinpaku .live-demo-gbar-chat.is-expanded, diff --git a/site/styles/live-mode.css b/site/styles/live-mode.css index 60157bb4c..0c5308da4 100644 --- a/site/styles/live-mode.css +++ b/site/styles/live-mode.css @@ -174,7 +174,7 @@ border-radius: 8px; pointer-events: none; opacity: 0; - transition: opacity 200ms var(--ease-out), top 320ms var(--ease-out), left 320ms var(--ease-out), width 320ms var(--ease-out), height 320ms var(--ease-out); + transition: opacity 200ms var(--ease-out); box-shadow: 0 0 0 4px var(--color-accent-dim); } .live-demo-outline.is-visible { @@ -323,10 +323,7 @@ opacity: 0; margin-left: 0; overflow: hidden; - transition: - max-width 0.25s cubic-bezier(0.22, 1, 0.36, 1), - opacity 0.2s ease, - margin-left 0.25s cubic-bezier(0.22, 1, 0.36, 1); + transition: opacity 0.2s ease; } .live-demo-gbar-btn-label--mono { font-family: var(--font-mono); @@ -857,4 +854,3 @@ font-size: 12px; margin-top: 6px; } - diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index 4fa61185a..e37a92ac0 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -93,16 +93,57 @@ describe('detectUrl — browser-only fixtures', () => { // 6. 24px heading / 8px all sides — H fail (improvement over old 8px floor) // 7. 32px hero / 6px V / 16px H — V fail // 8. 14px
 / 2px all sides          — both fail
-    // Pass column has 12 cases (small pills, standard cards, code blocks,
+    // Pass column has 13 cases (small pills, inline code, standard cards, code blocks,
     // buttons, inputs, big text with proportional padding) — none should fire.
     assert.equal(cramped.length, 8, `expected 8 cramped-padding findings, got ${cramped.length}`);
   });
 
+  it('cramped-padding wrapper: skips same-surface wrappers, full-bleed marquees, and inset inner text surfaces', async () => {
+    const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/flush-against-border.html`);
+    const cramped = f.filter(r => r.antipattern === 'cramped-padding');
+    const snippets = cramped.map(r => r.snippet || '').join('\n');
+
+    for (const cls of ['flag-frameworks', 'flag-card-borders', 'flag-bg-only', 'flag-outline-only', 'flag-asym-leftflush']) {
+      assert.match(snippets, new RegExp(`"${cls}"`), `expected ".${cls}" to be flagged`);
+    }
+    for (const cls of ['pass-same-bg-child', 'pass-marquee-shell', 'pass-inner-text-surface']) {
+      assert.doesNotMatch(snippets, new RegExp(`"${cls}"`), `".${cls}" should not be flagged`);
+    }
+  });
+
   it('line-length: flag column triggers, pass column adds none', async () => {
     const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/quality.html`);
     assert.equal(f.filter(r => r.antipattern === 'line-length').length, 1);
   });
 
+  it('clipped-overflow-container: utility-named popovers still flag when clipped', async () => {
+    const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/clipped-overflow-container.html`);
+    const snippets = f
+      .filter(r => r.antipattern === 'clipped-overflow-container')
+      .map(r => r.snippet || '')
+      .join('\n');
+
+    assert.match(snippets, /flag-shadow-utility/, 'shadow-lg utility surfaces must not be skipped as decorative');
+    assert.match(snippets, /flag-overlay-surface/, 'overlay-named content surfaces must not be skipped as decorative');
+    assert.doesNotMatch(snippets, /pass-contained-overlay/, 'aria-hidden decorative overlays should remain skipped');
+  });
+
+  it('oversized-h1: requires the headline to dominate the viewport, not just be large', async () => {
+    const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/oversized-h1-browser.html`);
+    const hits = f.filter(r => r.antipattern === 'oversized-h1');
+    assert.equal(
+      hits.length,
+      1,
+      `expected exactly one oversized-h1 finding, got ${hits.length}: ${hits.map(r => r.snippet).join('; ')}`,
+    );
+    assert.match(hits[0].snippet, /sprawls across the whole/i);
+    assert.equal(
+      hits.some(r => /missing design vocabulary/i.test(r.snippet || '')),
+      false,
+      'a large two-line homepage-style h1 must not flag unless it dominates the viewport',
+    );
+  });
+
   it('typography side-by-side: element-level flag cases get regular overlays', async () => {
     const puppeteer = await import('puppeteer');
     const browser = await puppeteer.default.launch({
@@ -174,6 +215,7 @@ describe('detectUrl — browser-only fixtures', () => {
       'pass-sr-only-legacy',
       'pass-sr-only-tiny-hidden',
       'pass-sr-only-clipped-wide',
+      'pass-hidden-slide-overflow',
     ]) {
       assert.ok(!flagged.has(cls), `".${cls}" should NOT be flagged as text-overflow`);
     }
@@ -235,6 +277,11 @@ describe('detectUrl — browser-only fixtures', () => {
       false,
       'light image with dark text should keep enough contrast',
     );
+    assert.equal(
+      f.some(r => r.antipattern === 'low-contrast' && /Hidden mockup text/i.test(r.snippet || '')),
+      false,
+      'aria-hidden decorative mockups should not produce visual contrast findings',
+    );
     assert.equal(
       f.some(r => r.antipattern === 'low-contrast' && /Should (?:flag|pass) after pixel sampling/i.test(r.snippet || '')),
       false,
@@ -288,7 +335,9 @@ describe('detectUrl — browser-only fixtures', () => {
     });
     try {
       const page = await browser.newPage();
-      await page.setViewport({ width: 1280, height: 800 });
+      // Keep three failing visual-contrast cards in the no-scroll viewport;
+      // the offscreen cases are covered by the scrollOffscreen test above.
+      await page.setViewport({ width: 1280, height: 1000 });
       await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
       const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
       await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs
index ca2e7336c..5f2cd28a3 100644
--- a/tests/detect-antipatterns-fixtures.test.mjs
+++ b/tests/detect-antipatterns-fixtures.test.mjs
@@ -407,6 +407,18 @@ describe('detectHtml — repeated-section-kickers', () => {
     'Figure Caption Label',
     'Normal Case Kicker',
     'Intentional Brand Label',
+    'Garden Suite',
+    'Sea Loft',
+    'Cliff Suite',
+    '/impeccabletypeset',
+    '/impeccablelayout',
+    '/impeccablecolorize',
+    '/impeccablecraft',
+    '/impeccableaudit',
+    '/impeccablepolish',
+    'Mockup Hero Variant One',
+    'Mockup Hero Variant Two',
+    'Mockup Hero Variant Three',
   ];
 
   it('repeated-section-kickers: flags only repeated section scaffolding', async () => {
@@ -471,6 +483,10 @@ describe('detectHtml — cramped-padding (wrapper variant)', () => {
     'pass-bg-padded',
     'pass-outline-padded',
     'pass-image-only',
+    'pass-margin-inset',
+    'pass-inner-shell',
+    'pass-same-bg-child',
+    'pass-inner-text-surface',
   ];
 
   it('cramped-padding (wrapper): flags only the should-flag column', async () => {
@@ -541,8 +557,26 @@ describe('detectHtml — clipped-overflow-container', () => {
   // (overflow hidden/clip) with an absolutely-positioned descendant clips
   // tooltips/menus that need to escape. Real scroll regions (auto/scroll),
   // visible overflow, and clipping containers without positioned children pass.
-  const SHOULD_FLAG = ['flag-overflow-hidden', 'flag-overflow-clip'];
-  const SHOULD_PASS = ['pass-hidden-no-abs', 'pass-visible-abs', 'pass-scroll-abs'];
+  const SHOULD_FLAG = [
+    'flag-overflow-hidden',
+    'flag-overflow-clip',
+    'flag-overflow-negative',
+    'flag-overflow-right',
+    'flag-shadow-utility',
+    'flag-overlay-surface',
+  ];
+  const SHOULD_PASS = [
+    'pass-hidden-no-abs',
+    'pass-visible-abs',
+    'pass-scroll-abs',
+    'pass-contained-abs',
+    'pass-button-shine',
+    'pass-crop-photo',
+    'pass-contained-overlay',
+    'pass-carousel-viewport',
+    'pass-fisheye-list',
+    'pass-split-container',
+  ];
 
   it('clipped-overflow-container: flags only clipping ancestors with positioned children', async () => {
     const f = await detectHtml(path.join(FIXTURES, 'clipped-overflow-container.html'));
diff --git a/tests/fixtures/antipatterns/clipped-overflow-container.html b/tests/fixtures/antipatterns/clipped-overflow-container.html
index 883ea1d26..daeab4166 100644
--- a/tests/fixtures/antipatterns/clipped-overflow-container.html
+++ b/tests/fixtures/antipatterns/clipped-overflow-container.html
@@ -9,6 +9,26 @@
     .col { padding: 16px; }
     .box { position: relative; width: 200px; height: 120px; margin: 0 0 24px; padding: 16px; border: 1px solid #ddd; }
     .pop { position: absolute; top: 100%; left: 0; width: 220px; background: #222; color: #fff; padding: 8px; }
+    .tip-negative { position: absolute; top: -34px; left: 16px; width: 160px; background: #222; color: #fff; padding: 8px; }
+    .flyout { position: absolute; top: 28px; left: 100%; width: 180px; background: #222; color: #fff; padding: 8px; }
+    .contained-badge { position: absolute; top: 10px; right: 10px; width: 36px; height: 24px; background: #e5f0ff; }
+    .button-shine { position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,.6), transparent); }
+    .crop-photo { position: absolute; inset: -18px; background: linear-gradient(135deg, #b7d7ff, #486aa3); }
+    .inside-overlay { position: absolute; left: 12px; right: 12px; bottom: 12px; min-height: 28px; background: rgba(255,255,255,.86); }
+    .pass-hidden-button { position: relative; overflow: hidden; width: 160px; height: 52px; border: 1px solid #ddd; background: #111; color: #fff; }
+    .pass-carousel-viewport,
+    .pass-fisheye-list,
+    .pass-split-container {
+      position: relative;
+      overflow: hidden;
+      width: 200px;
+      height: 120px;
+      margin: 0 0 24px;
+      border: 1px solid #ddd;
+    }
+    .carousel-slide-next { position: absolute; top: 100%; left: 0; width: 100%; height: 100%; padding: 16px; background: #f5f5f5; }
+    .fisheye-next-item { position: absolute; top: 100%; left: 0; width: 100%; padding: 8px; background: none; border: 0; text-align: left; }
+    .split-after-panel { position: absolute; top: 0; left: 50%; width: 100%; height: 100%; background: #f5f5f5; }
   
 
 
@@ -23,9 +43,25 @@
         
         Tooltip clipped by overflow clip
       
+      
+ + Tooltip clipped above the host +
+
+ +
Flyout menu clipped to the right of the host
+
+
+ +
Dropdown with utility classes still gets clipped.
+
+
+ + +
- +

Hidden container with only static content.

@@ -38,6 +74,30 @@ A genuine scroll region is allowed to contain positioned children.
+
+

Positioned decoration remains inside the clipping box.

+ +
+ +
+ +
+
+

Mockup frame with a contained absolute overlay.

+ +
+ +
+ +
+
+
Before/after comparison panel clipped by the split frame.
+
diff --git a/tests/fixtures/antipatterns/cramped-padding.html b/tests/fixtures/antipatterns/cramped-padding.html index 2c0619434..251fe2a77 100644 --- a/tests/fixtures/antipatterns/cramped-padding.html +++ b/tests/fixtures/antipatterns/cramped-padding.html @@ -66,6 +66,16 @@ border-radius: 4px; font-weight: 500; } + .pass-inline-code-chip { + display: inline-block; + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 12px; + line-height: 2; + padding: 0.3em 0.5em; + background: #eef2f7; + color: #0f172a; + border-radius: 4px; + } /* ── PASS: cards at current standards ── */ .pass-card-min { padding: 8px; background: #f1f5f9; border-radius: 6px; font-size: 16px; line-height: 1.6; } @@ -157,6 +167,10 @@ tag chip: 12px font, 4px / 10px padding design system tag chip +
+ long inline code chip: compact inline code should not be treated like a padded card + npx impeccable skills check +

Cards at current standards

diff --git a/tests/fixtures/antipatterns/flush-against-border.html b/tests/fixtures/antipatterns/flush-against-border.html index 57bacb538..84640a527 100644 --- a/tests/fixtures/antipatterns/flush-against-border.html +++ b/tests/fixtures/antipatterns/flush-against-border.html @@ -218,6 +218,83 @@ height: 80px; background: linear-gradient(135deg, #cbd5e1, #94a3b8); } + + /* ── PASS: parent has zero padding, but child margins create the visible inset ── */ + .pass-margin-inset { + padding: 0; + background: white; + border: 1px solid #cbd5e1; + border-radius: 8px; + } + .pass-margin-inset h4 { margin: 16px 18px 4px; font-size: 14px; color: #0f172a; } + .pass-margin-inset p { margin: 0 18px 16px; font-size: 13px; color: #475569; } + + /* ── PASS: an inner shell, not the parent, owns the inset spacing ── */ + .pass-inner-shell { + padding: 0; + background: white; + border: 1px solid #cbd5e1; + border-radius: 8px; + } + .pass-inner-shell .inner-shell { + margin: 18px; + } + .pass-inner-shell h4 { margin: 0 0 4px; font-size: 14px; color: #0f172a; } + .pass-inner-shell p { margin: 0; font-size: 13px; color: #475569; } + + /* ── PASS: same background as parent, so the child draws no visible boundary ── */ + .pass-same-bg-parent { + padding: 16px; + background: #eef2f7; + border-radius: 8px; + } + .pass-same-bg-child { + padding: 0; + background: #eef2f7; + border: 0; + border-radius: 8px; + } + .pass-same-bg-child h4 { margin: 0; font-size: 14px; color: #0f172a; } + .pass-same-bg-child p { margin: 4px 0 0; font-size: 13px; color: #475569; } + + /* ── PASS: full-bleed marquee/surface, not a text card missing padding ── */ + .pass-marquee-shell { + padding: 0; + background: #172033; + color: white; + overflow: hidden; + border: 0; + } + .pass-marquee-track { + display: flex; + gap: 12px; + width: max-content; + } + .pass-marquee-card { + width: 220px; + padding: 14px; + background: #23304a; + border: 1px solid rgba(255, 255, 255, 0.16); + } + .pass-marquee-card p { margin: 0; font-size: 13px; } + + /* ── PASS: outer scene owns background; inner text surface owns padding ── */ + .pass-inner-text-surface { + padding: 0; + background: #0f172a; + color: white; + overflow: hidden; + border: 0; + } + .pass-inner-text-surface-window { + display: grid; + gap: 6px; + min-height: 92px; + padding: 14px; + background: #111827; + border: 1px solid rgba(255, 255, 255, 0.16); + } + .pass-inner-text-surface-window p { margin: 0; font-size: 13px; } @@ -346,6 +423,59 @@
+ +

Child margins provide the inset

+
+ card: parent padding 0, but text children have margins that create visible inset. +
+

Title inset by child margin

+

Body copy is not visually flush even though the parent has zero padding.

+
+
+ +

Inner shell provides the inset

+
+ card: parent padding 0, but an inner shell is offset from every edge. +
+
+

Title inset by inner shell

+

The direct child creates the breathing room.

+
+
+
+ +

Same background as parent

+
+ child padding 0, but it has the same background as the parent, so no child boundary is visible. +
+
+

Same-surface visual shell

+

The text belongs to the parent surface, not to a distinct zero-padding card.

+
+
+
+ +

Full-bleed marquee shell

+
+ marquee shell clips animated cards intentionally; the cards, not the shell, own text padding. +
+
+

Quote card with its own comfortable padding.

+

Another moving card, also padded inside.

+
+
+
+ +

Inner text surface

+
+ outer shell has a scene background; the inner window owns the actual text padding. +
+
+

Build step running

+

Detector output belongs to the mock window.

+
+
+
diff --git a/tests/fixtures/antipatterns/gpt-tells.html b/tests/fixtures/antipatterns/gpt-tells.html index 121193c9c..9b7cea681 100644 --- a/tests/fixtures/antipatterns/gpt-tells.html +++ b/tests/fixtures/antipatterns/gpt-tells.html @@ -27,6 +27,12 @@
Hairline border with a tight, purposeful shadow.
+
+ A quiet low-contrast hairline with a broad but restrained ambient shadow. +
+
+ OKLCH shadow hue values should not be mistaken for shadow blur. +

We measured real outcomes for the people who use this every day.

diff --git a/tests/fixtures/antipatterns/oversized-h1-browser.html b/tests/fixtures/antipatterns/oversized-h1-browser.html new file mode 100644 index 000000000..a100665b5 --- /dev/null +++ b/tests/fixtures/antipatterns/oversized-h1-browser.html @@ -0,0 +1,27 @@ + + + + + Oversized H1 browser fixture + + + +
+

A sweeping product headline that sprawls across the whole viewport and keeps going

+

The missing design vocabulary for agents.

+
+ + + diff --git a/tests/fixtures/antipatterns/quality.html b/tests/fixtures/antipatterns/quality.html index 5736d3cef..6b470fb38 100644 --- a/tests/fixtures/antipatterns/quality.html +++ b/tests/fixtures/antipatterns/quality.html @@ -116,6 +116,17 @@ font-weight: 600; color: #475569; } + + .mock-terminal-title, + .mock-terminal-meta, + .mock-diff-line { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: #475569; + line-height: 1.5; + } + .mock-terminal-title { font-size: 11px; } + .mock-terminal-meta { font-size: 10.8px; } + .mock-diff-line { font-size: 11.84px; } @@ -220,6 +231,14 @@

Second level

Third level

+ +

Small mockup metadata

+
+ terminal/diff metadata at tiny sizes + /impeccable polish : scanning codebase +
12 files inspected · 4 visual issues queued
+
+ Replace generic button label with action-specific copy
+
diff --git a/tests/fixtures/antipatterns/repeated-section-kickers.html b/tests/fixtures/antipatterns/repeated-section-kickers.html index b25885891..4942511ff 100644 --- a/tests/fixtures/antipatterns/repeated-section-kickers.html +++ b/tests/fixtures/antipatterns/repeated-section-kickers.html @@ -69,6 +69,54 @@ .brand-system { min-height: 120px; } + .card-grid, + .command-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin: 0 0 24px; + } + .suite-card, + .command-card { + display: block; + min-height: 120px; + padding: 16px; + border: 1px solid #d8cec0; + background: #fff; + text-align: left; + } + .taxonomy-label { + display: block; + margin: 0 0 8px; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.11em; + text-transform: uppercase; + color: #5b5046; + } + .suite-card h3, + .command-card h3 { + font-size: 24px; + line-height: 30px; + } + .command-carousel, + .hidden-mockup { + display: grid; + gap: 12px; + margin: 0 0 24px; + } + .command-spread, + .mockup-hero { + min-height: 120px; + padding: 16px; + border: 1px solid #d8cec0; + background: #fff; + } + .command-spread h3, + .mockup-hero h2 { + font-size: 24px; + line-height: 30px; + } @@ -140,6 +188,75 @@

"Intentional Brand Label"

Deliberate brand systems can opt out with an explicit marker.

+ +
+
+ Suite +

"Garden Suite"

+

Repeated category labels inside cards are structured metadata, not page-section scaffolding.

+
+
+ Suite +

"Sea Loft"

+

The label is useful because the user is scanning sibling cards.

+
+
+ Suite +

"Cliff Suite"

+

Repeating it here does not create the generic section-kicker smell.

+
+
+ +
+ + + +
+ + + + diff --git a/tests/fixtures/antipatterns/text-overflow.html b/tests/fixtures/antipatterns/text-overflow.html index cc05ba873..887c42dfd 100644 --- a/tests/fixtures/antipatterns/text-overflow.html +++ b/tests/fixtures/antipatterns/text-overflow.html @@ -29,6 +29,15 @@ white-space: nowrap; clip-path: inset(50%); } + .hidden-slide { + opacity: 0; + pointer-events: none; + } + .hidden-slide .pass-hidden-slide-overflow { + display: block; + width: 120px; + white-space: nowrap; + } @@ -49,6 +58,9 @@ A legacy clipped screen-reader-only label with long text that should not be flagged. A tiny overflow-hidden screen-reader-only label without a clip declaration should not be flagged. A fully clipped label with a normal-sized box and overflowing text should not be flagged. +
+ An inactive carousel slide can contain long text that overflows while hidden. +
diff --git a/tests/fixtures/antipatterns/visual-contrast.html b/tests/fixtures/antipatterns/visual-contrast.html index 7591952ee..fc640ca7c 100644 --- a/tests/fixtures/antipatterns/visual-contrast.html +++ b/tests/fixtures/antipatterns/visual-contrast.html @@ -150,6 +150,10 @@

Dark text over a light SVG underlay should pass.

+ +