Files
pbakaus_impeccable/tests/detect-antipatterns-fixtures.test.mjs
T
Paul BakausandClaude Fable 5 7a99e1725d detector: four human-review rules — nav-CTA oklch contrast, numbered section labels, floating side-tab stripes, repeated card text
Four gaps found shipping in Opus 4.8 eval samples during human review:

1. low-contrast (extended): the browser adapters parsed text/own-bg
   colors with parseRgb only, so Chrome's oklch()-serialized computed
   colors silently skipped every contrast check — a flat dark-on-dark
   nav CTA (broader nav selector beating the button class) shipped at
   1.5:1 undetected. checkElementColorsDOM and readOwnBackgroundColor
   now fall back to parseAnyColor. Near-threshold ratios print two
   decimals so a 4.497 finding no longer reads "4.5 needs 4.5".

2. NEW numbered-section-labels (slop, advisory): tiny (<=13px) styled
   numeric index labels riding beside section headings, repeated across
   2+ sections with distinct indices. Sibling of repeated-section-kickers
   (which deliberately excludes bare numeric labels); handles both the
   direct prev-sibling shape and label-before-heading-wrapper shape.
   List/nav/table/card-item numbering is exempt.

3. side-tab (extended): the vertical pseudo-element stripe scan required
   the stripe to touch both corners (top/bottom 0 or height 100%), so a
   left accent bar inset a few px from each end evaded it; small end
   insets (<=20px each) now count. Added a browser-side pseudo-element
   check (getComputedStyle(el, '::before'/'::after')) since runtime-
   assigned custom-property colors are invisible to the text scanner.
   Selection-state exemptions stay as narrowed: only aria-selected=true /
   aria-current / active-class markers exempt, plus button/link
   affordances on the horizontal variant.

4. NEW repeated-container-text (quality): the same literal string (>=4
   chars, contains letters) rendered 3+ times at 3+ structurally distinct
   positions inside one bordered/elevated container. Parallel/templated
   repetition (table cells, calendar grids, nav lists, identical sibling
   rows) never counts — structural signatures, not word lists.

Verified: each rule fires on its repro sample via the file:// browser
scan; clean eval samples add no new findings (the new low-contrast hits
on other samples are genuine sub-AA oklch button pairs). Full test
suite green; browser bundle regenerated; README/homepage rule counts
bumped 49 -> 51 (docs-integrity test enforces them).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:09:54 -07:00

