Make cramped-padding rule asymmetric and proportional to font-size

The old rule used a fixed 8px floor on minPad, which produced false
positives on small inline pills (like the homepage's .detection-cmd
at 6px vertical / 14px horizontal on 13px font) and false negatives
on large text (a 24px heading with 8px padding all around passed
the floor but is genuinely too tight for the text size).

The new rule uses two independent axis thresholds that scale with
font-size:

  vertical:   max(4px, fontSize × 0.3)
  horizontal: max(8px, fontSize × 0.5)

The asymmetry reflects typographic reality: line-height already
provides built-in vertical breathing room (the line box is taller
than the cap height), so vertical padding can be tighter than
horizontal. Both thresholds scale with font-size — bigger text
demands proportionally more padding.

Behavior changes
- Small inline pills with line-height-aware padding now pass
  (.detection-cmd: V 6 ≥ 4, H 14 ≥ 8). The homepage CSS is unchanged.
- Cramped large text now flags (24px heading with 8px padding fails
  H 8 < 12). The old rule missed this entirely.
- All original 8px-floor flag cases still flag — 4px on 14px text
  is still 4 < 4.2 vertical, 2px is still cramped, etc.
- Snippet now indicates which axis failed and the specific threshold
  for the font-size: "6px vertical padding (need ≥4.8px for 16px text)"
  instead of the old "6px padding (need >=8px)".

Fixture
- tests/fixtures/antipatterns/cramped-padding.html is a new
  comprehensive side-by-side fixture with 8 flag cases and 12 pass
  cases spanning small pills, cards, code blocks, interactive
  elements, and big text. Replaces the prior 3-case version.

Test
- tests/detect-antipatterns-browser.test.mjs asserts exactly 8
  cramped-padding findings with detailed comments listing each
  expected case and which axis fails.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-07 00:26:44 -07:00
