mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Sync generated provider output
This commit is contained in:
@@ -82,6 +82,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -315,9 +324,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -507,6 +521,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',
|
||||
@@ -3367,6 +3389,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -3594,6 +3643,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
|
||||
@@ -3878,6 +3948,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)) {
|
||||
@@ -7066,6 +7190,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -7384,6 +7512,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -82,6 +82,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -315,9 +324,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -507,6 +521,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',
|
||||
@@ -3367,6 +3389,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -3594,6 +3643,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
|
||||
@@ -3878,6 +3948,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)) {
|
||||
@@ -7066,6 +7190,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -7384,6 +7512,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -82,6 +82,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -315,9 +324,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -507,6 +521,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',
|
||||
@@ -3367,6 +3389,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -3594,6 +3643,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
|
||||
@@ -3878,6 +3948,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)) {
|
||||
@@ -7066,6 +7190,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -7384,6 +7512,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -82,6 +82,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -315,9 +324,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -507,6 +521,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',
|
||||
@@ -3367,6 +3389,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -3594,6 +3643,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
|
||||
@@ -3878,6 +3948,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)) {
|
||||
@@ -7066,6 +7190,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -7384,6 +7512,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -82,6 +82,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -315,9 +324,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -507,6 +521,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',
|
||||
@@ -3367,6 +3389,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -3594,6 +3643,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
|
||||
@@ -3878,6 +3948,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)) {
|
||||
@@ -7066,6 +7190,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -7384,6 +7512,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -82,6 +82,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -315,9 +324,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -507,6 +521,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',
|
||||
@@ -3367,6 +3389,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -3594,6 +3643,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
|
||||
@@ -3878,6 +3948,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)) {
|
||||
@@ -7066,6 +7190,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -7384,6 +7512,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -82,6 +82,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -315,9 +324,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -507,6 +521,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',
|
||||
@@ -3367,6 +3389,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -3594,6 +3643,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
|
||||
@@ -3878,6 +3948,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)) {
|
||||
@@ -7066,6 +7190,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -7384,6 +7512,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -82,6 +82,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -315,9 +324,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -507,6 +521,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',
|
||||
@@ -3367,6 +3389,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -3594,6 +3643,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
|
||||
@@ -3878,6 +3948,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)) {
|
||||
@@ -7066,6 +7190,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -7384,6 +7512,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -82,6 +82,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -315,9 +324,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -507,6 +521,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',
|
||||
@@ -3367,6 +3389,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -3594,6 +3643,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
|
||||
@@ -3878,6 +3948,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)) {
|
||||
@@ -7066,6 +7190,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -7384,6 +7512,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -82,6 +82,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -315,9 +324,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -507,6 +521,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',
|
||||
@@ -3367,6 +3389,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -3594,6 +3643,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
|
||||
@@ -3878,6 +3948,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)) {
|
||||
@@ -7066,6 +7190,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -7384,6 +7512,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -82,6 +82,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -315,9 +324,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -507,6 +521,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',
|
||||
@@ -3367,6 +3389,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -3594,6 +3643,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
|
||||
@@ -3878,6 +3948,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)) {
|
||||
@@ -7066,6 +7190,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -7384,6 +7512,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -82,6 +82,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -315,9 +324,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -507,6 +521,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',
|
||||
@@ -3367,6 +3389,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -3594,6 +3643,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
|
||||
@@ -3878,6 +3948,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)) {
|
||||
@@ -7066,6 +7190,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -7384,6 +7512,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -82,6 +82,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -315,9 +324,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -507,6 +521,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',
|
||||
@@ -3367,6 +3389,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -3594,6 +3643,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
|
||||
@@ -3878,6 +3948,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)) {
|
||||
@@ -7066,6 +7190,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -7384,6 +7512,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -82,6 +82,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -315,9 +324,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -507,6 +521,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',
|
||||
@@ -3367,6 +3389,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -3594,6 +3643,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
|
||||
@@ -3878,6 +3948,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)) {
|
||||
@@ -7066,6 +7190,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -7384,6 +7512,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -82,6 +82,15 @@ const GENERIC_FONTS = new Set([
|
||||
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
||||
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
|
||||
|
||||
// Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
|
||||
// analyzer and the browser DOM check so both fire on the same saturation
|
||||
// pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
|
||||
// a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
|
||||
// text. A long article that uses a few em-dashes is left alone; a short,
|
||||
// dash-per-clause page is not.
|
||||
const EM_DASH_FLOOR = 8;
|
||||
const EM_DASH_CHARS_PER_DASH = 500;
|
||||
|
||||
// Serif faces that show up in italic-display heroes. The rule also fires when
|
||||
// the primary face is unknown but the stack ends in the generic `serif` token,
|
||||
// which catches custom/private faces with a serif fallback.
|
||||
@@ -315,9 +324,14 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'em-dash-overuse',
|
||||
category: 'slop',
|
||||
// Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
|
||||
// rather than a failure. It fires only on the AI saturation pattern, not on
|
||||
// ordinary prose. Advisory findings are surfaced separately, never counted
|
||||
// as failures, and skipped by the design hook unless a project opts in.
|
||||
advisory: true,
|
||||
name: 'Em-dash overuse',
|
||||
description:
|
||||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||||
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
|
||||
skillSection: 'Copy',
|
||||
skillGuideline: 'no em dashes',
|
||||
},
|
||||
@@ -507,6 +521,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',
|
||||
@@ -3367,6 +3389,33 @@ function checkNumberedSectionLabelsDOM() {
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
// Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
|
||||
// Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
|
||||
// two gates (absolute floor + density) so a long article using a few dashes is
|
||||
// left alone while a short, dash-per-clause page is flagged. Operates on
|
||||
// already-rendered text, so no HTML-entity decoding is needed (the browser has
|
||||
// resolved `—` to the literal glyph). Exported for jsdom unit tests.
|
||||
function checkEmDashOveruse(text) {
|
||||
const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
|
||||
let count = 0;
|
||||
const re = /[—]|--(?=\S)/g;
|
||||
while (re.exec(body) !== null) count++;
|
||||
if (count < EM_DASH_FLOOR) return [];
|
||||
if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
|
||||
return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
|
||||
}
|
||||
|
||||
function checkEmDashOveruseDOM() {
|
||||
const body = document.body;
|
||||
if (!body) return [];
|
||||
// innerText reflects rendered, visible text; fall back to textContent for
|
||||
// engines (jsdom) that don't compute innerText.
|
||||
const text = typeof body.innerText === 'string' && body.innerText
|
||||
? body.innerText
|
||||
: (body.textContent || '');
|
||||
return checkEmDashOveruse(text);
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -3594,6 +3643,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
|
||||
@@ -3878,6 +3948,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)) {
|
||||
@@ -7066,6 +7190,10 @@ if (IS_BROWSER) {
|
||||
type: f.type || f.id,
|
||||
category: ap ? ap.category : 'quality',
|
||||
severity: f.severity || ap?.severity || 'warning',
|
||||
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
||||
// treated as failures; carry the flag so the overlay/extension can
|
||||
// render them with the mildest affordance and consumers can filter.
|
||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
||||
detail: f.detail || f.snippet,
|
||||
ignoreValue: f.ignoreValue || f.value || '',
|
||||
name: ap ? ap.name : (f.type || f.id),
|
||||
@@ -7384,6 +7512,17 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
||||
// Reads rendered body text so it catches dashes written as HTML entities.
|
||||
// serializeFindings stamps the advisory flag from the registry.
|
||||
const emDashFindings = checkEmDashOveruseDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (emDashFindings.length > 0) {
|
||||
pageLevelFindings.push(...emDashFindings);
|
||||
addBrowserFindings(groupMap, document.body, emDashFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
Reference in New Issue
Block a user