mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 15:16:35 +03:00
Fix flat type hierarchy false positives (#702)
* Fix flat type hierarchy false positives Use rendered semantic roles and dominant size frequency, align the adjacent-step guidance, and abstain in source-only scans.\n\nAI assistance: prepared with Codex under maintainer direction. * Fix static hidden typography filtering Honor the hidden attribute in the static wrapper and use raw browser findings in regression coverage. AI assistance: prepared with Codex under maintainer direction. * Align typography sampling with painted content Count visibly painted aria-hidden text and exclude content-visibility hidden subtrees in both static and browser scans. AI assistance: prepared with Codex under maintainer direction.
This commit is contained in:
@@ -159,7 +159,7 @@ const ANTIPATTERNS = [
|
||||
scopes: ['type'],
|
||||
name: 'Flat type hierarchy',
|
||||
description:
|
||||
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
|
||||
'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'flat type hierarchy',
|
||||
},
|
||||
@@ -5184,6 +5184,88 @@ function checkElementGlow(tag, style, effectiveBg) {
|
||||
|
||||
// ─── Section 6: Page-Level Checks ───────────────────────────────────────────
|
||||
|
||||
const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption';
|
||||
const TYPE_HIERARCHY_MIN_ROLES = 3;
|
||||
const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25;
|
||||
|
||||
function typeHierarchyRole(el) {
|
||||
const tag = String(el?.tagName || el?.nodeName || '').toLowerCase();
|
||||
return /^h[1-6]$/.test(tag) ? tag : 'body';
|
||||
}
|
||||
|
||||
function hasTextContent(el) {
|
||||
return String(el?.textContent || '').trim().length > 0;
|
||||
}
|
||||
|
||||
function isRenderedTypeElement(el, getStyle) {
|
||||
for (let current = el; current; current = current.parentElement) {
|
||||
const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null;
|
||||
if (current.hidden || hiddenAttr) return false;
|
||||
const style = getStyle(current);
|
||||
if (!style) continue;
|
||||
const display = String(style.display || '').toLowerCase();
|
||||
const visibility = String(style.visibility || '').toLowerCase();
|
||||
const contentVisibility = String(style.contentVisibility || '').toLowerCase();
|
||||
if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false;
|
||||
const opacity = parseFloat(style.opacity);
|
||||
if (Number.isFinite(opacity) && opacity <= 0.01) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function dominantTypeRoleSize(samples) {
|
||||
const counts = new Map();
|
||||
for (const sample of samples) {
|
||||
counts.set(sample.size, (counts.get(sample.size) || 0) + 1);
|
||||
}
|
||||
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]);
|
||||
if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null;
|
||||
return ranked[0]?.[0] ?? null;
|
||||
}
|
||||
|
||||
function checkFlatTypeHierarchySamples(samples) {
|
||||
const byRole = new Map();
|
||||
for (const sample of samples || []) {
|
||||
const role = String(sample?.role || '');
|
||||
const size = Math.round(Number(sample?.size) * 10) / 10;
|
||||
if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue;
|
||||
if (!byRole.has(role)) byRole.set(role, []);
|
||||
byRole.get(role).push({ role, size });
|
||||
}
|
||||
|
||||
const roles = [...byRole.entries()].map(([role, roleSamples]) => ({
|
||||
role,
|
||||
size: dominantTypeRoleSize(roleSamples),
|
||||
})).filter(item => item.size !== null);
|
||||
|
||||
if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return [];
|
||||
|
||||
const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role));
|
||||
let largestStep = 1;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size);
|
||||
}
|
||||
if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return [];
|
||||
|
||||
const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', ');
|
||||
return [{
|
||||
id: 'flat-type-hierarchy',
|
||||
snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`,
|
||||
}];
|
||||
}
|
||||
|
||||
function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) {
|
||||
const samples = [];
|
||||
for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) {
|
||||
if (options.skipElement?.(el)) continue;
|
||||
if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue;
|
||||
const fontSize = parseFloat(getStyle(el)?.fontSize);
|
||||
if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue;
|
||||
samples.push({ role: typeHierarchyRole(el), size: fontSize });
|
||||
}
|
||||
return checkFlatTypeHierarchySamples(samples);
|
||||
}
|
||||
|
||||
// Browser page-level checks — use document/getComputedStyle globals
|
||||
|
||||
function checkTypography() {
|
||||
@@ -5222,17 +5304,10 @@ function checkTypography() {
|
||||
}
|
||||
}
|
||||
|
||||
const sizes = new Set();
|
||||
for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) {
|
||||
const fs = parseFloat(getComputedStyle(el).fontSize);
|
||||
if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10);
|
||||
}
|
||||
if (sizes.size >= 3) {
|
||||
const sorted = [...sizes].sort((a, b) => a - b);
|
||||
const ratio = sorted[sorted.length - 1] / sorted[0];
|
||||
if (ratio < 2.0) {
|
||||
findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
|
||||
}
|
||||
for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, {
|
||||
skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'),
|
||||
})) {
|
||||
findings.push({ type: finding.id, detail: finding.snippet });
|
||||
}
|
||||
|
||||
return findings;
|
||||
@@ -5479,21 +5554,7 @@ function checkPageTypography(doc, win) {
|
||||
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
|
||||
}
|
||||
|
||||
// Flat type hierarchy
|
||||
const sizes = new Set();
|
||||
const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
|
||||
for (const el of textEls) {
|
||||
const fontSize = parseFloat(win.getComputedStyle(el).fontSize);
|
||||
// Filter out sub-8px values (jsdom doesn't resolve relative units properly)
|
||||
if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
|
||||
}
|
||||
if (sizes.size >= 3) {
|
||||
const sorted = [...sizes].sort((a, b) => a - b);
|
||||
const ratio = sorted[sorted.length - 1] / sorted[0];
|
||||
if (ratio < 2.0) {
|
||||
findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
|
||||
}
|
||||
}
|
||||
findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el)));
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
@@ -607,32 +607,6 @@ const REGEX_MATCHERS = [
|
||||
];
|
||||
|
||||
const REGEX_ANALYZERS = [
|
||||
// Flat type hierarchy
|
||||
(content, filePath) => {
|
||||
const sizes = new Set();
|
||||
const REM = 16;
|
||||
let m;
|
||||
const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi;
|
||||
while ((m = sizeRe.exec(content)) !== null) {
|
||||
const px = m[2] === 'px' ? +m[1] : +m[1] * REM;
|
||||
if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10);
|
||||
}
|
||||
const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi;
|
||||
while ((m = clampRe.exec(content)) !== null) {
|
||||
sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10);
|
||||
sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10);
|
||||
}
|
||||
const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 };
|
||||
for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); }
|
||||
if (sizes.size < 3) return [];
|
||||
const sorted = [...sizes].sort((a, b) => a - b);
|
||||
const ratio = sorted[sorted.length - 1] / sorted[0];
|
||||
if (ratio >= 2.0) return [];
|
||||
const lines = content.split('\n');
|
||||
let line = 1;
|
||||
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 = [];
|
||||
@@ -1154,11 +1128,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [
|
||||
function runTextContentAnalyzers(content, filePath, options = {}) {
|
||||
const profile = options?.profile;
|
||||
if (!shouldRunPageAnalyzers(content, filePath)) return [];
|
||||
// The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS
|
||||
// (single-font's removal on 2026-07-29 shifted every index down one).
|
||||
// The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS.
|
||||
// flat-type-hierarchy left this source-only path in issue #619 because it
|
||||
// needs rendered role and usage evidence.
|
||||
const findings = [];
|
||||
for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {
|
||||
const analyzer = REGEX_ANALYZERS[2 + i];
|
||||
const analyzer = REGEX_ANALYZERS[1 + i];
|
||||
const ruleId = TEXT_CONTENT_ANALYZER_IDS[i];
|
||||
findings.push(...profileFindings(profile, {
|
||||
engine: 'regex',
|
||||
@@ -1284,7 +1259,6 @@ function detectText(content, filePath, options = {}) {
|
||||
// Page-level analyzers only run on full pages
|
||||
if (shouldRunPageAnalyzers(content, filePath)) {
|
||||
const analyzerIds = [
|
||||
'flat-type-hierarchy',
|
||||
'monotonous-spacing',
|
||||
'em-dash-overuse',
|
||||
'marketing-buzzword',
|
||||
|
||||
@@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = {
|
||||
marginLeft: '0px',
|
||||
position: 'static',
|
||||
visibility: 'visible',
|
||||
contentVisibility: 'visible',
|
||||
opacity: '1',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
checkElementOversizedH1,
|
||||
checkElementQuality,
|
||||
checkElementRadialSpotlight,
|
||||
checkFlatTypeHierarchyFromDoc,
|
||||
checkCreamPalette,
|
||||
checkHtmlPatterns,
|
||||
checkKickerAboveHeadingFromDoc,
|
||||
@@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) {
|
||||
for (const font of overusedFound) {
|
||||
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
|
||||
}
|
||||
const sizes = new Set();
|
||||
for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) {
|
||||
const fontSize = parseFloat(window.getComputedStyle(el).fontSize);
|
||||
if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
|
||||
}
|
||||
if (sizes.size >= 3) {
|
||||
const sorted = [...sizes].sort((a, b) => a - b);
|
||||
const ratio = sorted[sorted.length - 1] / sorted[0];
|
||||
if (ratio < 2.0) {
|
||||
findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
|
||||
}
|
||||
}
|
||||
findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el)));
|
||||
return findings;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ const ANTIPATTERNS = [
|
||||
scopes: ['type'],
|
||||
name: 'Flat type hierarchy',
|
||||
description:
|
||||
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
|
||||
'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'flat type hierarchy',
|
||||
},
|
||||
|
||||
+89
-26
@@ -3911,6 +3911,88 @@ function checkElementGlow(tag, style, effectiveBg) {
|
||||
|
||||
// ─── Section 6: Page-Level Checks ───────────────────────────────────────────
|
||||
|
||||
const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption';
|
||||
const TYPE_HIERARCHY_MIN_ROLES = 3;
|
||||
const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25;
|
||||
|
||||
function typeHierarchyRole(el) {
|
||||
const tag = String(el?.tagName || el?.nodeName || '').toLowerCase();
|
||||
return /^h[1-6]$/.test(tag) ? tag : 'body';
|
||||
}
|
||||
|
||||
function hasTextContent(el) {
|
||||
return String(el?.textContent || '').trim().length > 0;
|
||||
}
|
||||
|
||||
function isRenderedTypeElement(el, getStyle) {
|
||||
for (let current = el; current; current = current.parentElement) {
|
||||
const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null;
|
||||
if (current.hidden || hiddenAttr) return false;
|
||||
const style = getStyle(current);
|
||||
if (!style) continue;
|
||||
const display = String(style.display || '').toLowerCase();
|
||||
const visibility = String(style.visibility || '').toLowerCase();
|
||||
const contentVisibility = String(style.contentVisibility || '').toLowerCase();
|
||||
if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false;
|
||||
const opacity = parseFloat(style.opacity);
|
||||
if (Number.isFinite(opacity) && opacity <= 0.01) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function dominantTypeRoleSize(samples) {
|
||||
const counts = new Map();
|
||||
for (const sample of samples) {
|
||||
counts.set(sample.size, (counts.get(sample.size) || 0) + 1);
|
||||
}
|
||||
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]);
|
||||
if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null;
|
||||
return ranked[0]?.[0] ?? null;
|
||||
}
|
||||
|
||||
function checkFlatTypeHierarchySamples(samples) {
|
||||
const byRole = new Map();
|
||||
for (const sample of samples || []) {
|
||||
const role = String(sample?.role || '');
|
||||
const size = Math.round(Number(sample?.size) * 10) / 10;
|
||||
if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue;
|
||||
if (!byRole.has(role)) byRole.set(role, []);
|
||||
byRole.get(role).push({ role, size });
|
||||
}
|
||||
|
||||
const roles = [...byRole.entries()].map(([role, roleSamples]) => ({
|
||||
role,
|
||||
size: dominantTypeRoleSize(roleSamples),
|
||||
})).filter(item => item.size !== null);
|
||||
|
||||
if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return [];
|
||||
|
||||
const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role));
|
||||
let largestStep = 1;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size);
|
||||
}
|
||||
if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return [];
|
||||
|
||||
const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', ');
|
||||
return [{
|
||||
id: 'flat-type-hierarchy',
|
||||
snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`,
|
||||
}];
|
||||
}
|
||||
|
||||
function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) {
|
||||
const samples = [];
|
||||
for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) {
|
||||
if (options.skipElement?.(el)) continue;
|
||||
if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue;
|
||||
const fontSize = parseFloat(getStyle(el)?.fontSize);
|
||||
if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue;
|
||||
samples.push({ role: typeHierarchyRole(el), size: fontSize });
|
||||
}
|
||||
return checkFlatTypeHierarchySamples(samples);
|
||||
}
|
||||
|
||||
// Browser page-level checks — use document/getComputedStyle globals
|
||||
|
||||
function checkTypography() {
|
||||
@@ -3949,17 +4031,10 @@ function checkTypography() {
|
||||
}
|
||||
}
|
||||
|
||||
const sizes = new Set();
|
||||
for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) {
|
||||
const fs = parseFloat(getComputedStyle(el).fontSize);
|
||||
if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10);
|
||||
}
|
||||
if (sizes.size >= 3) {
|
||||
const sorted = [...sizes].sort((a, b) => a - b);
|
||||
const ratio = sorted[sorted.length - 1] / sorted[0];
|
||||
if (ratio < 2.0) {
|
||||
findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
|
||||
}
|
||||
for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, {
|
||||
skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'),
|
||||
})) {
|
||||
findings.push({ type: finding.id, detail: finding.snippet });
|
||||
}
|
||||
|
||||
return findings;
|
||||
@@ -4206,21 +4281,7 @@ function checkPageTypography(doc, win) {
|
||||
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
|
||||
}
|
||||
|
||||
// Flat type hierarchy
|
||||
const sizes = new Set();
|
||||
const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
|
||||
for (const el of textEls) {
|
||||
const fontSize = parseFloat(win.getComputedStyle(el).fontSize);
|
||||
// Filter out sub-8px values (jsdom doesn't resolve relative units properly)
|
||||
if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
|
||||
}
|
||||
if (sizes.size >= 3) {
|
||||
const sorted = [...sizes].sort((a, b) => a - b);
|
||||
const ratio = sorted[sorted.length - 1] / sorted[0];
|
||||
if (ratio < 2.0) {
|
||||
findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
|
||||
}
|
||||
}
|
||||
findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el)));
|
||||
|
||||
return findings;
|
||||
}
|
||||
@@ -5649,6 +5710,8 @@ export {
|
||||
checkKickerAboveHeadingFromDoc,
|
||||
checkElementMotion,
|
||||
checkElementGlow,
|
||||
checkFlatTypeHierarchySamples,
|
||||
checkFlatTypeHierarchyFromDoc,
|
||||
checkTypography,
|
||||
isCardLikeDOM,
|
||||
checkLayout,
|
||||
|
||||
@@ -35,6 +35,20 @@ const MIME = {
|
||||
'.jpg': 'image/jpeg',
|
||||
};
|
||||
|
||||
function isolatedBrowserFixtureCases(name) {
|
||||
const source = fs.readFileSync(path.join(ROOT, 'tests', 'fixtures', 'antipatterns', name), 'utf8');
|
||||
const style = source.match(/<style>([\s\S]*?)<\/style>/i)?.[1] || '';
|
||||
const cases = [];
|
||||
for (const match of source.matchAll(/<article\b([^>]*)>([\s\S]*?)<\/article>/gi)) {
|
||||
const attrs = match[1];
|
||||
const caseName = attrs.match(/\bdata-case="([^"]+)"/i)?.[1];
|
||||
const expect = attrs.match(/\bdata-expect="(flag|pass)"/i)?.[1];
|
||||
if (!caseName || !expect) continue;
|
||||
cases.push({ caseName, expect, html: `<article${attrs}>${match[2]}</article>` });
|
||||
}
|
||||
return { style, cases };
|
||||
}
|
||||
|
||||
let server;
|
||||
let baseUrl;
|
||||
|
||||
@@ -395,6 +409,40 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('flat-type-hierarchy: browser scan uses role and frequency evidence', async () => {
|
||||
const puppeteer = await import('puppeteer');
|
||||
const browser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const { style, cases } = isolatedBrowserFixtureCases('flat-type-hierarchy.html');
|
||||
await page.setContent(`<!DOCTYPE html><html><head><style>${style}</style></head><body></body></html>`);
|
||||
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
|
||||
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
|
||||
await page.evaluate(browserScript);
|
||||
|
||||
for (const item of cases) {
|
||||
const count = await page.evaluate((html) => {
|
||||
document.body.innerHTML = html;
|
||||
return window.impeccableDetect({ serialize: false })
|
||||
.flatMap(group => group.findings || [])
|
||||
.filter(finding => (finding.type || finding.id) === 'flat-type-hierarchy')
|
||||
.length;
|
||||
}, item.html);
|
||||
assert.equal(
|
||||
count,
|
||||
item.expect === 'flag' ? 1 : 0,
|
||||
`unexpected browser result for "${item.caseName}"`,
|
||||
);
|
||||
}
|
||||
await page.close();
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('overused-font: hook inline-ignore comments do not suppress browser findings', async () => {
|
||||
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/hook-inline-ignore.html`);
|
||||
assert.ok(
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import {
|
||||
@@ -20,6 +21,49 @@ import { checkEmDashOveruse } from '../cli/engine/rules/checks.mjs';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES = path.join(__dirname, 'fixtures', 'antipatterns');
|
||||
|
||||
function isolatedFixtureCases(name) {
|
||||
const source = fs.readFileSync(path.join(FIXTURES, name), 'utf8');
|
||||
const style = source.match(/<style>([\s\S]*?)<\/style>/i)?.[1] || '';
|
||||
const cases = [];
|
||||
for (const match of source.matchAll(/<article\b([^>]*)>([\s\S]*?)<\/article>/gi)) {
|
||||
const attrs = match[1];
|
||||
const caseName = attrs.match(/\bdata-case="([^"]+)"/i)?.[1];
|
||||
const expect = attrs.match(/\bdata-expect="(flag|pass)"/i)?.[1];
|
||||
if (!caseName || !expect) continue;
|
||||
cases.push({
|
||||
caseName,
|
||||
expect,
|
||||
html: `<!DOCTYPE html><html><head><style>${style}</style></head><body><article${attrs}>${match[2]}</article></body></html>`,
|
||||
});
|
||||
}
|
||||
return cases;
|
||||
}
|
||||
|
||||
describe('flat-type-hierarchy — role and usage fixture (issue #619)', () => {
|
||||
it('flags compressed document roles and passes dense UI/chrome shapes', async () => {
|
||||
const cases = isolatedFixtureCases('flat-type-hierarchy.html');
|
||||
assert.equal(cases.filter(item => item.expect === 'flag').length, 5);
|
||||
assert.equal(cases.filter(item => item.expect === 'pass').length, 6);
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-flat-type-'));
|
||||
try {
|
||||
for (const [index, item] of cases.entries()) {
|
||||
const file = path.join(tempDir, `case-${index}.html`);
|
||||
fs.writeFileSync(file, item.html);
|
||||
const findings = await detectHtml(file);
|
||||
const flat = findings.filter(finding => finding.antipattern === 'flat-type-hierarchy');
|
||||
if (item.expect === 'flag') {
|
||||
assert.equal(flat.length, 1, `expected "${item.caseName}" to flag: ${JSON.stringify(findings)}`);
|
||||
} else {
|
||||
assert.equal(flat.length, 0, `expected "${item.caseName}" to pass: ${JSON.stringify(flat)}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectText - Astro structural CSS fixtures', () => {
|
||||
const SHOULD_FLAG = [
|
||||
'Kinpaku Edge',
|
||||
|
||||
@@ -16,6 +16,7 @@ import * as domutils from 'domutils';
|
||||
import { StaticDocument } from '../cli/engine/engines/static-html/css-cascade.mjs';
|
||||
import { filterByScopes } from '../cli/engine/registry/antipatterns.mjs';
|
||||
import {
|
||||
checkFlatTypeHierarchySamples,
|
||||
checkColors,
|
||||
checkElementTextOverflowDOM,
|
||||
checkHeroEyebrow,
|
||||
@@ -703,19 +704,62 @@ describe('detectHtml — overused fonts system stack', () => {
|
||||
});
|
||||
|
||||
describe('detectText — flat type hierarchy', () => {
|
||||
test('flags sizes too close together', () => {
|
||||
test('source-only declarations abstain because rendered role frequency is unknowable', () => {
|
||||
const page = '<!DOCTYPE html><html><style>h1{font-size:18px}h2{font-size:16px}h3{font-size:15px}p{font-size:14px}.s{font-size:13px}</style></html>';
|
||||
const f = detectText(page, 'test.html');
|
||||
expect(f.some(r => r.antipattern === 'flat-type-hierarchy')).toBe(true);
|
||||
expect(f.filter(r => r.antipattern === 'flat-type-hierarchy')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('passes good hierarchy', () => {
|
||||
test('also abstains when source declarations suggest a wide hierarchy', () => {
|
||||
const page = '<!DOCTYPE html><html><style>h1{font-size:48px}h2{font-size:32px}p{font-size:16px}.s{font-size:12px}</style></html>';
|
||||
const f = detectText(page, 'test.html');
|
||||
expect(f.filter(r => r.antipattern === 'flat-type-hierarchy')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flat-type-hierarchy — role analysis', () => {
|
||||
test('uses the dominant size within each semantic role', () => {
|
||||
const findings = checkFlatTypeHierarchySamples([
|
||||
{ role: 'h1', size: 18 },
|
||||
{ role: 'h2', size: 16 },
|
||||
{ role: 'h2', size: 16 },
|
||||
{ role: 'h2', size: 40 },
|
||||
...Array.from({ length: 20 }, () => ({ role: 'body', size: 14 })),
|
||||
{ role: 'body', size: 10 },
|
||||
]);
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].snippet).toContain('body 14px, h2 16px, h1 18px');
|
||||
expect(findings[0].snippet).not.toContain('40px');
|
||||
});
|
||||
|
||||
test('passes when one adjacent role step reaches the documented threshold', () => {
|
||||
const findings = checkFlatTypeHierarchySamples([
|
||||
{ role: 'h1', size: 25 },
|
||||
{ role: 'h2', size: 20 },
|
||||
{ role: 'body', size: 16 },
|
||||
]);
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('abstains when fewer than three semantic roles render', () => {
|
||||
const findings = checkFlatTypeHierarchySamples([
|
||||
{ role: 'h1', size: 18 },
|
||||
...Array.from({ length: 100 }, () => ({ role: 'body', size: 14 })),
|
||||
]);
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('abstains from a role whose competing sizes have no dominant value', () => {
|
||||
const findings = checkFlatTypeHierarchySamples([
|
||||
{ role: 'h1', size: 18 },
|
||||
{ role: 'h1', size: 48 },
|
||||
{ role: 'h2', size: 16 },
|
||||
{ role: 'body', size: 14 },
|
||||
]);
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Static HTML/CSS fixture tests moved to detect-antipatterns-fixtures.test.mjs (run via node --test)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -749,13 +793,13 @@ describe('partials skip page-level checks', () => {
|
||||
expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
|
||||
});
|
||||
|
||||
test('regex: full page with flat hierarchy IS flagged', () => {
|
||||
test('regex: full page with declarations-only hierarchy abstains', () => {
|
||||
const page = '<!DOCTYPE html><html><head></head><body>\n' +
|
||||
'<h1 style="font-size: 18px">h1</h1>\n<h2 style="font-size: 16px">h2</h2>\n' +
|
||||
'<p style="font-size: 14px">p</p>\n<span style="font-size: 15px">s</span>\n' +
|
||||
'<small style="font-size: 13px">sm</small>\n</body></html>';
|
||||
const f = detectText(page, 'index.html');
|
||||
expect(f.some(r => r.antipattern === 'flat-type-hierarchy')).toBe(true);
|
||||
expect(f.filter(r => r.antipattern === 'flat-type-hierarchy')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Flat Type Hierarchy — Role and Usage Cases</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background: #ffffff;
|
||||
color: #172033;
|
||||
font-family: Karla, Arial, sans-serif;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 32px;
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
article {
|
||||
margin: 0 0 24px;
|
||||
padding: 20px;
|
||||
border: 1px solid #d8deea;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section aria-label="Should flag">
|
||||
<article data-expect="flag" data-case="Flag Compressed Product">
|
||||
<h1 style="font-size: 18px">Flag Compressed Product</h1>
|
||||
<h2 style="font-size: 16px">Primary section</h2>
|
||||
<h3 style="font-size: 15px">Supporting section</h3>
|
||||
<p style="font-size: 14px">Body copy is almost indistinguishable from every heading level.</p>
|
||||
</article>
|
||||
|
||||
<article data-expect="flag" data-case="Flag Soft Editorial">
|
||||
<h1 style="font-size: 21px">Flag Soft Editorial</h1>
|
||||
<h2 style="font-size: 19px"><span>A section with barely less emphasis</span></h2>
|
||||
<p style="font-size: 17px">The body is compressed into the same narrow band.</p>
|
||||
</article>
|
||||
|
||||
<article data-expect="flag" data-case="Flag Crowded Documentation">
|
||||
<h1 style="font-size: 20px">Flag Crowded Documentation</h1>
|
||||
<h2 style="font-size: 18px">Second-level documentation heading</h2>
|
||||
<h3 style="font-size: 17px">Third-level documentation heading</h3>
|
||||
<p style="font-size: 16px">Reading text has no strong size step above it.</p>
|
||||
</article>
|
||||
|
||||
<article data-expect="flag" data-case="Flag Repeated Role">
|
||||
<h1 style="font-size: 18px">Flag Repeated Role</h1>
|
||||
<h2 style="font-size: 16px">Dominant section size</h2>
|
||||
<h2 style="font-size: 16px">Another section at the dominant size</h2>
|
||||
<h2 style="font-size: 17px">A one-off section variation</h2>
|
||||
<p style="font-size: 15px">The representative role sizes remain uniformly compressed.</p>
|
||||
</article>
|
||||
|
||||
<article aria-hidden="true" data-expect="flag" data-case="Flag Painted Aria Hidden">
|
||||
<h1 style="font-size: 18px">Flag Painted Aria Hidden</h1>
|
||||
<h2 style="font-size: 16px">A visible section excluded from the accessibility tree</h2>
|
||||
<p style="font-size: 15px">Painted text still contributes to the visual hierarchy.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section aria-label="Should pass">
|
||||
<article data-expect="pass" data-case="Pass Dense Session List">
|
||||
<h1 style="font-size: 16px">Pass Dense Session List</h1>
|
||||
<div style="font-size: 11px">Session alpha</div>
|
||||
<div style="font-size: 11px">Session beta</div>
|
||||
<div style="font-size: 11px">Session gamma</div>
|
||||
<div style="font-size: 11px">Session delta</div>
|
||||
<div style="font-size: 11px">Session epsilon</div>
|
||||
<div style="font-size: 11px">Session zeta</div>
|
||||
<div style="font-size: 11px">Session eta</div>
|
||||
<div style="font-size: 11px">Session theta</div>
|
||||
<span style="font-size: 13px">One-off status</span>
|
||||
<button style="font-size: 14px">One-off action</button>
|
||||
<span style="font-size: 15px">One-off summary</span>
|
||||
</article>
|
||||
|
||||
<article data-expect="pass" data-case="Pass Empty Inherited Containers">
|
||||
<h2 style="font-size: 16px">Pass Empty Inherited Containers</h2>
|
||||
<h1 hidden style="font-size: 18px">Hidden inherited title</h1>
|
||||
<p hidden style="font-size: 14px">Hidden inherited body</p>
|
||||
<div style="font-size: 14px"></div>
|
||||
<div style="font-size: 15px"><span></span></div>
|
||||
<div style="font-size: 16px"></div>
|
||||
</article>
|
||||
|
||||
<article data-expect="pass" data-case="Pass One-Off Chrome">
|
||||
<h1 style="font-size: 24px">Pass One-Off Chrome</h1>
|
||||
<p style="font-size: 16px">The body size carries the page.</p>
|
||||
<p style="font-size: 16px">The body size repeats consistently.</p>
|
||||
<p style="font-size: 16px">The body size remains dominant.</p>
|
||||
<button style="font-size: 14px">Rare action</button>
|
||||
<label style="font-size: 15px">Rare label</label>
|
||||
<div style="font-size: 18px">Rare utility value</div>
|
||||
</article>
|
||||
|
||||
<article data-expect="pass" data-case="Pass Dense Issue List">
|
||||
<h1 style="font-size: 16px">Pass Dense Issue List</h1>
|
||||
<a style="font-size: 12px" href="#">Issue metadata one</a>
|
||||
<a style="font-size: 12px" href="#">Issue metadata two</a>
|
||||
<a style="font-size: 12px" href="#">Issue metadata three</a>
|
||||
<a style="font-size: 14px" href="#">Issue title one</a>
|
||||
<a style="font-size: 14px" href="#">Issue title two</a>
|
||||
<span style="font-size: 16px">List count</span>
|
||||
</article>
|
||||
|
||||
<article data-expect="pass" data-case="Pass Strong Document Hierarchy">
|
||||
<h1 style="font-size: 48px">Pass Strong Document Hierarchy</h1>
|
||||
<h2 style="font-size: 30px">A clearly subordinate section</h2>
|
||||
<h3 style="font-size: 22px">A readable subsection</h3>
|
||||
<p style="font-size: 16px">Body copy sits on a clearly separated scale.</p>
|
||||
</article>
|
||||
|
||||
<article style="content-visibility: hidden" data-expect="pass" data-case="Pass Content Visibility Hidden">
|
||||
<h1 style="font-size: 18px">Pass Content Visibility Hidden</h1>
|
||||
<h2 style="font-size: 16px">This section is not painted</h2>
|
||||
<p style="font-size: 15px">Non-painted text should not affect the hierarchy.</p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user