mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-20 01:56:37 +03:00
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:
co-authored by
Claude Fable 5
parent
d8eb4d73c7
commit
ed7a6fbe4e
@@ -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
@@ -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 · p99 1.20s · retention infinite</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user