diff --git a/cli/engine/registry/antipatterns.mjs b/cli/engine/registry/antipatterns.mjs index 79fd04064..2fb79744f 100644 --- a/cli/engine/registry/antipatterns.mjs +++ b/cli/engine/registry/antipatterns.mjs @@ -410,6 +410,14 @@ const ANTIPATTERNS = [ description: 'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.', }, + { + id: 'undersized-ui-text', + category: 'quality', + scopes: ['type'], + name: 'Undersized functional text', + description: + 'Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.', + }, { id: 'all-caps-body', category: 'quality', diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index 5d03c9112..28e9b423b 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -2850,6 +2850,27 @@ function textDescendantsFlushSides(el, rect) { return flush; } +// Screen-reader-only ("visually hidden") text is exempt from the tiny-text +// floors: it is never rendered, so its size is irrelevant. Detect the two +// standard idioms — a known sr-only class on the element or an ancestor, and +// the clip / 1px-box pattern. Works in both jsdom (declared styles) and the +// browser (computed styles). +const SR_ONLY_SELECTOR = '.sr-only, .visually-hidden, .visuallyhidden, .screen-reader, .screen-reader-only, .screenreader, .a11y-hidden, .hidden-visually, [class*="sr-only" i], [class*="visually-hidden" i], [class*="visuallyhidden" i], [class*="screen-reader" i], [class*="screenreader" i]'; +function isVisuallyHidden(el, style) { + if ((el.matches && el.matches(SR_ONLY_SELECTOR)) || (el.closest && el.closest(SR_ONLY_SELECTOR))) return true; + const pos = style.position || ''; + if (pos === 'absolute' || pos === 'fixed') { + const clip = style.clip || ''; + const clipPath = style.clipPath || style.webkitClipPath || style['clip-path'] || ''; + if (/rect\(\s*0/.test(clip) || /inset\(\s*(?:50%|99|100%)/.test(clipPath)) return true; + const w = parseFloat(style.width); + const h = parseFloat(style.height); + const overflow = style.overflow || ''; + if ((w === 1 || h === 1) && (overflow === 'hidden' || overflow === 'clip')) return true; + } + return false; +} + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in // jsdom and the browser). Two checks (line-length, cramped-padding) gate on // element rect dimensions, which jsdom can't compute — pass `rect: null` from @@ -3134,6 +3155,60 @@ function checkQuality(opts) { } } + // --- Undersized functional / UI text --- + // Complements `tiny-text` above, which owns long body copy and deliberately + // EXEMPTS the UI furniture layer (nav, footer, links, buttons, labels, + // uppercase micro-labels). This rule targets exactly that blind spot: the + // interactive and short content-bearing text — nav items, buttons, labels, + // table cells, meta rows, timecodes — shipped below an 11px floor. + // + // The live failure it closes: a build shipped its entire furniture layer at + // 8px, and the design hook waved it through because 8px had been added to + // the DESIGN.md size ramp. Being on the ramp is a token argument, not a + // legibility one, so this rule ignores the design system entirely — a value + // on the ramp is still flagged. + // + // Floors: 11px for anything functional. The floor holds inside a footer; + // only NON-interactive legal smallprint gets the softer 10px floor. Exempts + // sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. + // Uppercase letterspaced micro-labels are still functional — not exempt. + { + const directText = [...el.childNodes] + .filter(n => n.nodeType === 3) + .map(n => n.textContent || '') + .join('') + .replace(/\s+/g, ' ') + .trim(); + const dtLen = directText.length; + const UI_SKIP_TAGS = new Set(['sub', 'sup', 'script', 'style', 'title', 'option']); + const notRendered = style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse'; + // jsdom resolves the parent chain in resolveFontSizePx, so em/rem/%-sized + // text that computes at or above the floor never reaches here. The browser + // adapter additionally catches values only resolvable with real layout + // (e.g. viewport-relative units, cascade winners set in linked sheets). + if (fontSize > 0 && fontSize < 11 && dtLen >= 2 && !UI_SKIP_TAGS.has(tag) && !notRendered) { + const EXEMPT_CONTEXT = 'pre, code, kbd, samp, var, svg, [aria-hidden="true"], [class*="terminal" i], [class*="console" i], [class*="code" i], [class*="mock" i], [class*="editor" i], [class*="syntax" i], [class*="diff" i]'; + const isExemptContext = (el.matches && el.matches(EXEMPT_CONTEXT)) || (el.closest && el.closest(EXEMPT_CONTEXT)); + if (!isExemptContext && !isVisuallyHidden(el, style)) { + const INTERACTIVE = 'a[href], button, summary, label, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="checkbox"], [role="radio"], [role="switch"], [role="treeitem"], [tabindex]'; + const FURNITURE = 'nav, [role="navigation"], td, th, [role="gridcell"], [role="cell"], caption, figcaption, dt, dd, footer, [class*="meta" i], [class*="label" i], [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="kicker" i], [class*="eyebrow" i], [class*="breadcrumb" i], [class*="timestamp" i], [class*="category" i], [class*="caption" i], [class*="nav" i]'; + const SMALLPRINT = 'small, footer, [class*="legal" i], [class*="copyright" i], [class*="fineprint" i], [class*="fine-print" i], [class*="smallprint" i], [class*="small-print" i], [class*="disclaimer" i], [class*="disclosure" i], [class*="footnote" i]'; + const isInteractive = (el.matches && el.matches(INTERACTIVE)) || (el.closest && el.closest(INTERACTIVE)); + const isFurniture = (el.matches && el.matches(FURNITURE)) || (el.closest && el.closest(FURNITURE)); + const isSmallprint = (el.matches && el.matches(SMALLPRINT)) || (el.closest && el.closest(SMALLPRINT)); + const floor = (!isInteractive && isSmallprint) ? 10 : 11; + // Fire on functional text only: interactive, structural furniture, or + // any short (<=20-char) run — the label / meta / timecode shape. Long + // non-furniture body copy stays with `tiny-text`, so the two rules + // never double-flag the same element. + if (fontSize < floor && (isInteractive || isFurniture || dtLen <= 20)) { + const excerpt = directText.slice(0, 40); + findings.push({ id: 'undersized-ui-text', snippet: `${fontSize}px functional text "${excerpt}" (below ${floor}px floor)` }); + } + } + } + } + // --- All-caps body text --- if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') { if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) { diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs index a441aa852..a4cdb7558 100644 --- a/tests/detect-antipatterns-fixtures.test.mjs +++ b/tests/detect-antipatterns-fixtures.test.mjs @@ -555,6 +555,48 @@ describe('detectHtml — icon-tile-stack', () => { }); }); +describe('detectHtml — undersized-ui-text', () => { + // Two-column fixture: left col = should-flag, right col = should-pass. + // The rule's snippet embeds the element's direct text in quotes, e.g. + // `8px functional text "Flag Nav Link" (below 11px floor)`. + // The test extracts those quoted texts and matches them against the lists. + const SHOULD_FLAG = [ + 'Flag Nav Link', // interactive nav link at 8px + 'Flag Category', // non-interactive furniture label at 8px + 'Flag Meta Row', // meta row at 9px + 'Flag Button', // interactive button at 10px + 'Flag Table Cell', // structural table cell at 9px + 'Flag Caps Label', // uppercase letterspaced micro-label — NOT exempt + 'Flag Footer Link', // interactive text in footer stays on the 11px floor + ]; + const SHOULD_PASS = [ + 'Pass Legal Fine Print', // non-interactive footer smallprint at 10px (floor 10) + 'Pass Sr Only', // visually-hidden text + 'Pass Sup Marker', // sup tag exempt + 'Pass Sub Marker', // sub tag exempt + 'Pass Em Sized', // 0.6em of a 20px parent = 12px, above the floor + 'Pass Terminal Line', // code/terminal mock, legitimately small + 'Pass Normal Link', // functional text at the 12px floor + ]; + + it('undersized-ui-text: flags only the should-flag column', async () => { + const f = await detectHtml(path.join(FIXTURES, 'undersized-ui-text.html')); + const flagged = new Set(); + for (const r of f) { + if (r.antipattern !== 'undersized-ui-text') continue; + const m = (r.snippet || '').match(/"([^"]+)"/); + if (m) flagged.add(m[1]); + } + + for (const text of SHOULD_FLAG) { + assert.ok(flagged.has(text), `expected "${text}" to be flagged as undersized-ui-text`); + } + for (const text of SHOULD_PASS) { + assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as undersized-ui-text`); + } + }); +}); + describe('detectHtml — quality (static-compatible rules)', () => { // Six of the eight quality rules can run in static HTML/CSS because they only need // computed CSS values (tight-leading, tiny-text, justified-text, diff --git a/tests/fixtures/antipatterns/undersized-ui-text.html b/tests/fixtures/antipatterns/undersized-ui-text.html new file mode 100644 index 000000000..b70029606 --- /dev/null +++ b/tests/fixtures/antipatterns/undersized-ui-text.html @@ -0,0 +1,96 @@ + + +
+ +| Flag Table Cell |
ReferencePass Sup Marker and waterPass Sub Marker
+ + +