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 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-22 12:23:06 -07:00
co-authored by Claude Fable 5
parent 270f4d20aa
commit daec380cdb
4 changed files with 221 additions and 0 deletions
+8
View File
@@ -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',
+75
View File
@@ -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)) {
@@ -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,
+96
View File
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>undersized-ui-text fixture</title>
<style>
/* Explicit pixel dimensions because jsdom does no layout. */
body { font-family: system-ui, sans-serif; font-size: 16px; }
.col { display: inline-block; width: 480px; vertical-align: top; }
/* --- should-flag styles --- */
.nav-link { font-size: 8px; }
.category { font-size: 8px; }
.meta { font-size: 9px; }
.btn-small { font-size: 10px; width: 120px; height: 24px; }
.cell-small { font-size: 9px; }
.caps-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; }
.footer-link { font-size: 8px; }
/* --- should-pass styles --- */
.legal { font-size: 10px; line-height: 1.5; } /* non-interactive footer smallprint at 10px */
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); font-size: 4px; }
.sup-marker { font-size: 8px; }
.sub-marker { font-size: 8px; }
.em-parent { font-size: 20px; }
.em-child { font-size: 0.6em; } /* resolves to 12px, above the floor */
.mock-terminal { width: 400px; }
.term-line { font-size: 9px; } /* legitimately small code/terminal text */
.normal-link { font-size: 12px; } /* at the floor, allowed */
</style>
</head>
<body>
<!-- ================= SHOULD FLAG (functional/UI text below the 11px floor) ================= -->
<div class="col" id="should-flag">
<h2>Should flag</h2>
<!-- interactive: nav link at 8px -->
<nav aria-label="primary">
<a href="/docs" class="nav-link">Flag Nav Link</a>
</nav>
<!-- non-interactive furniture: category label at 8px -->
<span class="category">Flag Category</span>
<!-- non-interactive furniture: meta row at 9px -->
<span class="meta">Flag Meta Row</span>
<!-- interactive: button label at 10px -->
<button type="button" class="btn-small">Flag Button</button>
<!-- structural furniture: table cell at 9px -->
<table>
<tbody>
<tr><td class="cell-small">Flag Table Cell</td></tr>
</tbody>
</table>
<!-- decorative letterspaced uppercase micro-label at 10px: STILL functional, not exempt -->
<span class="caps-label">Flag Caps Label</span>
<!-- interactive text in a footer stays on the 11px floor: footer link at 8px -->
<footer>
<a href="/privacy" class="footer-link">Flag Footer Link</a>
</footer>
</div>
<!-- ================= SHOULD PASS ================= -->
<div class="col" id="should-pass">
<h2>Should pass</h2>
<!-- non-interactive legal fine print in a footer at 10px: floor drops to 10px for smallprint -->
<footer>
<p class="legal">Pass Legal Fine Print copyright 2026 all rights reserved across this jurisdiction</p>
</footer>
<!-- visually-hidden / screen-reader text: never rendered, exempt -->
<span class="sr-only">Pass Sr Only</span>
<!-- sup / sub markers are exempt by tag -->
<p>Reference<sup class="sup-marker">Pass Sup Marker</sup> and water<sub class="sub-marker">Pass Sub Marker</sub></p>
<!-- em-sized text relative to a large parent computes to 12px, above the floor -->
<div class="em-parent"><span class="em-child">Pass Em Sized</span></div>
<!-- code / terminal mock: legitimately small, exempt -->
<div class="mock-terminal">
<span class="term-line">Pass Terminal Line</span>
</div>
<!-- functional text exactly at the 12px floor is allowed -->
<a href="/home" class="normal-link">Pass Normal Link</a>
</div>
</body>
</html>