834 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Static HTML/CSS fixture tests for anti-pattern detection.
* Run via Node's built-in test runner (not bun).
*
* Usage: node --test tests/detect-antipatterns-fixtures.test.mjs
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import path from 'path';
import { fileURLToPath } from 'url';
import {
detectHtml,
detectText,
normalizeDesignSystem,
} from '../cli/engine/detect-antipatterns.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURES = path.join(__dirname, 'fixtures', 'antipatterns');
describe('detectHtml — static HTML/CSS fixtures', () => {
it('should-flag: catches border anti-patterns', async () => {
const f = await detectHtml(path.join(FIXTURES, 'should-flag.html'));
assert.ok(f.some(r => r.antipattern === 'side-tab'));
assert.ok(f.some(r => r.antipattern === 'border-accent-on-rounded'));
});
it('should-pass: zero border findings', async () => {
const f = await detectHtml(path.join(FIXTURES, 'should-pass.html'));
assert.equal(f.filter(r => r.antipattern === 'side-tab' || r.antipattern === 'border-accent-on-rounded').length, 0);
});
it('border-baseline: paired side-tab fixture flags only the positive column', async () => {
const f = await detectHtml(path.join(FIXTURES, 'border-baseline.html'));
const sideTabs = f.filter(r => r.antipattern === 'side-tab');
const accents = f.filter(r => r.antipattern === 'border-accent-on-rounded');
assert.equal(
sideTabs.length,
6,
`expected 6 side-tab findings, got ${sideTabs.length}: ${sideTabs.map(r => r.snippet).join('; ')}`
);
assert.equal(
accents.length,
2,
`expected 2 rounded accent findings, got ${accents.length}: ${accents.map(r => r.snippet).join('; ')}`
);
});
it('linked-stylesheet: catches borders, no false positives', async () => {
const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html'));
assert.ok(f.some(r => r.antipattern === 'side-tab'));
assert.ok(f.some(r => r.antipattern === 'border-accent-on-rounded'));
assert.equal(f.filter(r => r.snippet?.includes('clean')).length, 0);
assert.equal(
f.filter(r => r.antipattern !== 'side-tab' && r.antipattern !== 'border-accent-on-rounded').length,
0,
`expected only border findings, got: ${f.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`
);
});
it('partial-component: flags borders, skips page-level', async () => {
const f = await detectHtml(path.join(FIXTURES, 'partial-component.html'));
assert.ok(f.some(r => r.antipattern === 'side-tab'));
assert.equal(f.filter(r => r.antipattern === 'flat-type-hierarchy').length, 0);
});
it('color: flag column triggers all color rules, pass column adds none', async () => {
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
// pure-black-white was removed from the skill in v3.2; only the remaining rules
// are expected to fire from the flag column.
assert.ok(f.some(r => r.antipattern === 'gray-on-color'), 'expected gray-on-color');
assert.ok(f.some(r => r.antipattern === 'low-contrast'), 'expected low-contrast');
assert.ok(f.some(r => r.antipattern === 'gradient-text'), 'expected gradient-text');
assert.ok(f.some(r => r.antipattern === 'ai-color-palette'), 'expected ai-color-palette');
assert.equal(
f.some(r => r.antipattern === 'pure-black-white'),
false,
'pure-black-white detector was removed in v3.2',
);
// Gradient-bg + gray text case (added with the gradient-fix patch)
assert.ok(
f.some(r => r.antipattern === 'low-contrast' && /#808080|#3b82f6|#8b5cf6/i.test(r.snippet || '')),
'expected low-contrast finding for gray heading on blue/purple gradient',
);
assert.ok(
f.some(r => r.antipattern === 'gray-on-color' && /gradient/i.test(r.snippet || '')),
'expected gray-on-color finding referencing gradient',
);
});
it('color: white text on background-image url() ancestor is not flagged as low-contrast', async () => {
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
// The pass column has white text on a div with background-image: url().
// The detector can't know the image color, so it must not assume the body
// bg and report a false low-contrast finding (#ffffff on #fafafa).
const falsePositive = f.filter(r =>
r.antipattern === 'low-contrast' &&
/#ffffff on #fafafa/i.test(r.snippet || '')
);
assert.equal(
falsePositive.length, 0,
`expected no low-contrast from bg-image ancestor, got: ${falsePositive.map(r => r.snippet).join('; ')}`
);
});
it('color: styled <a> and <button> with their own background get contrast checks', async () => {
// SAFE_TAGS skips <a> and <button> by default to avoid noise on inline links
// (text links inside paragraphs). When these elements are styled as buttons
// (own opaque background, padding, direct text), the contrast check must run.
// Mirrors a real bug from the landing-demo: a pill-style <a> with
// warm-charcoal text on near-black bg, ~2:1 contrast, was missed by both
// the CLI and browser overlay paths because <a> was categorically skipped.
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
const pillBtnFlag = f.some(r =>
r.antipattern === 'low-contrast' &&
/#5b4f44/i.test(r.snippet || '') &&
/#1f1a15/i.test(r.snippet || '')
);
assert.ok(pillBtnFlag, 'expected low-contrast finding for styled <a> pill button');
const styledButtonFlag = f.some(r =>
r.antipattern === 'low-contrast' &&
/#6c7280/i.test(r.snippet || '') &&
/#374151/i.test(r.snippet || '')
);
assert.ok(styledButtonFlag, 'expected low-contrast finding for styled <button>');
});
it('color: inline <a> without own background remains skipped (no regression)', async () => {
// The exception for styled buttons must not regress to flagging plain
// inline text links — those would create noise on essentially every
// page on the web.
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
const inlineLinkFalsePositive = f.some(r =>
r.antipattern === 'low-contrast' &&
/#aaaaaa/i.test(r.snippet || '')
);
assert.equal(
inlineLinkFalsePositive, false,
'inline <a> without own background must remain skipped'
);
});
it('color: styled <a> with good contrast does not flag', async () => {
// The detector exception must let the check run, but a properly contrasted
// styled button must obviously pass.
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
const goodPillFalsePositive = f.some(r =>
r.antipattern === 'low-contrast' &&
/#f5f0e8/i.test(r.snippet || '') &&
/#141419/i.test(r.snippet || '')
);
assert.equal(
goodPillFalsePositive, false,
'styled <a> with high contrast must not flag'
);
});
it('color: emoji-only text is never flagged as low-contrast', async () => {
// Emojis render as multicolor glyphs regardless of CSS `color`, so the
// CSS text color is irrelevant for contrast. The fixture's emoji cards
// intentionally set text color to match the bg (which would trip the
// rule for any other text). The detector must skip emoji-only nodes.
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
const emojiCardColorPairs = ['#ffe4e6 on #ffe4e6', '#1a1a1a on #1a1a1a'];
const matches = f.filter(r =>
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
emojiCardColorPairs.some(pair => (r.snippet || '').includes(pair))
);
assert.equal(
matches.length, 0,
`expected no contrast findings on emoji-only text, got: ${matches.map(r => r.snippet).join('; ')}`
);
});
it('legitimate-borders: zero findings', async () => {
const f = await detectHtml(path.join(FIXTURES, 'legitimate-borders.html'));
assert.equal(f.length, 0, `expected no findings, got: ${f.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`);
});
it('modern-color-borders: oklch/oklab/lch/lab side-tabs are flagged, neutrals pass', async () => {
// Regression for the isNeutralColor bug where any non-rgb() color format
// (oklch, oklab, lch, lab — which jsdom does NOT normalize to rgb) was
// misclassified as neutral, causing checkBorders() to silently skip
// every element with a modern-color side border.
//
// Also regression for the SAFE_TAGS/label bug: card-shaped <label>
// elements (clickable checklist rows with padding + radius + colored
// side border) used to be silently skipped because checkBorders'
// SAFE_TAGS gate excluded <label>. The fix narrows that gate so card-
// shaped labels are checked while plain inline form labels still pass.
const f = await detectHtml(path.join(FIXTURES, 'modern-color-borders.html'));
const sideTabs = f.filter(r => r.antipattern === 'side-tab');
// Twelve FLAG cases: oklch x3, oklab, lch, lab — all colored border-left
// with a non-zero border-radius — plus two card-shaped <label> cases
// (one oklch, one rgb), plus four var()-based cases (shorthand, mixed
// neutral+colored, border-right, and a card-shaped <label>). Each must
// produce exactly one side-tab.
assert.equal(
sideTabs.length, 12,
`expected 12 side-tab findings from the FLAG column, got ${sideTabs.length}: ${sideTabs.map(r => r.snippet).join('; ')}`
);
// Eleven findings must be border-left; exactly one is border-right
// (the #flag-var-right case). The fixture doesn't decorate top/bottom
// on any flag element.
const leftFindings = sideTabs.filter(r => /border-left/.test(r.snippet || ''));
const rightFindings = sideTabs.filter(r => /border-right/.test(r.snippet || ''));
assert.equal(leftFindings.length, 11, `expected 11 border-left findings, got ${leftFindings.length}`);
assert.equal(rightFindings.length, 1, `expected 1 border-right finding, got ${rightFindings.length}`);
// PASS column must contribute zero border findings of either flavor.
// There are 14 pass cases: 7 structural neutrals plus 4 labels (plain
// inline form label, label with a neutral gray border, label in a form
// row, and a label with a thin 1px colored left border), plus 3 var()
// pass cases (neutral-resolving var, thin var, uniform all-sides var).
// If any leaks through, the label exception or var() fallback is
// over-broad.
const borderAccent = f.filter(r => r.antipattern === 'border-accent-on-rounded');
assert.equal(
borderAccent.length, 0,
`expected 0 border-accent-on-rounded, got ${borderAccent.length}: ${borderAccent.map(r => r.snippet).join('; ')}`
);
});
it('modern-color-borders: regex fallback skips neutral 1px oklch dividers', () => {
const css = `
.flag-side-tab {
border-radius: 8px;
border-left: 2px solid oklch(65% 0.12 250);
}
.pass-context-divider {
border-radius: 8px;
border-right: 1px solid oklch(92% 0 0 / 0.12);
}
.pass-neutral-side {
border-radius: 8px;
border-left: 3px solid oklch(80% 0 0);
}
`;
const f = detectText(css, path.join(FIXTURES, 'modern-color-borders-regex.css'));
const sideTabs = f.filter(r => r.antipattern === 'side-tab');
assert.equal(
sideTabs.length,
1,
`expected only the colored 2px side-tab to flag, got: ${sideTabs.map(r => r.snippet).join('; ')}`
);
assert.match(sideTabs[0].snippet, /border-left: 2px solid oklch/);
});
it('typography-should-flag: detects all three issues', async () => {
const f = await detectHtml(path.join(FIXTURES, 'typography-should-flag.html'));
assert.ok(f.some(r => r.antipattern === 'overused-font'));
assert.ok(f.some(r => r.antipattern === 'single-font'));
assert.ok(f.some(r => r.antipattern === 'flat-type-hierarchy'));
assert.equal(
f.some(r => r.antipattern === 'low-contrast'),
false,
`typography fixture should not contain incidental contrast findings: ${f.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`
);
});
it('typography: side-by-side page has visible element-level flag cases', async () => {
const f = await detectHtml(path.join(FIXTURES, 'typography.html'));
const ids = new Set(f.map(r => r.antipattern));
for (const id of ['tight-leading', 'tiny-text', 'all-caps-body', 'wide-tracking', 'justified-text']) {
assert.ok(ids.has(id), `expected typography side-by-side fixture to include ${id}`);
}
assert.ok(ids.has('overused-font'), 'expected typography side-by-side fixture to include a page-level overused-font finding');
});
it('typography-should-pass: zero findings', async () => {
const f = await detectHtml(path.join(FIXTURES, 'typography-should-pass.html'));
assert.equal(f.length, 0);
});
it('design-system: flags only values outside the provided DESIGN.md tokens', async () => {
const designSystem = normalizeDesignSystem({
frontmatter: {
typography: {
display: { fontFamily: 'Avenir Next, Georgia, serif', fontSize: 'clamp(2.5rem, 6vw, 4rem)' },
body: { fontFamily: 'IBM Plex Sans, Arial, sans-serif', fontSize: '16px' },
label: { fontFamily: 'IBM Plex Sans, Arial, sans-serif', fontSize: '14px' },
},
colors: {
ink: '#241f1a',
paper: '#f7f4ee',
surface: '#ffffff',
accent: '#b8422e',
border: '#d4c7b9',
},
rounded: {
sm: '4px',
md: '8px',
'"2xl"': '32px',
full: '999px',
},
},
sidecar: {
extensions: {
colorMeta: {
accent: {
canonical: '#b8422e',
tonalRamp: ['#923524', '#d55a42'],
},
},
},
},
});
const f = await detectHtml(path.join(FIXTURES, 'design-system.html'), { designSystem });
const designFindings = f.filter((r) => r.antipattern.startsWith('design-system-'));
const snippets = designFindings.map((r) => r.snippet).join('\n');
assert.ok(designFindings.some((r) => r.antipattern === 'design-system-font'), 'expected unsupported font');
assert.ok(designFindings.some((r) => r.antipattern === 'design-system-color'), 'expected undocumented colors');
assert.ok(designFindings.some((r) => r.antipattern === 'design-system-radius'), 'expected undocumented radius');
assert.ok(
designFindings.some((r) => r.antipattern === 'design-system-font' && /Google Fonts: Poppins/.test(r.snippet || '')),
'expected source-level Google Fonts usage in HTML to be flagged',
);
assert.ok(
designFindings.some((r) => r.antipattern === 'design-system-font-size' && /12\.5px/.test(r.snippet || '')),
'expected off-ramp literal font-size to be flagged',
);
assert.doesNotMatch(snippets, /1rem is off/, 'documented rem step must pass');
assert.doesNotMatch(snippets, /1\.2em is off/, 'relative em sizes are abstained on');
assert.doesNotMatch(snippets, /16px is off|14px is off/, 'on-ramp sizes must pass');
assert.doesNotMatch(snippets, /Undocumented color #ff00aa/, 'source and computed color findings should not duplicate');
assert.doesNotMatch(snippets, /font-family: Poppins/, 'source and computed font findings should not duplicate');
assert.doesNotMatch(snippets, /border-radius: 18px is outside/, 'source and computed radius findings should not duplicate');
assert.doesNotMatch(snippets, /on style "\.design-system-fixture/, 'static DOM design pass should skip <style> content');
assert.equal(
designFindings.find((r) => /Flag Color Hot Pink/.test(r.snippet || ''))?.line,
37,
'deduped HTML design findings should keep the source line when available',
);
assert.equal(
designFindings.find((r) => /Flag Radius Eighteen/.test(r.snippet || ''))?.line,
40,
'deduped radius findings should keep the source line when available',
);
for (const label of [
'Flag Font Unsupported',
'Flag Color Hot Pink',
'Flag Background Cyan',
'Flag Border Teal',
'Flag Radius Eighteen',
]) {
assert.match(snippets, new RegExp(label), `expected ${label} to be flagged`);
}
for (const label of [
'Pass Display Font',
'Pass Rem Font Size',
'Pass Relative Font Size',
'Pass Generic Font',
'Pass Token Color',
'Pass Alpha Color',
'Pass Close Color',
'Pass Ramp Color',
'Pass Zero Radius',
'Pass Percent Radius',
'Pass Scale Radius',
'Pass Pill Radius',
]) {
assert.doesNotMatch(snippets, new RegExp(label), `${label} should pass`);
}
});
it('numbered-section-markers: visible sequence flags while script/style/svg internals pass', async () => {
const f = await detectHtml(path.join(FIXTURES, 'numbered-section-markers.html'));
const numbered = f.filter(r => r.antipattern === 'numbered-section-markers');
assert.equal(
numbered.length,
1,
`expected one visible numbered-marker finding, got: ${numbered.map(r => r.snippet).join('; ')}`
);
assert.match(numbered[0].snippet, /01, 02, 03/);
});
it('numbered-section-labels: tiny repeated index labels flag, deliberate/list/card numbering passes', async () => {
const f = await detectHtml(path.join(FIXTURES, 'numbered-section-labels.html'));
const labels = f.filter(r => r.antipattern === 'numbered-section-labels');
const snippets = labels.map(r => r.snippet).join(' | ');
assert.equal(
labels.length,
4,
`expected 4 numbered-label findings, got ${labels.length}: ${snippets}`
);
for (const heading of ['Alpha ships first', 'Beta earns trust', 'Gamma holds the line', 'Delta closes the loop']) {
assert.match(snippets, new RegExp(heading), `expected label beside "${heading}" to flag`);
}
for (const heading of ['Epsilon', 'Zeta', 'Eta', 'Theta', 'Iota', 'Kappa', 'Lambda', 'Mu']) {
assert.doesNotMatch(snippets, new RegExp(heading), `label beside "${heading}" should pass`);
}
});
it('repeated-container-text: same string in 3+ distinct slots of one card flags; structural repetition passes', async () => {
const f = await detectHtml(path.join(FIXTURES, 'repeated-container-text.html'));
const repeats = f.filter(r => r.antipattern === 'repeated-container-text');
const snippets = repeats.map(r => r.snippet).join(' | ');
assert.equal(
repeats.length,
2,
`expected 2 repeated-text findings, got ${repeats.length}: ${snippets}`
);
assert.match(snippets, /Suspended.*3×|Suspended" rendered 3/, 'expected the 3-slot status word to flag');
assert.match(snippets, /Unavailable" rendered 4/, 'expected the 4-slot status word to flag');
for (const passText of ['Rolled back', 'On schedule', 'Overview page', 'Standby mode', 'Open slot', 'Rescheduled', '2026']) {
assert.doesNotMatch(snippets, new RegExp(passText), `"${passText}" should pass`);
}
});
});
describe('detectHtml — icon-tile-stack', () => {
// Two-column fixture convention: left col = should-flag, right col = should-pass.
// The rule's snippet embeds the heading text in quotes, e.g.
// "80x80px icon tile above h3 \"Lightning Fast\"".
// The test extracts those quoted texts and matches them against the
// expected lists below.
const SHOULD_FLAG = [
'Lightning Fast',
'Secure Storage',
'Easy Setup',
'Powerful Analytics',
'Emoji Inline Icon',
];
const SHOULD_PASS = [
'Sarah Chen',
'Article Headline',
'Inline Side By Side',
'Plain Heading No Icon',
'Tiny Icon Above Me',
'Huge Hero Image',
];
it('icon-tile-stack: flags only the should-flag column', async () => {
const f = await detectHtml(path.join(FIXTURES, 'icon-tile-stack.html'));
const flagged = new Set();
for (const r of f) {
if (r.antipattern !== 'icon-tile-stack') continue;
const m = (r.snippet || '').match(/"([^"]+)"/);
if (m) flagged.add(m[1]);
}
for (const text of SHOULD_FLAG) {
assert.ok(flagged.has(text), `expected "${text}" to be flagged as icon-tile-stack`);
}
for (const text of SHOULD_PASS) {
assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as icon-tile-stack`);
}
});
});
describe('detectHtml — quality (static-compatible rules)', () => {
// Six of the eight quality rules can run in static HTML/CSS because they only need
// computed CSS values (tight-leading, tiny-text, justified-text,
// all-caps-body, wide-tracking) or pure DOM walks (skipped-heading).
// The other two (line-length, cramped-padding) need real layout rects and
// live in tests/detect-antipatterns-browser.test.mjs (Puppeteer-backed).
it('quality: flag column triggers all 6 static-compatible quality rules', async () => {
const f = await detectHtml(path.join(FIXTURES, 'quality.html'));
assert.equal(f.filter(r => r.antipattern === 'tight-leading').length, 1);
assert.equal(f.filter(r => r.antipattern === 'tiny-text').length, 1);
assert.equal(f.filter(r => r.antipattern === 'justified-text').length, 1);
assert.equal(f.filter(r => r.antipattern === 'all-caps-body').length, 1);
assert.equal(f.filter(r => r.antipattern === 'wide-tracking').length, 1);
assert.equal(f.filter(r => r.antipattern === 'skipped-heading').length, 1);
});
});
describe('detectHtml — layout', () => {
it('layout: flag column triggers nested-cards, pass column adds none', async () => {
const f = await detectHtml(path.join(FIXTURES, 'layout.html'));
const nested = f.filter(r => r.antipattern === 'nested-cards');
assert.ok(nested.length >= 4, `expected ≥4 nested-cards findings, got ${nested.length}`);
// The page-level layout rules (monotonous-spacing, everything-centered)
// need Tailwind-via-CDN to render, which the static engine does not fetch.
// They're effectively dormant in this test environment regardless of the fixture
// contents — so all we can verify is that the pass column doesn't push
// them awake unexpectedly.
assert.equal(f.filter(r => r.antipattern === 'monotonous-spacing').length, 0);
assert.equal(f.filter(r => r.antipattern === 'everything-centered').length, 0);
});
});
describe('detectHtml — italic-serif-display', () => {
// Two-column fixture: left col flag, right col pass. Snippet embeds the
// heading text in quotes so the test can extract it via /"([^"]+)"/.
const SHOULD_FLAG = [
'Fraunces 88px italic',
'Recoleta 64px italic',
'Playfair 72px italic',
'Unknown Serif Generic Fallback',
];
const SHOULD_PASS = [
'Sans Italic Display',
'Roman Serif Display',
'Italic Serif Pull Quote',
// The italic <em> inside the roman h1 is intentionally not detected in v1.
// The h1's own text "Inline Em Inside Roman" must not appear flagged.
'Inline Em Inside Roman',
'Italic Serif at 32px',
'h1 Sans-Serif Roman',
];
it('italic-serif-display: flags only the should-flag column', async () => {
const f = await detectHtml(path.join(FIXTURES, 'italic-serif-display.html'));
const flagged = new Set();
for (const r of f) {
if (r.antipattern !== 'italic-serif-display') continue;
const m = (r.snippet || '').match(/"([^"]+)"/);
if (m) flagged.add(m[1]);
}
for (const text of SHOULD_FLAG) {
assert.ok(flagged.has(text), `expected "${text}" to be flagged as italic-serif-display`);
}
for (const text of SHOULD_PASS) {
assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as italic-serif-display`);
}
});
});
describe('detectHtml — hero-eyebrow-chip', () => {
const SHOULD_FLAG = [
'Eyebrow Above Hero',
'Span Eyebrow Above Hero',
'Pill Chip Above Hero',
'Already Uppercase Text',
// The rule no longer gates on heading font size (modern hero h1s
// use clamp() / vw / var() that static HTML/CSS cannot resolve), and the
// eyebrow text ceiling moved 30 → 60 chars. Both shapes now flag.
'Body-Sized Heading Below Eyebrow',
'Long Uppercase Sentence Above Hero',
];
const SHOULD_PASS = [
'Eyebrow With Normal Tracking',
'Uppercase Caption Far From Hero',
'Hero With No Eyebrow',
'Heading Above Heading',
];
it('hero-eyebrow-chip: flags only the should-flag column', async () => {
const f = await detectHtml(path.join(FIXTURES, 'hero-eyebrow-chip.html'));
const flagged = new Set();
for (const r of f) {
if (r.antipattern !== 'hero-eyebrow-chip') continue;
// Snippet shape: ... above h1 "Heading Text"
const matches = [...(r.snippet || '').matchAll(/"([^"]+)"/g)];
// Last quoted token is the heading text
if (matches.length) flagged.add(matches[matches.length - 1][1]);
}
for (const text of SHOULD_FLAG) {
assert.ok(flagged.has(text), `expected "${text}" to be flagged as hero-eyebrow-chip`);
}
for (const text of SHOULD_PASS) {
assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as hero-eyebrow-chip`);
}
});
});
describe('detectHtml — repeated-section-kickers', () => {
const SHOULD_FLAG = [
'The Future Is Admitted',
'A Private Rehearsal',
'Reviewed, Not Sold',
'Touch the Future',
];
const SHOULD_PASS = [
'Breadcrumb Before Heading',
'Form Heading Is Separate',
'Step Indicator',
'Figure Caption Label',
'Normal Case Kicker',
'Intentional Brand Label',
'Garden Suite',
'Sea Loft',
'Cliff Suite',
'/impeccabletypeset',
'/impeccablelayout',
'/impeccablecolorize',
'/impeccablecraft',
'/impeccableaudit',
'/impeccablepolish',
'Mockup Hero Variant One',
'Mockup Hero Variant Two',
'Mockup Hero Variant Three',
];
it('repeated-section-kickers: flags only repeated section scaffolding', async () => {
const f = await detectHtml(path.join(FIXTURES, 'repeated-section-kickers.html'));
const flagged = new Set();
for (const r of f) {
if (r.antipattern !== 'repeated-section-kickers') continue;
assert.equal(r.severity, 'advisory');
const matches = [...(r.snippet || '').matchAll(/"([^"]+)"/g)];
if (matches.length) flagged.add(matches[matches.length - 1][1]);
}
for (const text of SHOULD_FLAG) {
assert.ok(flagged.has(text), `expected "${text}" to be flagged as repeated-section-kickers`);
}
for (const text of SHOULD_PASS) {
assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as repeated-section-kickers`);
}
});
});
describe('detectHtml — motion', () => {
// The static CSS engine applies class-based fixture styles, so it catches all
// flag-column layout-transition cases without relying on browser layout.
it('motion: flag column triggers both motion rules, pass column adds none', async () => {
const f = await detectHtml(path.join(FIXTURES, 'motion.html'));
assert.equal(f.filter(r => r.antipattern === 'bounce-easing').length, 2);
assert.equal(f.filter(r => r.antipattern === 'layout-transition').length, 8);
});
});
describe('detectHtml — dark glow', () => {
// Calibrated static baseline — see motion test note above.
// 11 element-level findings (glow-blue, glow-purple, glow-cyan, glow-multi,
// inline pink, glow-oklch, glow-hex, glow-hsl, glow-var, glow-text,
// glow-light-oklch) + 1 page-level text-scan finding. Pass column adds none.
it('glow: flag column triggers dark-glow, pass column adds none', async () => {
const f = await detectHtml(path.join(FIXTURES, 'glow.html'));
const glow = f.filter(r => r.antipattern === 'dark-glow');
assert.equal(glow.length, 12);
// Every finding is a glow tell, none reference the pass-column shadows
for (const g of glow) {
assert.match(g.snippet, /Zero-offset (box|text)-shadow glow|Colored (box|text)-shadow glow/);
}
});
});
describe('detectHtml — cramped-padding (wrapper variant)', () => {
// The cramped-padding rule has two shapes (merged under one id):
// 1. Self-text: element has its own text and padding-vs-font-size is wrong
// 2. Wrapper: element wraps text-bearing children and has near-zero
// padding against a visible boundary (border/outline/bg)
// This suite covers the wrapper variant via flush-against-border.html.
// The self-text variant lives in tests/detect-antipatterns-browser.test.mjs
// because it needs real layout rects.
//
// Snippet for the wrapper variant embeds the element's class in quotes
// so the test can grep for which cases fired.
const SHOULD_FLAG_CLASSES = [
'flag-frameworks',
'flag-card-borders',
'flag-bg-only',
'flag-outline-only',
'flag-asym-leftflush',
];
const SHOULD_PASS_CLASSES = [
'pass-no-boundary',
'pass-top-rule',
'pass-bordered-padded',
'pass-bg-padded',
'pass-outline-padded',
'pass-image-only',
'pass-margin-inset',
'pass-inner-shell',
'pass-same-bg-child',
'pass-inner-text-surface',
];
it('cramped-padding (wrapper): flags only the should-flag column', async () => {
const f = await detectHtml(path.join(FIXTURES, 'flush-against-border.html'));
const flagged = new Set();
for (const r of f) {
if (r.antipattern !== 'cramped-padding') continue;
const m = (r.snippet || '').match(/"([^"]+)"/);
if (m) flagged.add(m[1]);
}
for (const cls of SHOULD_FLAG_CLASSES) {
assert.ok(
flagged.has(cls),
`expected ".${cls}" to be flagged as cramped-padding (got: ${[...flagged].join(', ')})`
);
}
for (const cls of SHOULD_PASS_CLASSES) {
assert.ok(
!flagged.has(cls),
`".${cls}" should NOT be flagged as cramped-padding`
);
}
});
});
describe('detectHtml — oversized-h1', () => {
// Fires when a LONG headline is set at display size (dominating the
// viewport). A punchy one/two-word headline at the same size is a valid
// stylistic choice and must pass; so must a long headline at a sane size.
it('oversized-h1: flags only long headlines set at display size', async () => {
const f = await detectHtml(path.join(FIXTURES, 'oversized-h1.html'));
const hits = f.filter(r => r.antipattern === 'oversized-h1');
assert.equal(
hits.length, 2,
`expected 2 oversized-h1 findings, got ${hits.length}: ${hits.map(r => r.snippet).join('; ')}`,
);
// None of the pass cases (short-but-huge, or long-but-sane-size) may flag.
assert.equal(
hits.some(r => /Bold\.|Ship faster|ordinary headline/i.test(r.snippet || '')),
false,
'short display headlines and sanely-sized long headlines must not flag',
);
});
});
describe('detectHtml — extreme-negative-tracking', () => {
// Mirror image of wide-tracking: catches letter-spacing crushed past the
// point of legibility. Optical tightening that display type legitimately
// wants (around -0.02em) must pass.
it('extreme-negative-tracking: flags the 3 crushed cases, pass column adds none', async () => {
const f = await detectHtml(path.join(FIXTURES, 'extreme-negative-tracking.html'));
const hits = f.filter(r => r.antipattern === 'extreme-negative-tracking');
assert.equal(
hits.length, 3,
`expected 3 extreme-negative-tracking findings, got ${hits.length}: ${hits.map(r => r.snippet).join('; ')}`
);
assert.equal(
hits.some(r => /Optical tighten/i.test(r.snippet || '')),
false,
'the -0.02em display heading must not be flagged',
);
});
});
describe('detectHtml — clipped-overflow-container', () => {
// Snippet embeds the container class in quotes. A clipping ancestor
// (overflow hidden/clip) with an absolutely-positioned descendant clips
// tooltips/menus that need to escape. Real scroll regions (auto/scroll),
// visible overflow, and clipping containers without positioned children pass.
const SHOULD_FLAG = [
'flag-overflow-hidden',
'flag-overflow-clip',
'flag-overflow-negative',
'flag-overflow-right',
'flag-shadow-utility',
'flag-overlay-surface',
];
const SHOULD_PASS = [
'pass-hidden-no-abs',
'pass-visible-abs',
'pass-scroll-abs',
'pass-contained-abs',
'pass-button-shine',
'pass-crop-photo',
'pass-contained-overlay',
'pass-carousel-viewport',
'pass-fisheye-list',
'pass-split-container',
];
it('clipped-overflow-container: flags only clipping ancestors with positioned children', async () => {
const f = await detectHtml(path.join(FIXTURES, 'clipped-overflow-container.html'));
const flagged = new Set();
for (const r of f) {
if (r.antipattern !== 'clipped-overflow-container') continue;
const m = (r.snippet || '').match(/(flag-[\w-]+|pass-[\w-]+)/);
if (m) flagged.add(m[1]);
}
for (const cls of SHOULD_FLAG) {
assert.ok(flagged.has(cls), `expected ".${cls}" to be flagged as clipped-overflow-container`);
}
for (const cls of SHOULD_PASS) {
assert.ok(!flagged.has(cls), `".${cls}" should NOT be flagged as clipped-overflow-container`);
}
});
});
describe('detectHtml — cream-palette', () => {
it('cream-palette: flags a warm cream/beige page background', async () => {
const f = await detectHtml(path.join(FIXTURES, 'cream-palette.html'));
assert.equal(
f.filter(r => r.antipattern === 'cream-palette').length, 1,
`expected one cream-palette finding, got: ${f.filter(r => r.antipattern === 'cream-palette').map(r => r.snippet).join('; ')}`,
);
});
it('cream-palette: does not fire on a neutral (non-cream) page', async () => {
const f = await detectHtml(path.join(FIXTURES, 'typography-should-pass.html'));
assert.equal(f.some(r => r.antipattern === 'cream-palette'), false, 'neutral page must not flag cream-palette');
});
it('cream-palette: catches a Tailwind warm-light bg utility on body', async () => {
// No inline/<style> background — only a `bg-amber-50` class, which the
// static engine can't resolve to computed CSS. The class-list fallback
// must still flag it.
const f = await detectHtml(path.join(FIXTURES, 'cream-palette-tailwind.html'));
const hits = f.filter(r => r.antipattern === 'cream-palette');
assert.equal(hits.length, 1, `expected one cream-palette finding, got: ${hits.map(r => r.snippet).join('; ')}`);
assert.match(hits[0].snippet, /amber-50/, 'snippet should name the Tailwind utility');
});
});
describe('detectHtml — gated provider tells (--gpt / --gemini)', () => {
const GPT_IDS = ['gpt-thin-border-wide-shadow', 'repeating-stripes-gradient', 'codex-grid-background', 'theater-slop-phrase'];
it('gpt-tells: gated OFF by default — none of the GPT idioms surface', async () => {
const f = await detectHtml(path.join(FIXTURES, 'gpt-tells.html'));
for (const id of GPT_IDS) {
assert.equal(
f.some(r => r.antipattern === id), false,
`${id} must not surface without --gpt`,
);
}
});
it('gpt-tells: with providers:[gpt], each flag case triggers once, pass column adds none', async () => {
const f = await detectHtml(path.join(FIXTURES, 'gpt-tells.html'), { providers: ['gpt'] });
for (const id of GPT_IDS) {
assert.equal(
f.filter(r => r.antipattern === id).length, 1,
`expected exactly one ${id} finding under --gpt, got ${f.filter(r => r.antipattern === id).length}`,
);
}
});
it('gemini-tells: gated OFF by default, ON under providers:[gemini]', async () => {
const off = await detectHtml(path.join(FIXTURES, 'gemini-tells.html'));
assert.equal(
off.some(r => r.antipattern === 'image-hover-transform'), false,
'image-hover-transform must not surface without --gemini',
);
const on = await detectHtml(path.join(FIXTURES, 'gemini-tells.html'), { providers: ['gemini'] });
// Two flag cases: a CSS img:hover{transform} rule and a Tailwind hover:scale on <img>.
assert.equal(
on.filter(r => r.antipattern === 'image-hover-transform').length, 2,
`expected 2 image-hover-transform findings under --gemini, got ${on.filter(r => r.antipattern === 'image-hover-transform').length}`,
);
});
});