From daec380cdbfbe5f305fd24f7940fa856a3014566 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 22 Jul 2026 10:07:52 -0700 Subject: [PATCH] Add undersized-ui-text rule for functional text below an 11px floor The existing `tiny-text` rule owns long body copy and deliberately exempts the UI furniture layer (nav, footer, links, buttons, labels, uppercase micro-labels). That left a real gap: a build shipped its entire furniture layer (nav links, category names, timecodes, meta rows) at 8px because the chosen pixel font only steps in 8px increments, and the design hook waved it through as merely "not on the DESIGN.md ramp" -- which the model resolved by adding 8px to the ramp. Being on the ramp launders the token, not the legibility problem. New `undersized-ui-text` quality rule closes that laundering path: - Flags interactive and short content-bearing text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below an 11px floor. The floor holds inside a footer; only non-interactive legal smallprint gets the softer 10px floor. - Ignores the design system entirely, so a value ON the ramp is still flagged. - Uppercase letterspaced micro-labels stay in scope (still functional). - Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. em/rem/%-sized text that computes at or above the floor never fires. - Complements tiny-text without double-flagging: long non-furniture body copy stays with tiny-text. Implemented as a single check in checkQuality (rules/checks.mjs), so both the static-html (jsdom) and browser adapters pick it up through the unified per-element path -- no dual wiring. Registered in registry/antipatterns.mjs. TDD: fixture tests/fixtures/antipatterns/undersized-ui-text.html (7 flag / 7 pass shapes), failing test first, then implement. Full fixtures suite 64/64. Deferred (blocked by an active release-gate eval reading build/_data/dist): regenerate the browser bundle (bun run build:browser -> cli/engine/detect-antipatterns-browser.js) and the extension detector (bun run build:extension -> extension/detector/detect.js + antipatterns.json) so the standalone browser/extension artifacts carry the new rule. Co-Authored-By: Claude Fable 5 --- cli/engine/registry/antipatterns.mjs | 8 ++ cli/engine/rules/checks.mjs | 75 +++++++++++++++ tests/detect-antipatterns-fixtures.test.mjs | 42 ++++++++ .../antipatterns/undersized-ui-text.html | 96 +++++++++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 tests/fixtures/antipatterns/undersized-ui-text.html 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 @@ + + + + +undersized-ui-text fixture + + + + + +
+

Should flag

+ + + + + + Flag Category + + + Flag Meta Row + + + + + + + + + +
Flag Table Cell
+ + + Flag Caps Label + + + +
+ + +
+

Should pass

+ + +
+ +
+ + + Pass Sr Only + + +

ReferencePass Sup Marker and waterPass Sub Marker

+ + +
Pass Em Sized
+ + +
+ Pass Terminal Line +
+ + + Pass Normal Link +
+ + +