diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 3f5abd0a8..e6899f569 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -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; } diff --git a/cli/engine/engines/regex/detect-text.mjs b/cli/engine/engines/regex/detect-text.mjs index 26ca4f390..df1334fdd 100644 --- a/cli/engine/engines/regex/detect-text.mjs +++ b/cli/engine/engines/regex/detect-text.mjs @@ -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', diff --git a/cli/engine/engines/static-html/css-cascade.mjs b/cli/engine/engines/static-html/css-cascade.mjs index cc36ac9b5..7f6080b61 100644 --- a/cli/engine/engines/static-html/css-cascade.mjs +++ b/cli/engine/engines/static-html/css-cascade.mjs @@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = { marginLeft: '0px', position: 'static', visibility: 'visible', + contentVisibility: 'visible', opacity: '1', top: 'auto', right: 'auto', diff --git a/cli/engine/engines/static-html/detect-html.mjs b/cli/engine/engines/static-html/detect-html.mjs index 51c34224b..84e6ab1f5 100644 --- a/cli/engine/engines/static-html/detect-html.mjs +++ b/cli/engine/engines/static-html/detect-html.mjs @@ -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; } diff --git a/cli/engine/registry/antipatterns.mjs b/cli/engine/registry/antipatterns.mjs index 88ff84a07..945307783 100644 --- a/cli/engine/registry/antipatterns.mjs +++ b/cli/engine/registry/antipatterns.mjs @@ -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', }, diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index 8bfb2b1df..579e1c12e 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -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, diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index 0a264bf28..b1f15979b 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -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(/`); + 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( diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs index 814f259b4..9e3db1618 100644 --- a/tests/detect-antipatterns-fixtures.test.mjs +++ b/tests/detect-antipatterns-fixtures.test.mjs @@ -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(/${match[2]}`, + }); + } + 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', diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index 4f0ae4cb7..9ad3a7a47 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -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 = ''; 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 = ''; 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 = '\n' + '

h1

\n

h2

\n' + '

p

\ns\n' + 'sm\n'; 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); }); }); diff --git a/tests/fixtures/antipatterns/flat-type-hierarchy.html b/tests/fixtures/antipatterns/flat-type-hierarchy.html new file mode 100644 index 000000000..fa3441841 --- /dev/null +++ b/tests/fixtures/antipatterns/flat-type-hierarchy.html @@ -0,0 +1,129 @@ + + + + + + Flat Type Hierarchy — Role and Usage Cases + + + +
+
+
+

Flag Compressed Product

+

Primary section

+

Supporting section

+

Body copy is almost indistinguishable from every heading level.

+
+ +
+

Flag Soft Editorial

+

A section with barely less emphasis

+

The body is compressed into the same narrow band.

+
+ +
+

Flag Crowded Documentation

+

Second-level documentation heading

+

Third-level documentation heading

+

Reading text has no strong size step above it.

+
+ +
+

Flag Repeated Role

+

Dominant section size

+

Another section at the dominant size

+

A one-off section variation

+

The representative role sizes remain uniformly compressed.

+
+ + +
+ +
+
+

Pass Dense Session List

+
Session alpha
+
Session beta
+
Session gamma
+
Session delta
+
Session epsilon
+
Session zeta
+
Session eta
+
Session theta
+ One-off status + + One-off summary +
+ +
+

Pass Empty Inherited Containers

+

Hidden inherited title

+ +
+
+
+
+ +
+

Pass One-Off Chrome

+

The body size carries the page.

+

The body size repeats consistently.

+

The body size remains dominant.

+ + +
Rare utility value
+
+ + + +
+

Pass Strong Document Hierarchy

+

A clearly subordinate section

+

A readable subsection

+

Body copy sits on a clearly separated scale.

+
+ +
+

Pass Content Visibility Hidden

+

This section is not painted

+

Non-painted text should not affect the hierarchy.

+
+
+
+ +