mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
Add layout anti-pattern detection: nested cards, identical grids, spacing, centering
Four new layout detections: - nested-cards: jsdom DOM walk finds card-like elements (shadow + rounded + bg) nested inside other card-like elements. Excludes dropdowns (absolute/fixed), form inputs, code blocks, badges (<20 chars), and known component classes. - identical-card-grid: detects grid/flex parents with 3+ children sharing the same structural fingerprint (icon + heading + paragraph template pattern). - monotonous-spacing: regex on raw HTML collects padding/margin/gap values (px, rem, Tailwind classes), rounds to nearest 4px, flags when >60% use the same value with <=3 distinct values. - everything-centered: regex counts text-align:center and Tailwind text-center on text elements, flags when >70% of 5+ text elements are centered. Also narrowed pure-black-white to only flag #000 as background color — text-black, text-white, bg-white, and #fff are no longer flagged (too common, per user feedback). Extensive should-pass fixture covers: shadcn card sub-components, cards with form inputs/dropdowns/code blocks/badges/accordions/tabs/images, pricing cards, varied spacing, mixed centered/left-aligned layouts. 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
e45d6cde1a
commit
72b9ca2941
@@ -82,9 +82,9 @@ const ANTIPATTERNS = [
|
||||
// -------------------------------------------------------------------------
|
||||
{
|
||||
id: 'pure-black-white',
|
||||
name: 'Pure black or white',
|
||||
name: 'Pure black background',
|
||||
description:
|
||||
'Pure #000 or #fff never appears in nature. Tint your blacks and whites slightly toward your brand hue for a more natural, cohesive feel.',
|
||||
'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.',
|
||||
},
|
||||
{
|
||||
id: 'gray-on-color',
|
||||
@@ -110,6 +110,33 @@ const ANTIPATTERNS = [
|
||||
description:
|
||||
'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
|
||||
},
|
||||
// -------------------------------------------------------------------------
|
||||
// Layout & space anti-patterns
|
||||
// -------------------------------------------------------------------------
|
||||
{
|
||||
id: 'nested-cards',
|
||||
name: 'Nested cards',
|
||||
description:
|
||||
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
|
||||
},
|
||||
{
|
||||
id: 'identical-card-grid',
|
||||
name: 'Identical card grid',
|
||||
description:
|
||||
'Same-sized cards with identical icon + heading + text structure, repeated endlessly. Vary card sizes, layouts, or content structure to create visual interest.',
|
||||
},
|
||||
{
|
||||
id: 'monotonous-spacing',
|
||||
name: 'Monotonous spacing',
|
||||
description:
|
||||
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
|
||||
},
|
||||
{
|
||||
id: 'everything-centered',
|
||||
name: 'Everything centered',
|
||||
description:
|
||||
'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
|
||||
},
|
||||
];
|
||||
|
||||
/** Check if content looks like a full page (not a component/partial) */
|
||||
@@ -270,7 +297,10 @@ 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());
|
||||
|
||||
// Pure black/white is handled via regex on raw HTML (jsdom's computed bg is unreliable)
|
||||
// Pure black background — only flag #000 as background (not text, not white)
|
||||
if (bgColor && bgColor.a > 0.1 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
|
||||
findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
|
||||
}
|
||||
|
||||
if (hasDirectText && textColor) {
|
||||
// --- Gray text on colored background ---
|
||||
@@ -327,26 +357,10 @@ function checkElementColors(el, style, tag, window) {
|
||||
const TW_GRAY_FAMILIES = /\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/;
|
||||
const TW_COLORED_BG = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/;
|
||||
|
||||
// Tailwind pure black/white
|
||||
// Only flag bg-black (pure black background) — text colors are fine
|
||||
if (/\bbg-black\b/.test(classList)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
|
||||
}
|
||||
if (/\bbg-white\b/.test(classList)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'bg-white' });
|
||||
}
|
||||
if (/\btext-black\b/.test(classList)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'text-black' });
|
||||
}
|
||||
// text-white: only flag if there's no dark background on the same element
|
||||
// (text-white on dark bg is a standard, intentional pattern)
|
||||
if (/\btext-white\b/.test(classList)) {
|
||||
const hasDarkBg = /\bbg-black\b/.test(classList) ||
|
||||
/\bbg-(?:gray|slate|zinc|neutral|stone)-(?:7|8|9)\d{2}\b/.test(classList) ||
|
||||
/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:[5-9]\d{2}|[6-9]\d{2})\b/.test(classList);
|
||||
if (!hasDarkBg) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'text-white (no dark bg class)' });
|
||||
}
|
||||
}
|
||||
|
||||
// Tailwind gray text on colored background
|
||||
const grayMatch = classList.match(TW_GRAY_FAMILIES);
|
||||
@@ -501,14 +515,10 @@ function checkPageTypography(document, window) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pure black/white (regex on raw HTML — jsdom doesn't resolve inline bg colors) ---
|
||||
const pureRe = /(?:color|background(?:-color)?)\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
|
||||
if (pureRe.test(html)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'Pure #000 in styles' });
|
||||
}
|
||||
const pureWhiteRe = /(?:color|background(?:-color)?)\s*:\s*(?:#ffffff|#fff|rgb\(\s*255,\s*255,\s*255\s*\))\b/gi;
|
||||
if (pureWhiteRe.test(html)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'Pure #fff in styles' });
|
||||
// --- Pure black background (regex on raw HTML — only flag #000 as background, not text) ---
|
||||
const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
|
||||
if (pureBlackBgRe.test(html)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
|
||||
}
|
||||
|
||||
// --- AI color palette: purple/violet in raw CSS ---
|
||||
@@ -543,6 +553,206 @@ function checkPageTypography(document, window) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element looks like a "card" (has shadow, border-radius, and background).
|
||||
*/
|
||||
function isCardLike(el, window) {
|
||||
const style = window.getComputedStyle(el);
|
||||
const tag = el.tagName.toLowerCase();
|
||||
|
||||
// Skip non-visual elements
|
||||
if (SAFE_TAGS.has(tag)) return false;
|
||||
// Skip form elements (inputs, selects, textareas have shadow/rounded)
|
||||
if (['input', 'select', 'textarea'].includes(tag)) return false;
|
||||
// Skip images, media
|
||||
if (['img', 'video', 'canvas', 'picture'].includes(tag)) return false;
|
||||
|
||||
const shadow = style.boxShadow || '';
|
||||
const hasShadow = shadow && shadow !== 'none';
|
||||
const radius = parseFloat(style.borderRadius) || 0;
|
||||
const hasRadius = radius > 0;
|
||||
|
||||
// Check background: card-like if it has an opaque bg different from transparent
|
||||
const rawBg = el.getAttribute?.('style')?.match(/background(?:-color)?\s*:\s*([^;]+)/i);
|
||||
const hasBg = rawBg && !/transparent/i.test(rawBg[1]);
|
||||
|
||||
// Also check Tailwind classes for card indicators
|
||||
const cls = el.getAttribute?.('class') || '';
|
||||
const twShadow = /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
|
||||
const twRounded = /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
|
||||
const twBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
|
||||
const twBorder = /\bborder\b/.test(cls);
|
||||
|
||||
// A "card" needs at least 2 of: shadow, rounded, bg/border
|
||||
const signals = [
|
||||
hasShadow || twShadow,
|
||||
hasRadius || twRounded,
|
||||
hasBg || twBg || twBorder,
|
||||
].filter(Boolean).length;
|
||||
|
||||
return signals >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page-level layout checks.
|
||||
* Returns array of { id, snippet } findings.
|
||||
*/
|
||||
function checkPageLayout(document, window) {
|
||||
const findings = [];
|
||||
|
||||
// --- Nested cards ---
|
||||
const allEls = document.querySelectorAll('*');
|
||||
const flaggedNested = new Set();
|
||||
for (const el of allEls) {
|
||||
if (!isCardLike(el, window)) continue;
|
||||
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const cls = el.getAttribute?.('class') || '';
|
||||
const rawStyle = el.getAttribute?.('style') || '';
|
||||
|
||||
// Exclude elements that look like non-card components
|
||||
if (['pre', 'code'].includes(tag)) continue;
|
||||
// Exclude absolutely/fixed positioned elements (dropdowns, modals, tooltips)
|
||||
if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue;
|
||||
// Exclude small elements (badges, chips, icons) — text < 20 chars
|
||||
if ((el.textContent?.trim().length || 0) < 20) continue;
|
||||
// Exclude form elements that happen to match card heuristics
|
||||
if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
|
||||
|
||||
// Walk up to find card-like ancestor
|
||||
let parent = el.parentElement;
|
||||
while (parent) {
|
||||
if (isCardLike(parent, window)) {
|
||||
const key = `${parent.tagName}:${el.tagName}`;
|
||||
if (!flaggedNested.has(key)) {
|
||||
flaggedNested.add(key);
|
||||
findings.push({ id: 'nested-cards', snippet: `Card inside card (${tag} in ${parent.tagName.toLowerCase()})` });
|
||||
}
|
||||
break;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Identical card grid ---
|
||||
const gridParents = document.querySelectorAll('[class*="grid"], [style*="display: grid"], [style*="display: flex"]');
|
||||
for (const grid of gridParents) {
|
||||
const children = [...grid.children].filter(c => {
|
||||
const tag = c.tagName.toLowerCase();
|
||||
return tag !== 'script' && tag !== 'style';
|
||||
});
|
||||
if (children.length < 3) continue;
|
||||
|
||||
// Compare structural fingerprint of each child
|
||||
function fingerprint(el) {
|
||||
const childTags = [...el.children].map(c => c.tagName.toLowerCase());
|
||||
// Check for icon-like element (svg, img, or div with fixed size classes)
|
||||
const hasIcon = childTags.includes('svg') || childTags.includes('img') ||
|
||||
[...el.children].some(c => {
|
||||
const cls = c.getAttribute?.('class') || '';
|
||||
return /\bw-\d+\b.*\bh-\d+\b/.test(cls) && /\brounded/.test(cls);
|
||||
});
|
||||
const hasHeading = childTags.some(t => /^h[1-6]$/.test(t));
|
||||
const hasParagraph = childTags.includes('p');
|
||||
return `icon:${hasIcon}|h:${hasHeading}|p:${hasParagraph}|children:${childTags.length}`;
|
||||
}
|
||||
|
||||
const fps = children.map(fingerprint);
|
||||
const allSame = fps.every(f => f === fps[0]);
|
||||
// Only flag if structure includes icon + heading + paragraph (the template pattern)
|
||||
if (allSame && fps[0].includes('icon:true') && fps[0].includes('h:true') && fps[0].includes('p:true')) {
|
||||
findings.push({
|
||||
id: 'identical-card-grid',
|
||||
snippet: `${children.length} identical cards (icon + heading + text)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Monotonous spacing ---
|
||||
// Regex on raw HTML — jsdom doesn't compute inline px spacing reliably
|
||||
const spacingValues = [];
|
||||
const html = document.documentElement?.outerHTML || '';
|
||||
|
||||
// CSS inline: padding/margin with px values
|
||||
const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
|
||||
let sm;
|
||||
while ((sm = spacingRe.exec(html)) !== null) {
|
||||
const v = parseInt(sm[1], 10);
|
||||
if (v > 0 && v < 200) spacingValues.push(v);
|
||||
}
|
||||
// CSS gap
|
||||
const gapRe = /gap\s*:\s*(\d+)px/gi;
|
||||
while ((sm = gapRe.exec(html)) !== null) {
|
||||
spacingValues.push(parseInt(sm[1], 10));
|
||||
}
|
||||
// Tailwind spacing classes
|
||||
const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
|
||||
while ((sm = twSpaceRe.exec(html)) !== null) {
|
||||
spacingValues.push(parseInt(sm[1], 10) * 4);
|
||||
}
|
||||
// rem values (convert at 16px base)
|
||||
const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
|
||||
while ((sm = remSpacingRe.exec(html)) !== null) {
|
||||
const v = Math.round(parseFloat(sm[1]) * 16);
|
||||
if (v > 0 && v < 200) spacingValues.push(v);
|
||||
}
|
||||
|
||||
// Round to nearest 4px to group similar values (e.g., 15px and 16px are effectively the same)
|
||||
const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
|
||||
if (roundedSpacing.length >= 10) {
|
||||
const counts = {};
|
||||
for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;
|
||||
const maxCount = Math.max(...Object.values(counts));
|
||||
const dominantPct = maxCount / roundedSpacing.length;
|
||||
const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
|
||||
// Flag if the dominant spacing value is used > 60% of the time with few distinct values
|
||||
if (dominantPct > 0.6 && unique.length <= 3) {
|
||||
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
||||
findings.push({
|
||||
id: 'monotonous-spacing',
|
||||
snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Everything centered ---
|
||||
// Check inline styles and Tailwind classes for text-align: center
|
||||
// Also walk up ancestors for inherited centering
|
||||
const textEls = document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
|
||||
let centeredCount = 0;
|
||||
let totalText = 0;
|
||||
for (const el of textEls) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length >= 3);
|
||||
if (!hasDirectText) continue;
|
||||
totalText++;
|
||||
|
||||
// Check element and ancestors for centering
|
||||
let cur = el;
|
||||
let isCentered = false;
|
||||
while (cur && cur.nodeType === 1) {
|
||||
const rawStyle = cur.getAttribute?.('style') || '';
|
||||
const cls = cur.getAttribute?.('class') || '';
|
||||
if (/text-align\s*:\s*center/i.test(rawStyle) || /\btext-center\b/.test(cls)) {
|
||||
isCentered = true;
|
||||
break;
|
||||
}
|
||||
// Stop at body
|
||||
if (cur.tagName === 'BODY') break;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
if (isCentered) centeredCount++;
|
||||
}
|
||||
|
||||
if (totalText >= 5 && centeredCount / totalText > 0.7) {
|
||||
findings.push({
|
||||
id: 'everything-centered',
|
||||
snippet: `${centeredCount}/${totalText} text elements centered (${Math.round(centeredCount / totalText * 100)}%)`,
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// jsdom detection (default for HTML files)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -603,11 +813,14 @@ async function detectHtml(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
// Page-level typography checks (only for full pages, not partials)
|
||||
// Page-level checks (only for full pages, not partials)
|
||||
if (isFullPage(html)) {
|
||||
for (const f of checkPageTypography(document, window)) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of checkPageLayout(document, window)) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
}
|
||||
|
||||
window.close();
|
||||
@@ -784,10 +997,10 @@ const REGEX_MATCHERS = [
|
||||
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat)\b/gi,
|
||||
test: () => true,
|
||||
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
|
||||
// --- Pure black/white ---
|
||||
{ id: 'pure-black-white', regex: /(?:color|background(?:-color)?)\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\)|#ffffff|#fff|rgb\(255,\s*255,\s*255\))\b/gi,
|
||||
// --- Pure black background ---
|
||||
{ id: 'pure-black-white', regex: /background(?:-color)?\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\))\b/gi,
|
||||
test: () => true,
|
||||
fmt: (m) => `${m[0]}` },
|
||||
fmt: (m) => m[0] },
|
||||
// --- Gradient text ---
|
||||
{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
|
||||
test: (m, line) => /gradient/i.test(line),
|
||||
@@ -796,8 +1009,8 @@ const REGEX_MATCHERS = [
|
||||
{ id: 'gradient-text', regex: /\bbg-clip-text\b/g,
|
||||
test: (m, line) => /\bbg-gradient-to-/i.test(line),
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind pure black/white ---
|
||||
{ id: 'pure-black-white', regex: /\b(bg-black|bg-white|text-black)\b/g,
|
||||
// --- Tailwind pure black background ---
|
||||
{ id: 'pure-black-white', regex: /\bbg-black\b/g,
|
||||
test: () => true,
|
||||
fmt: (m) => m[0] },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
@@ -861,6 +1074,43 @@ const REGEX_ANALYZERS = [
|
||||
for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } }
|
||||
return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)];
|
||||
},
|
||||
// Monotonous spacing (regex)
|
||||
(content, filePath) => {
|
||||
const vals = [];
|
||||
let m;
|
||||
const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
|
||||
while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); }
|
||||
const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
|
||||
while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); }
|
||||
const gapRe = /gap\s*:\s*(\d+)px/gi;
|
||||
while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]);
|
||||
const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
|
||||
while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4);
|
||||
const rounded = vals.map(v => Math.round(v / 4) * 4);
|
||||
if (rounded.length < 10) return [];
|
||||
const counts = {};
|
||||
for (const v of rounded) counts[v] = (counts[v] || 0) + 1;
|
||||
const maxCount = Math.max(...Object.values(counts));
|
||||
const pct = maxCount / rounded.length;
|
||||
const unique = [...new Set(rounded)].filter(v => v > 0);
|
||||
if (pct <= 0.6 || unique.length > 3) return [];
|
||||
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
||||
return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
|
||||
},
|
||||
// Everything centered (regex)
|
||||
(content, filePath) => {
|
||||
const lines = content.split('\n');
|
||||
let centered = 0, total = 0;
|
||||
for (const line of lines) {
|
||||
// Check lines that have text content elements
|
||||
if (/<(?:h[1-6]|p|div|li|button)\b[^>]*>/i.test(line) && line.trim().length > 20) {
|
||||
total++;
|
||||
if (/text-align\s*:\s*center/i.test(line) || /\btext-center\b/.test(line)) centered++;
|
||||
}
|
||||
}
|
||||
if (total < 5 || centered / total <= 0.7) return [];
|
||||
return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)];
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -1058,7 +1308,7 @@ if (isMainModule) main();
|
||||
|
||||
export {
|
||||
ANTIPATTERNS, SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS,
|
||||
checkElementBorders, checkPageTypography, isNeutralColor, isFullPage,
|
||||
checkElementBorders, checkPageTypography, checkPageLayout, isNeutralColor, isFullPage,
|
||||
detectHtml, detectUrl, detectText,
|
||||
walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user