diff --git a/src/detect-antipatterns-browser.js b/src/detect-antipatterns-browser.js index f7e06a559..e181ccb08 100644 --- a/src/detect-antipatterns-browser.js +++ b/src/detect-antipatterns-browser.js @@ -395,8 +395,20 @@ function checkBorders(tag, widths, colors, radius) { return findings; } +// Returns true if the given text is composed entirely of emoji characters +// (plus whitespace / variation selectors). Emojis render as multicolor glyphs +// regardless of CSS `color`, so contrast checks against the element's text +// color are meaningless for these nodes. +const EMOJI_CHAR_RE = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/u; +const EMOJI_CHARS_GLOBAL = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/gu; +function isEmojiOnlyText(text) { + if (!text) return false; + if (!EMOJI_CHAR_RE.test(text)) return false; + return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === ''; +} + function checkColors(opts) { - const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, bgClip, bgImage, classList } = opts; + const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts; if (SAFE_TAGS.has(tag)) return []; const findings = []; @@ -405,7 +417,7 @@ function checkColors(opts) { findings.push({ id: 'pure-black-white', snippet: '#000000 background' }); } - if (hasDirectText && textColor) { + if (hasDirectText && textColor && !isEmojiOnly) { // Run background-dependent checks against either a solid bg or, if the // ancestor is a gradient, against every gradient stop (use the worst case). const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null); @@ -840,7 +852,8 @@ function checkElementColorsDOM(el) { const rect = el.getBoundingClientRect(); if (rect.width < 10 || rect.height < 10) return []; const style = getComputedStyle(el); - const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim()); + const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); + const hasDirectText = directText.trim().length > 0; const effectiveBg = resolveBackground(el); return checkColors({ tag, @@ -851,6 +864,7 @@ function checkElementColorsDOM(el) { fontSize: parseFloat(style.fontSize) || 16, fontWeight: parseInt(style.fontWeight) || 400, hasDirectText, + isEmojiOnly: isEmojiOnlyText(directText), bgClip: style.webkitBackgroundClip || style.backgroundClip || '', bgImage: style.backgroundImage || '', classList: el.getAttribute('class') || '', @@ -867,8 +881,13 @@ function checkElementIconTileDOM(el) { const headRect = el.getBoundingClientRect(); const sibStyle = getComputedStyle(sibling); + // The tile may either contain an / icon child, OR the tile itself + // may contain an emoji/symbol character directly as its only text content + // (the "card-icon" pattern from many AI-generated demos). const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]'); const iconRect = iconChild?.getBoundingClientRect(); + const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); + const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText); return checkIconTile({ headingTag: tag, @@ -882,7 +901,7 @@ function checkElementIconTileDOM(el) { siblingBgImage: sibStyle.backgroundImage || '', siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0, siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0, - hasIconChild: !!iconChild, + hasIconChild: !!iconChild || hasInlineEmojiIcon, iconChildWidth: iconRect?.width || 0, }); } @@ -1205,8 +1224,8 @@ function checkElementBorders(tag, style) { } function checkElementColors(el, style, tag, window) { - const hasText = el.textContent?.trim().length > 0; - const hasDirectText = hasText && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim()); + const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); + const hasDirectText = directText.trim().length > 0; const effectiveBg = resolveBackground(el, window); return checkColors({ @@ -1218,6 +1237,7 @@ function checkElementColors(el, style, tag, window) { fontSize: parseFloat(style.fontSize) || 16, fontWeight: parseInt(style.fontWeight) || 400, hasDirectText, + isEmojiOnly: isEmojiOnlyText(directText), bgClip: style.webkitBackgroundClip || style.backgroundClip || '', bgImage: style.backgroundImage || '', classList: el.getAttribute?.('class') || el.className || '', @@ -1240,6 +1260,9 @@ function checkElementIconTile(el, tag, window) { const iconStyle = window.getComputedStyle(iconChild); iconWidth = parseFloat(iconStyle.width) || parseFloat(iconChild.getAttribute('width')) || 0; } + // Or: tile contains an emoji/symbol character directly as its only content + const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); + const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText); return checkIconTile({ headingTag: tag, @@ -1253,7 +1276,7 @@ function checkElementIconTile(el, tag, window) { siblingBgImage: sibStyle.backgroundImage || '', siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0, siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0, - hasIconChild: !!iconChild, + hasIconChild: !!iconChild || hasInlineEmojiIcon, iconChildWidth: iconWidth, }); } diff --git a/src/detect-antipatterns.mjs b/src/detect-antipatterns.mjs index da51e0776..6dad21522 100644 --- a/src/detect-antipatterns.mjs +++ b/src/detect-antipatterns.mjs @@ -390,8 +390,20 @@ function checkBorders(tag, widths, colors, radius) { return findings; } +// Returns true if the given text is composed entirely of emoji characters +// (plus whitespace / variation selectors). Emojis render as multicolor glyphs +// regardless of CSS `color`, so contrast checks against the element's text +// color are meaningless for these nodes. +const EMOJI_CHAR_RE = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/u; +const EMOJI_CHARS_GLOBAL = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/gu; +function isEmojiOnlyText(text) { + if (!text) return false; + if (!EMOJI_CHAR_RE.test(text)) return false; + return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === ''; +} + function checkColors(opts) { - const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, bgClip, bgImage, classList } = opts; + const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts; if (SAFE_TAGS.has(tag)) return []; const findings = []; @@ -400,7 +412,7 @@ function checkColors(opts) { findings.push({ id: 'pure-black-white', snippet: '#000000 background' }); } - if (hasDirectText && textColor) { + if (hasDirectText && textColor && !isEmojiOnly) { // Run background-dependent checks against either a solid bg or, if the // ancestor is a gradient, against every gradient stop (use the worst case). const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null); @@ -835,7 +847,8 @@ function checkElementColorsDOM(el) { const rect = el.getBoundingClientRect(); if (rect.width < 10 || rect.height < 10) return []; const style = getComputedStyle(el); - const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim()); + const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); + const hasDirectText = directText.trim().length > 0; const effectiveBg = resolveBackground(el); return checkColors({ tag, @@ -846,6 +859,7 @@ function checkElementColorsDOM(el) { fontSize: parseFloat(style.fontSize) || 16, fontWeight: parseInt(style.fontWeight) || 400, hasDirectText, + isEmojiOnly: isEmojiOnlyText(directText), bgClip: style.webkitBackgroundClip || style.backgroundClip || '', bgImage: style.backgroundImage || '', classList: el.getAttribute('class') || '', @@ -862,8 +876,13 @@ function checkElementIconTileDOM(el) { const headRect = el.getBoundingClientRect(); const sibStyle = getComputedStyle(sibling); + // The tile may either contain an / icon child, OR the tile itself + // may contain an emoji/symbol character directly as its only text content + // (the "card-icon" pattern from many AI-generated demos). const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]'); const iconRect = iconChild?.getBoundingClientRect(); + const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); + const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText); return checkIconTile({ headingTag: tag, @@ -877,7 +896,7 @@ function checkElementIconTileDOM(el) { siblingBgImage: sibStyle.backgroundImage || '', siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0, siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0, - hasIconChild: !!iconChild, + hasIconChild: !!iconChild || hasInlineEmojiIcon, iconChildWidth: iconRect?.width || 0, }); } @@ -1200,8 +1219,8 @@ function checkElementBorders(tag, style) { } function checkElementColors(el, style, tag, window) { - const hasText = el.textContent?.trim().length > 0; - const hasDirectText = hasText && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim()); + const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); + const hasDirectText = directText.trim().length > 0; const effectiveBg = resolveBackground(el, window); return checkColors({ @@ -1213,6 +1232,7 @@ function checkElementColors(el, style, tag, window) { fontSize: parseFloat(style.fontSize) || 16, fontWeight: parseInt(style.fontWeight) || 400, hasDirectText, + isEmojiOnly: isEmojiOnlyText(directText), bgClip: style.webkitBackgroundClip || style.backgroundClip || '', bgImage: style.backgroundImage || '', classList: el.getAttribute?.('class') || el.className || '', @@ -1235,6 +1255,9 @@ function checkElementIconTile(el, tag, window) { const iconStyle = window.getComputedStyle(iconChild); iconWidth = parseFloat(iconStyle.width) || parseFloat(iconChild.getAttribute('width')) || 0; } + // Or: tile contains an emoji/symbol character directly as its only content + const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); + const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText); return checkIconTile({ headingTag: tag, @@ -1248,7 +1271,7 @@ function checkElementIconTile(el, tag, window) { siblingBgImage: sibStyle.backgroundImage || '', siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0, siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0, - hasIconChild: !!iconChild, + hasIconChild: !!iconChild || hasInlineEmojiIcon, iconChildWidth: iconWidth, }); } diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs index 30d953873..582315566 100644 --- a/tests/detect-antipatterns-fixtures.test.mjs +++ b/tests/detect-antipatterns-fixtures.test.mjs @@ -59,6 +59,23 @@ describe('detectHtml — jsdom fixtures', () => { ); }); + it('color: emoji-only text is never flagged as low-contrast', async () => { + // Emojis render as multicolor glyphs regardless of CSS `color`, so the + // CSS text color is irrelevant for contrast. The fixture's emoji cards + // intentionally set text color to match the bg (which would trip the + // rule for any other text). The detector must skip emoji-only nodes. + const f = await detectHtml(path.join(FIXTURES, 'color.html')); + const emojiCardColorPairs = ['#ffe4e6 on #ffe4e6', '#1a1a1a on #1a1a1a']; + const matches = f.filter(r => + (r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') && + emojiCardColorPairs.some(pair => (r.snippet || '').includes(pair)) + ); + assert.equal( + matches.length, 0, + `expected no contrast findings on emoji-only text, got: ${matches.map(r => r.snippet).join('; ')}` + ); + }); + it('legitimate-borders: minimal false positives', async () => { const f = await detectHtml(path.join(FIXTURES, 'legitimate-borders.html')); const borderFindings = f.filter(r => r.antipattern === 'side-tab' || r.antipattern === 'border-accent-on-rounded'); @@ -89,6 +106,7 @@ describe('detectHtml — icon-tile-stack', () => { 'Secure Storage', 'Easy Setup', 'Powerful Analytics', + 'Emoji Inline Icon', ]; const SHOULD_PASS = [ 'Sarah Chen', diff --git a/tests/fixtures/antipatterns/color.html b/tests/fixtures/antipatterns/color.html index 720d8c557..1c0a687b7 100644 --- a/tests/fixtures/antipatterns/color.html +++ b/tests/fixtures/antipatterns/color.html @@ -96,6 +96,16 @@

