detector: text-occlusion + first-viewport-column-overflow (57 -> 59)

Two browser-engine quality rules, both warning severity.

text-occlusion / element-overlap fires on three shapes: an opaque
decorated box painted over a text element (elementFromPoint confirms
real coverage, box >= 30%), one text run buried under another when at
least one side is a positioned layer (text >= 45%, so line-box leading
bleed between stacked flow blocks does not count), and an inline element
whose opaque fill leaks past its line onto a neighbour (the class-name
collision bug). A large headline whose edge overhangs a bounded content
card is caught as an element collision even when the text stays on top.
Gradient scrims, decorative SVG emblems, fixed/sticky overlays, floats,
and raw image backdrops (contrast territory, deduped against the pixel
low-contrast rule) are exempt.

first-viewport-column-overflow fires when a multi-column opening section
runs one column past 140% of the viewport while a sibling fits inside
one screen, the stretched-hero signature. Single-column pages and
full-page heroes with no fitting sibling are exempt.

Validated: fires on the diagnosed repros, clean across a 60-sample
sweep. Fixtures + browser tests added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-15 14:53:03 -07:00
co-authored by Claude Fable 5
parent d8eb4d73c7
commit ed7a6fbe4e
10 changed files with 927 additions and 6 deletions
@@ -359,6 +359,29 @@ describe('detectUrl — browser-only fixtures', () => {
}
});
it('text-occlusion: box-over-text, inline padding leak, headline/card overhang flag; leading bleed, scrim, fixed bar pass', async () => {
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/text-occlusion.html`, { visualContrast: false });
const hits = f.filter(r => r.antipattern === 'text-occlusion');
const snippets = hits.map(h => h.snippet).join('\n');
assert.match(snippets, /flag-box-text/, `opaque box painted over text should flag: ${snippets}`);
assert.match(snippets, /flag-leak/, `inline element with leaked opaque padding should flag: ${snippets}`);
assert.match(snippets, /flag-headline/, `headline overhanging an opaque card should flag: ${snippets}`);
for (const cls of ['pass-title', 'pass-eyebrow', 'pass-hero', 'cap', 'pass-under', 'pass-fixedbar']) {
assert.doesNotMatch(snippets, new RegExp(cls), `".${cls}" must not flag: ${snippets}`);
}
assert.equal(hits.length, 3, `expected exactly 3 text-occlusion findings, got ${hits.length}: ${snippets}`);
});
it('first-viewport-column-overflow: stretched-hero column flags; balanced columns and single hero pass', async () => {
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/first-viewport-column-overflow.html`, { visualContrast: false });
const hits = f.filter(r => r.antipattern === 'first-viewport-column-overflow');
assert.equal(hits.length, 1, `expected exactly 1 first-viewport-column-overflow finding, got ${hits.length}: ${JSON.stringify(hits.map(h => h.snippet))}`);
assert.match(hits[0].snippet, /\bflag\b/, `finding must attach to the stretched-hero section: ${hits[0].snippet}`);
for (const cls of ['pass-balanced', 'pass-hero']) {
assert.doesNotMatch(hits[0].snippet, new RegExp(cls), `".${cls}" must not flag`);
}
});
it('visual contrast: browser fallback catches low contrast on image backgrounds', async () => {
const analyticOnly = await detectUrl(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, {
waitUntil: 'load',
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>first-viewport-column-overflow fixture</title>
<style>
* { margin: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #111; background: #fff; }
p { margin: 0 0 14px; line-height: 1.6; }
/* ── FLAG: two-column opening section, right column runs far past the fold
while the left column fits inside one viewport ── */
.flag { display: grid; grid-template-columns: 1.4fr 1fr; gap: 40px; padding: 40px; }
.flag .short { }
.flag .short h1 { font-size: 40px; margin-bottom: 16px; }
.flag .tall .filler { height: 1600px; background: #eee; border-radius: 8px; padding: 16px; }
/* ── PASS: balanced two-column section, both fit ── */
.pass-balanced { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; padding: 40px; }
.pass-balanced .col { }
/* ── PASS: single full-page hero (no sibling column) ── */
.pass-hero { padding: 40px; }
.pass-hero .art { height: 1400px; background: #f3f3f3; }
</style>
</head>
<body>
<section class="flag">
<div class="short">
<h1>Upload your film and add a narrator</h1>
<p>Paste a link or drop a file. We transcribe it, translate, and one narrator reads over the top.</p>
<p>The original stays audible underneath, just quieter.</p>
</div>
<aside class="tall">
<p>What is a lektor? A single calm voice reads every line of a foreign film.</p>
<div class="filler">A tall editorial column with a long photo and a lot of copy.</div>
</aside>
</section>
<section class="pass-balanced">
<div class="col">
<p>Left column with a normal amount of copy that fits comfortably within the opening viewport.</p>
<p>Two short paragraphs, nothing that runs long.</p>
</div>
<div class="col">
<p>Right column, also short. Both sides sit inside the first screen.</p>
<p>No column stretches the fold.</p>
</div>
</section>
<section class="pass-hero">
<div class="art"></div>
</section>
</body>
</html>
+81
View File
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>text-occlusion fixture</title>
<style>
* { margin: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #111; background: #fff; }
section { padding: 40px; position: relative; }
/* ── FLAG 1: opaque box painted over text (>30%) ── */
.flag-box-wrap { position: relative; height: 120px; }
.flag-box-text { position: absolute; top: 40px; left: 40px; font-size: 20px; width: 300px; }
.flag-box-cover { position: absolute; top: 30px; left: 30px; width: 240px; height: 60px;
background: #1f7a3d; border-radius: 8px; z-index: 5; }
/* ── FLAG 2: inline element with opaque fill + leaked vertical padding ── */
.flag-leak-host { position: relative; margin-top: 30px; }
.flag-leak { display: inline; background: #2f8543; padding: 36px; border-radius: 6px; }
.flag-leak-card { background: #14202b; color: #fff; padding: 24px; border-radius: 6px; }
/* ── FLAG 3: big headline overhanging an opaque card ── */
.flag-hero { position: relative; height: 220px; }
.flag-headline { position: absolute; top: 40px; left: 0; font-size: 64px; font-weight: 800;
width: 760px; color: #b22; white-space: nowrap; }
.flag-card { position: absolute; top: 20px; left: 700px; width: 360px; height: 180px;
background: #f4f5f7; border: 1px solid #ddd; box-shadow: 0 2px 8px rgba(0,0,0,.08); border-radius: 8px; }
/* ── PASS: stacked headline with tight leading (line-box bleed, no real overlap) ── */
.pass-stack { margin-top: 40px; }
.pass-eyebrow { font-size: 14px; letter-spacing: .1em; text-transform: uppercase; color: #a33; }
.pass-title { font-size: 96px; line-height: .9; font-weight: 800; }
/* ── PASS: text over image with a scrim, text on top (readable) ── */
.pass-hero { position: relative; height: 200px; overflow: hidden; border-radius: 8px; }
.pass-hero img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.pass-hero .scrim { position: absolute; inset: 0; background: linear-gradient(transparent, rgba(0,0,0,.6)); }
.pass-hero .cap { position: absolute; bottom: 16px; left: 16px; color: #fff; font-size: 24px; z-index: 2; }
/* ── PASS: fixed status bar over content at the fold ── */
.pass-under { margin-top: 20px; font-size: 16px; }
.pass-fixedbar { position: fixed; left: 0; right: 0; bottom: 0; height: 40px; background: #b22;
color: #fff; display: flex; align-items: center; padding: 0 16px; }
</style>
</head>
<body>
<section>
<div class="flag-box-wrap">
<p class="flag-box-text">Root cause identified in four minutes</p>
<div class="flag-box-cover" aria-hidden="true"></div>
</div>
<div class="flag-leak-host">
<span class="flag-leak" aria-hidden="true"></span>
<div class="flag-leak-card">Incident closed. Missing index on the replica, found from one alert.</div>
</div>
<div class="flag-hero">
<div class="flag-headline">The trace you need is the one they sampled away.</div>
<div class="flag-card">
<p>INCIDENT 4417</p>
<p>service checkout-api</p>
</div>
</div>
<div class="pass-stack">
<p class="pass-eyebrow">Family Italian on the waterfront</p>
<h1 class="pass-title">Trattoria da Nonna Lucia</h1>
</div>
<div class="pass-hero">
<img src="data:image/gif;base64,R0lGODlhAQABAIABAAAAAP///yH5BAEAAAEALAAAAAABAAEAAAICTAEAOw==" alt="">
<div class="scrim"></div>
<div class="cap">Villa on the cliff</div>
</div>
<p class="pass-under">Every span of every trace, kept on disk, no sampling ever applied here.</p>
</section>
<div class="pass-fixedbar">8/8 services up &middot; p99 1.20s &middot; retention infinite</div>
</body>
</html>