Release prep: CLI v3.3.1

Bump the npm package and regenerate the browser detector bundle with
the advisory tier, entity-aware em-dash counting, and the
undersized-ui-text rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-22 13:13:40 -07:00
co-authored by Claude Fable 5
parent 087983070b
commit 3e233d22d7
2 changed files with 141 additions and 2 deletions
+140 -1
View File
@@ -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 `&mdash;` 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;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "impeccable",
"version": "3.3.0",
"version": "3.3.1",
"author": "Paul Bakaus",
"description": "Design skills, commands, and anti-pattern detection for AI coding agents",
"keywords": [