Distinctive accents (not AI purple)

Red heading — not AI purple

Amber heading — distinctive

+ +

Emoji on light backgrounds

+ +
+

⚠️ 🚨 ✨ 🎨

+
+
+

⚠️ 🚨 ✨ 🎨

+
diff --git a/tests/fixtures/antipatterns/icon-tile-stack.html b/tests/fixtures/antipatterns/icon-tile-stack.html index 247324d00..f0e7d4af5 100644 --- a/tests/fixtures/antipatterns/icon-tile-stack.html +++ b/tests/fixtures/antipatterns/icon-tile-stack.html @@ -56,6 +56,18 @@ margin-bottom: 16px; } + /* 5: inline emoji icon — tile contains an emoji/symbol character directly, + not an or child. This is the "card-icon" pattern from many + generated demos. */ + .icon-emoji { + width: 48px; height: 48px; + border-radius: 12px; + background: linear-gradient(135deg, #8b5cf6, #a855f7); + display: flex; align-items: center; justify-content: center; + font-size: 24px; + margin-bottom: 16px; + } + /* ── PASS cases ── */ /* a: round avatar above name (border-radius: 50% — circle, not rounded square) */ @@ -157,6 +169,12 @@

Powerful Analytics

Insights that drive smarter business decisions.

+ +
+
+

Emoji Inline Icon

+

The tile contains an emoji directly, no SVG child.

+