co-authored by Claude Opus 4.6
parent 092e4a9e4f
commit 3569085cea
4 changed files with 210 additions and 117 deletions
+22 -10
View File
@@ -1052,6 +1052,12 @@ function checkQuality(opts) {
}
// --- Cramped padding --- (browser-only: needs rect to skip small badges/labels)
// Vertical and horizontal thresholds are independent because line-height
// already provides built-in vertical breathing room (the line box is taller
// than the cap height), but horizontal has no equivalent. Both scale with
// font-size — bigger text demands proportionally more padding.
// vertical: max(4px, fontSize × 0.3)
// horizontal: max(8px, fontSize × 0.5)
if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
const borders = {
top: parseFloat(style.borderTopWidth) || 0,
@@ -1062,16 +1068,22 @@ function checkQuality(opts) {
const borderCount = Object.values(borders).filter(w => w > 0).length;
const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
if (borderCount >= 2 || hasBg) {
const paddings = [];
if (hasBg || borders.top > 0) paddings.push(parseFloat(style.paddingTop) || 0);
if (hasBg || borders.right > 0) paddings.push(parseFloat(style.paddingRight) || 0);
if (hasBg || borders.bottom > 0) paddings.push(parseFloat(style.paddingBottom) || 0);
if (hasBg || borders.left > 0) paddings.push(parseFloat(style.paddingLeft) || 0);
if (paddings.length > 0) {
const minPad = Math.min(...paddings);
if (minPad < 8) {
findings.push({ id: 'cramped-padding', snippet: `${minPad}px padding (need >=8px)` });
}
const vPads = [], hPads = [];
if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
if (hasBg || borders.bottom > 0) vPads.push(parseFloat(style.paddingBottom) || 0);
if (hasBg || borders.left > 0) hPads.push(parseFloat(style.paddingLeft) || 0);
if (hasBg || borders.right > 0) hPads.push(parseFloat(style.paddingRight) || 0);
const vMin = vPads.length ? Math.min(...vPads) : Infinity;
const hMin = hPads.length ? Math.min(...hPads) : Infinity;
const vThresh = Math.max(4, fontSize * 0.3);
const hThresh = Math.max(8, fontSize * 0.5);
// Emit at most one finding per element — pick whichever axis is worse.
if (vMin < vThresh) {
findings.push({ id: 'cramped-padding', snippet: `${vMin}px vertical padding (need ≥${vThresh.toFixed(1)}px for ${fontSize}px text)` });
} else if (hMin < hThresh) {
findings.push({ id: 'cramped-padding', snippet: `${hMin}px horizontal padding (need ≥${hThresh.toFixed(1)}px for ${fontSize}px text)` });
}
}
}
+22 -10
View File
@@ -1047,6 +1047,12 @@ function checkQuality(opts) {
}
// --- Cramped padding --- (browser-only: needs rect to skip small badges/labels)
// Vertical and horizontal thresholds are independent because line-height
// already provides built-in vertical breathing room (the line box is taller
// than the cap height), but horizontal has no equivalent. Both scale with
// font-size — bigger text demands proportionally more padding.
// vertical: max(4px, fontSize × 0.3)
// horizontal: max(8px, fontSize × 0.5)
if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
const borders = {
top: parseFloat(style.borderTopWidth) || 0,
@@ -1057,16 +1063,22 @@ function checkQuality(opts) {
const borderCount = Object.values(borders).filter(w => w > 0).length;
const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
if (borderCount >= 2 || hasBg) {
const paddings = [];
if (hasBg || borders.top > 0) paddings.push(parseFloat(style.paddingTop) || 0);
if (hasBg || borders.right > 0) paddings.push(parseFloat(style.paddingRight) || 0);
if (hasBg || borders.bottom > 0) paddings.push(parseFloat(style.paddingBottom) || 0);
if (hasBg || borders.left > 0) paddings.push(parseFloat(style.paddingLeft) || 0);
if (paddings.length > 0) {
const minPad = Math.min(...paddings);
if (minPad < 8) {
findings.push({ id: 'cramped-padding', snippet: `${minPad}px padding (need >=8px)` });
}
const vPads = [], hPads = [];
if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
if (hasBg || borders.bottom > 0) vPads.push(parseFloat(style.paddingBottom) || 0);
if (hasBg || borders.left > 0) hPads.push(parseFloat(style.paddingLeft) || 0);
if (hasBg || borders.right > 0) hPads.push(parseFloat(style.paddingRight) || 0);
const vMin = vPads.length ? Math.min(...vPads) : Infinity;
const hMin = hPads.length ? Math.min(...hPads) : Infinity;
const vThresh = Math.max(4, fontSize * 0.3);
const hThresh = Math.max(8, fontSize * 0.5);
// Emit at most one finding per element — pick whichever axis is worse.
if (vMin < vThresh) {
findings.push({ id: 'cramped-padding', snippet: `${vMin}px vertical padding (need ≥${vThresh.toFixed(1)}px for ${fontSize}px text)` });
} else if (hMin < hThresh) {
findings.push({ id: 'cramped-padding', snippet: `${hMin}px horizontal padding (need ≥${hThresh.toFixed(1)}px for ${fontSize}px text)` });
}
}
}
+14 -6
View File
@@ -74,14 +74,22 @@ describe('detectUrl — browser-only fixtures', () => {
// Everything else in the quality.html fixture runs in jsdom and is asserted
// by tests/detect-antipatterns-fixtures.test.mjs.
it('cramped-padding: flag column triggers, small-pill case is currently a known false positive', async () => {
it('cramped-padding: flag column triggers all 8 cramped cases, pass column adds none', async () => {
const f = await detectUrl(`${BASE}/fixtures/antipatterns/cramped-padding.html`);
const cramped = f.filter(r => r.antipattern === 'cramped-padding');
// Flag column: 2 obvious cramped containers (4px and 2px padding).
// Pass column: 1 finding from the .detection-cmd-style small pill —
// currently a false positive that the user is deciding what to do with.
// Total = 3. When the rule is relaxed for small inline pills, expect 2.
assert.equal(cramped.length, 3, `expected 3 cramped-padding findings (2 flag + 1 disputed pill), got ${cramped.length}`);
// Flag column has 8 cases that should fire under the asymmetric
// proportional rule (vertical: max(4, fs×0.3), horizontal: max(8, fs×0.5)):
// 1. 14px body / 4px all sides — V fail
// 2. 14px body / 2px all sides — both fail
// 3. 16px body / 4px all sides — both fail
// 4. 14px body / 1px V / 16px H — V fail
// 5. 14px body / 12px V / 4px H — H fail
// 6. 24px heading / 8px all sides — H fail (improvement over old 8px floor)
// 7. 32px hero / 6px V / 16px H — V fail
// 8. 14px <pre> / 2px all sides — both fail
// Pass column has 12 cases (small pills, standard cards, code blocks,
// buttons, inputs, big text with proportional padding) — none should fire.
assert.equal(cramped.length, 8, `expected 8 cramped-padding findings, got ${cramped.length}`);
});
it('line-length: flag column triggers, pass column adds none', async () => {
+152 -91
View File
@@ -5,96 +5,84 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cramped Padding — Should Flag vs Should Pass</title>
<style>
/* Two-column fixture: left = should flag, right = should pass.
Focused on the cramped-padding rule alone so we can think about
its threshold and edge cases without other rules interfering. */
body { font-family: system-ui, sans-serif; background: #fafafa; padding: 24px; margin: 0; color: #0f172a; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; max-width: 1200px; margin: 0 auto; }
/* Comprehensive fixture for the cramped-padding rule.
Both columns include a wide variety of cases so we can verify the
rule scales sensibly with font-size and treats the two axes
independently (vertical can be tighter because line-height already
provides built-in breathing room; horizontal has no equivalent). */
body { font-family: system-ui, sans-serif; background: #fafafa; padding: 24px; margin: 0; color: #0f172a; line-height: 1.5; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; max-width: 1400px; margin: 0 auto; }
.col h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 16px; color: #475569; }
.col h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin: 24px 0 8px; color: #64748b; }
.case { margin-bottom: 12px; }
.case-label { display: block; font-size: 11px; color: #64748b; margin-bottom: 4px; }
.col h3 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; margin: 28px 0 10px; color: #64748b; }
.case { margin-bottom: 14px; padding: 14px 16px; background: white; border: 1px solid #e2e8f0; border-radius: 10px; }
.case-label { display: block; font-size: 12px; color: #64748b; margin-bottom: 8px; font-style: italic; }
/* ── FLAG: clearly cramped ── */
/* ── FLAG: cramped body in cards ── */
.flag-card-4 { padding: 4px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; }
.flag-card-2 { padding: 2px; background: #f1f5f9; border-radius: 6px; font-size: 14px; }
.flag-card-bg4 { padding: 4px; background: #fef3c7; border-radius: 6px; font-size: 16px; }
/* 4px padding on a bordered container with body-length text */
.cramped-border {
border: 1px solid #d1d5db;
padding: 4px;
border-radius: 4px;
font-size: 14px;
}
/* ── FLAG: asymmetric cramping ── */
.flag-asym-v { padding: 1px 16px; background: #f1f5f9; border-radius: 6px; font-size: 14px; }
.flag-asym-h { padding: 12px 4px; background: #f1f5f9; border-radius: 6px; font-size: 14px; }
/* 2px padding on a colored container */
.cramped-bg {
background: #e5e7eb;
padding: 2px 4px;
border-radius: 4px;
font-size: 14px;
}
/* ── FLAG: cramped large text (currently MISSED by the 8px floor) ── */
.flag-heading-tight { padding: 8px; background: #f1f5f9; border-radius: 6px; font-size: 24px; font-weight: 600; line-height: 1.2; }
.flag-hero-tight { padding: 6px 16px; background: #f1f5f9; border-radius: 6px; font-size: 32px; font-weight: 700; line-height: 1.2; }
/* Zero padding on a bordered container — text touches the border */
.cramped-zero {
border: 2px solid #3b82f6;
padding: 0;
font-size: 14px;
}
/* ── FLAG: cramped multi-line code block ── */
.flag-pre-tight { padding: 2px; background: #1e293b; color: #e2e8f0; border-radius: 6px; font-family: ui-monospace, monospace; font-size: 14px; line-height: 1.5; }
/* ── PASS: comfortable padding ── */
.good-padding {
border: 1px solid #d1d5db;
padding: 16px;
border-radius: 8px;
font-size: 14px;
}
.good-padding-bg {
background: #f1f5f9;
padding: 14px 18px;
border-radius: 8px;
font-size: 14px;
}
/* Small inline command pill — exact replica of .detection-cmd on
the impeccable.style homepage. The detector currently flags 6px
vertical padding as "cramped", but visually this is a small
inline badge where the generous 14px horizontal padding plus
tight font-size makes 6px vertical look balanced. Under
consideration: should the rule skip small inline pills, or
relax for small font sizes? */
.small-pill {
display: inline-block;
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 13px;
line-height: 1.625; /* matches inherited body line-height on homepage */
color: #0f172a;
background: #f5f5f7;
padding: 6px 14px;
border-radius: 6px;
white-space: nowrap;
}
.small-pill::before {
content: '➜ ';
color: #ec4899;
}
/* Same pill at the 8px threshold — should pass cleanly */
.small-pill-8 {
/* ── PASS: small inline pills (the .detection-cmd category) ── */
.pass-cmd {
display: inline-block;
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 13px;
line-height: 1.625;
color: #0f172a;
background: #f5f5f7;
padding: 8px 14px;
padding: 6px 14px;
border-radius: 6px;
white-space: nowrap;
}
.small-pill-8::before {
content: '➜ ';
color: #ec4899;
.pass-cmd::before { content: '➜ '; color: #ec4899; }
.pass-badge {
display: inline-block;
font-size: 11px;
line-height: 1.6;
padding: 4px 8px;
background: #dbeafe;
color: #1e40af;
border-radius: 999px;
font-weight: 600;
}
.pass-chip {
display: inline-block;
font-size: 12px;
line-height: 1.6;
padding: 4px 10px;
background: #fef3c7;
color: #92400e;
border-radius: 4px;
font-weight: 500;
}
/* ── PASS: cards at current standards ── */
.pass-card-min { padding: 8px; background: #f1f5f9; border-radius: 6px; font-size: 16px; line-height: 1.6; }
.pass-card-good { padding: 16px; background: #f1f5f9; border-radius: 8px; font-size: 14px; line-height: 1.6; }
.pass-card-large { padding: 12px; background: #f1f5f9; border-radius: 8px; font-size: 18px; line-height: 1.6; }
/* ── PASS: comfortable code blocks ── */
.pass-pre-min { padding: 8px; background: #1e293b; color: #e2e8f0; border-radius: 6px; font-family: ui-monospace, monospace; font-size: 14px; line-height: 1.5; }
.pass-pre-good { padding: 12px; background: #1e293b; color: #e2e8f0; border-radius: 6px; font-family: ui-monospace, monospace; font-size: 14px; line-height: 1.5; }
/* ── PASS: interactive elements ── */
.pass-button-std { padding: 8px 16px; background: #3b82f6; color: white; border: 0; border-radius: 6px; font-size: 14px; cursor: pointer; }
.pass-input-std { padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 16px; width: 100%; box-sizing: border-box; }
/* ── PASS: big text with proportional padding ── */
.pass-heading-good { padding: 16px; background: #f1f5f9; border-radius: 8px; font-size: 24px; font-weight: 600; line-height: 1.2; }
.pass-hero-good { padding: 20px 32px; background: #f1f5f9; border-radius: 8px; font-size: 32px; font-weight: 700; line-height: 1.2; }
</style>
</head>
<body>
@@ -106,18 +94,47 @@
<div class="col" data-col="flag">
<h2>Should flag</h2>
<h3>Bordered containers with too-small padding</h3>
<h3>Cramped body in cards</h3>
<div class="case">
<span class="case-label">4px padding, bordered, body-length text</span>
<div class="cramped-border">This text is crammed against the border with only 4px padding. It feels claustrophobic and hard to read.</div>
<span class="case-label">14px body, 4px padding all sides</span>
<div class="flag-card-4">This text is crammed against the border with only 4px padding. It feels claustrophobic and hard to read because the eye has nowhere to rest.</div>
</div>
<div class="case">
<span class="case-label">2px / 4px padding, colored background</span>
<div class="cramped-bg">Cramped background padding makes the body text feel jammed against the edges of the colored container.</div>
<span class="case-label">14px body, 2px padding all sides</span>
<div class="flag-card-2">Two pixels of padding is essentially nothing — body text needs room to breathe inside its container, especially across multiple lines.</div>
</div>
<div class="case">
<span class="case-label">Zero padding, bordered</span>
<div class="cramped-zero">Zero padding on a bordered element. The text is literally touching the border.</div>
<span class="case-label">16px body, 4px padding, background only</span>
<div class="flag-card-bg4">A colored background containment with only 4px padding around 16px body text. The colored edge feels like it's pressing in.</div>
</div>
<h3>Asymmetric cramping</h3>
<div class="case">
<span class="case-label">1px vertical / 16px horizontal, 14px body</span>
<div class="flag-asym-v">Generous horizontal padding doesn't save you when the vertical padding is just one pixel. Lines stack on top of each other against the container edge.</div>
</div>
<div class="case">
<span class="case-label">12px vertical / 4px horizontal, 14px body</span>
<div class="flag-asym-h">Comfortable vertical padding can't fix horizontal text running right up against the side edges. Words feel like they're falling off the container.</div>
</div>
<h3>Currently missed: cramped large text</h3>
<div class="case">
<span class="case-label">24px heading, 8px padding (passes the 8px floor but is too tight for the text size)</span>
<div class="flag-heading-tight">Tight Heading Container Label</div>
</div>
<div class="case">
<span class="case-label">32px hero, 6px vertical / 16px horizontal padding</span>
<div class="flag-hero-tight">Hero Section With Tight Vertical Padding</div>
</div>
<h3>Cramped multi-line code block</h3>
<div class="case">
<span class="case-label">&lt;pre&gt;, 2px padding, 14px monospace</span>
<pre class="flag-pre-tight">function example() {
const value = "cramped";
return value;
}</pre>
</div>
</div>
@@ -127,24 +144,68 @@
<div class="col" data-col="pass">
<h2>Should pass</h2>
<h3>Comfortable container padding</h3>
<h3>Small inline pills</h3>
<div class="case">
<span class="case-label">16px padding, bordered</span>
<div class="good-padding">This container has 16px padding, giving the text room to breathe within its border.</div>
<span class="case-label">.detection-cmd replica: 13px font, 6px / 14px padding (the homepage case)</span>
<code class="pass-cmd">npx impeccable detect src/</code>
</div>
<div class="case">
<span class="case-label">14px / 18px padding, colored background</span>
<div class="good-padding-bg">Generous padding inside the colored container — text has plenty of room from the edge.</div>
<span class="case-label">small badge: 11px font, 4px / 8px padding</span>
<span class="pass-badge">FEATURED ITEM BADGE</span>
</div>
<div class="case">
<span class="case-label">tag chip: 12px font, 4px / 10px padding</span>
<span class="pass-chip">design system tag chip</span>
</div>
<h3>Small inline pills (under consideration)</h3>
<h3>Cards at current standards</h3>
<div class="case">
<span class="case-label">6px / 14px small pill (matches .detection-cmd on impeccable.style)</span>
<code class="small-pill">npx impeccable detect src/</code>
<span class="case-label">16px body, 8px padding (the original 8px floor)</span>
<div class="pass-card-min">Standard card with 8px padding around 16px body text. Comfortable and within current conventions.</div>
</div>
<div class="case">
<span class="case-label">8px / 14px small pill (clears the rule's current 8px floor)</span>
<code class="small-pill-8">npx impeccable detect src/</code>
<span class="case-label">14px body, 16px padding (generous)</span>
<div class="pass-card-good">Generous 16px padding around 14px body text. The eye has plenty of room from the container edge.</div>
</div>
<div class="case">
<span class="case-label">18px body, 12px padding (proportional)</span>
<div class="pass-card-large">Larger body text gets proportionally more padding. 12px around 18px text feels balanced.</div>
</div>
<h3>Comfortable code blocks</h3>
<div class="case">
<span class="case-label">multi-line &lt;pre&gt;, 8px padding, 14px font</span>
<pre class="pass-pre-min">function example() {
const value = "ok";
return value;
}</pre>
</div>
<div class="case">
<span class="case-label">multi-line &lt;pre&gt;, 12px padding, 14px font</span>
<pre class="pass-pre-good">function example() {
const value = "comfortable";
return value;
}</pre>
</div>
<h3>Interactive elements</h3>
<div class="case">
<span class="case-label">button: 14px font, 8px / 16px padding</span>
<button class="pass-button-std">Standard sized button</button>
</div>
<div class="case">
<span class="case-label">form input: 16px font, 8px / 12px padding</span>
<input type="text" class="pass-input-std" value="Standard input with adequate padding">
</div>
<h3>Big text with proportional padding</h3>
<div class="case">
<span class="case-label">24px heading, 16px padding</span>
<div class="pass-heading-good">Heading With Room To Breathe</div>
</div>
<div class="case">
<span class="case-label">32px hero, 20px / 32px padding</span>
<div class="pass-hero-good">Hero With Big Padding</div>
</div>
</div>
</div>