mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Handle emoji-only text in contrast and icon-tile detection
Emojis render as multicolor glyphs regardless of CSS \`color\`, so the text color is irrelevant for contrast calculations. The detector was flagging emoji icons as low-contrast whenever the surrounding bg/text colors were close (e.g. an emoji card with text-color set to match the bg). Adds an isEmojiOnlyText() helper that returns true when the direct text consists entirely of emoji characters (and zero-width joiners, variation selectors, skin-tone modifiers, regional indicators), and skips both gray-on-color and low-contrast checks when it's true. Same insight fixes a missed icon-tile-stack detection: many AI- generated cards use \`<div class="card-icon">⚡</div>\` where the tile contains the emoji directly as text, not an <svg>/<i> child. The detector now also recognizes these "inline emoji icon" tiles. Both fixes are TDD'd: new test cases in color.html (two emoji cards with matching text/bg colors) and icon-tile-stack.html (the inline emoji tile pattern). The test suite went from green → red → green across both rules. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
89f4ec41d7
commit
06ef4f3c14
@@ -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 <svg>/<i> 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 <svg>/<i> 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
+10
@@ -96,6 +96,16 @@
|
||||
<h3>Distinctive accents (not AI purple)</h3>
|
||||
<h3 style="color: rgb(220, 38, 38); font-size: 1.25rem; margin: 0;">Red heading — not AI purple</h3>
|
||||
<h3 style="color: rgb(180, 83, 9); font-size: 1.25rem; margin: 8px 0 0;">Amber heading — distinctive</h3>
|
||||
|
||||
<h3>Emoji on light backgrounds</h3>
|
||||
<!-- Emojis render as multicolor glyphs regardless of CSS color, so the
|
||||
CSS color is irrelevant for contrast. These should NOT be flagged. -->
|
||||
<div class="card emoji-test" data-test="emoji-light" style="background: #ffe4e6;">
|
||||
<p style="color: #ffe4e6; font-size: 24px;">⚠️ 🚨 ✨ 🎨</p>
|
||||
</div>
|
||||
<div class="card emoji-test" data-test="emoji-dark" style="background: #1a1a1a;">
|
||||
<p style="color: #1a1a1a; font-size: 24px;">⚠️ 🚨 ✨ 🎨</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/detect-antipatterns-browser.js"></script>
|
||||
|
||||
@@ -56,6 +56,18 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 5: inline emoji icon — tile contains an emoji/symbol character directly,
|
||||
not an <svg> or <i> 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 @@
|
||||
<h3>Powerful Analytics</h3>
|
||||
<p>Insights that drive smarter business decisions.</p>
|
||||
</div>
|
||||
|
||||
<div class="case">
|
||||
<div class="icon-emoji">⚡</div>
|
||||
<h3>Emoji Inline Icon</h3>
|
||||
<p>The tile contains an emoji directly, no SVG child.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user