1---
2import fs from 'node:fs';
3import path from 'node:path';
4import Base from '../../layouts/Base.astro';
5import '../../styles/sub-pages.css';
6import '../../styles/detector-lab.css';
7import {
8 createDetectorProfile,
9 detectHtml,
10 summarizeDetectorProfile,
11} from '../../../cli/engine/detect-antipatterns.mjs';
12
13const ROOT = process.cwd();
14const FIXTURES_DIR = path.join(ROOT, 'tests', 'fixtures', 'antipatterns');
15
16const FIXTURES = [
17 {
18 file: 'border-baseline.html',
19 group: 'Borders',
20 title: 'Border side tabs',
21 summary: 'Side-by-side positive and negative cases for colored side borders and rounded accent borders.',
22 },
23 {
24 file: 'partial-component.html',
25 group: 'Baseline',
26 title: 'Partial component',
27 summary: 'Component-like HTML where page-level rules must stay quiet.',
28 },
29 {
30 file: 'linked-stylesheet.html',
31 group: 'Static CSS',
32 title: 'Linked stylesheet',
33 summary: 'Local CSS inlining, selector matching, and cascade coverage.',
34 },
35 {
36 file: 'modern-color-borders.html',
37 group: 'Borders',
38 title: 'Modern color borders',
39 summary: 'OKLCH, LAB, LCH, HSL, HWB, and custom-property border cases.',
40 },
41 {
42 file: 'color.html',
43 group: 'Color',
44 title: 'Color and contrast',
45 summary: 'Solid contrast, gray-on-color, gradient stops, links, buttons, and emoji skips.',
46 },
47 {
48 file: 'visual-contrast.html',
49 group: 'Color',
50 title: 'Visual contrast fallback',
51 summary: 'Image-background text cases for the Puppeteer pixel-diff contrast pass.',
52 },
53 {
54 file: 'legitimate-borders.html',
55 group: 'Borders',
56 title: 'Legitimate borders',
57 summary: 'Border patterns that should pass despite being visually close to side tabs.',
58 },
59 {
60 file: 'typography.html',
61 group: 'Typography',
62 title: 'Typography side by side',
63 summary: 'Element-level typography checks with visible flag/pass highlights.',
64 },
65 {
66 file: 'quality.html',
67 group: 'Typography',
68 title: 'Quality and readability',
69 summary: 'Text quality checks, plus browser-only line length in the live pane.',
70 },
71 {
72 file: 'italic-serif-display.html',
73 group: 'Typography',
74 title: 'Italic serif display',
75 summary: 'Large decorative serif italics versus legitimate emphasis.',
76 },
77 {
78 file: 'hero-eyebrow-chip.html',
79 group: 'Typography',
80 title: 'Hero eyebrow chip',
81 summary: 'Uppercase micro-labels and pills above hero headings.',
82 },
83 {
84 file: 'layout.html',
85 group: 'Layout',
86 title: 'Layout',
87 summary: 'Nested cards and page-level layout patterns.',
88 },
89 {
90 file: 'repeated-section-kickers.html',
91 group: 'Layout',
92 title: 'Repeated section kickers',
93 summary: 'Repeated scaffolding labels that create AI-page rhythm.',
94 },
95 {
96 file: 'icon-tile-stack.html',
97 group: 'Layout',
98 title: 'Icon tile stack',
99 summary: 'Large icon tiles stacked over headings.',
100 },
101 {
102 file: 'cramped-padding.html',
103 group: 'Browser layout',
104 title: 'Cramped padding',
105 summary: 'Browser-only padding checks that need real element rectangles.',
106 },
107 {
108 file: 'body-text-viewport-edge.html',
109 group: 'Browser layout',
110 title: 'Viewport-edge body text',
111 summary: 'Browser-only body text that bleeds to the viewport edge.',
112 },
113 {
114 file: 'motion.html',
115 group: 'Motion',
116 title: 'Motion',
117 summary: 'Bounce easing and layout-property transitions.',
118 },
119 {
120 file: 'glow.html',
121 group: 'Motion',
122 title: 'Dark glow',
123 summary: 'Chromatic shadows on dark surfaces versus safe shadows.',
124 },
125 {
126 file: 'overlay-positioning.html',
127 group: 'Overlay',
128 title: 'Overlay positioning',
129 summary: 'Edge cases for the browser overlay itself.',
130 },
131];
132
133function nowMs() {
134 return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
135}
136
137function roundMs(value: number) {
138 return Number(value.toFixed(3));
139}
140
141function displayMs(value: number) {
142 if (!Number.isFinite(value)) return '0 ms';
143 if (value >= 1000) return `${(value / 1000).toFixed(2)} s`;
144 if (value < 10) return `${value.toFixed(2)} ms`;
145 return `${value.toFixed(1)} ms`;
146}
147
148function slug(file: string) {
149 return file.replace(/\.html$/, '');
150}
151
152function routeFor(file: string) {
153 return `/detector/fixtures/antipatterns/${file}`;
154}
155
156function countByRule(findings: Array<{ antipattern: string }>) {
157 const counts = new Map<string, number>();
158 for (const finding of findings) {
159 counts.set(finding.antipattern, (counts.get(finding.antipattern) || 0) + 1);
160 }
161 return [...counts.entries()]
162 .map(([ruleId, count]) => ({ ruleId, count }))
163 .sort((a, b) => b.count - a.count || a.ruleId.localeCompare(b.ruleId));
164}
165
166async function measureFixture(meta: typeof FIXTURES[number]) {
167 const filePath = path.join(FIXTURES_DIR, meta.file);
168 const profile = createDetectorProfile();
169 const started = nowMs();
170 const findings = await detectHtml(filePath, { profile });
171 const totalMs = roundMs(nowMs() - started);
172 return {
173 ...meta,
174 id: slug(meta.file),
175 url: routeFor(meta.file),
176 bytes: fs.statSync(filePath).size,
177 staticTotalMs: totalMs,
178 staticFindings: findings.length,
179 findingsByRule: countByRule(findings),
180 profile: summarizeDetectorProfile(profile)
181 .map(row => ({
182 engine: row.engine,
183 phase: row.phase,
184 ruleId: row.ruleId,
185 calls: row.calls,
186 totalMs: row.totalMs,
187 avgMs: row.avgMs,
188 p50: row.p50,
189 p95: row.p95,
190 findings: row.findings,
191 })),
192 };
193}
194
195function percentile(values: number[], pct: number) {
196 if (values.length === 0) return 0;
197 const sorted = [...values].sort((a, b) => a - b);
198 const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((pct / 100) * sorted.length) - 1));
199 return sorted[idx];
200}
201
202function aggregateProfile(fixtures: Awaited<ReturnType<typeof measureFixture>>[]) {
203 const groups = new Map<string, {
204 engine: string;
205 phase: string;
206 ruleId: string;
207 calls: number;
208 totalMs: number;
209 findings: number;
210 samples: number[];
211 }>();
212 for (const fixture of fixtures) {
213 for (const row of fixture.profile) {
214 const key = `${row.engine}\u0000${row.phase}\u0000${row.ruleId}`;
215 let group = groups.get(key);
216 if (!group) {
217 group = {
218 engine: row.engine,
219 phase: row.phase,
220 ruleId: row.ruleId,
221 calls: 0,
222 totalMs: 0,
223 findings: 0,
224 samples: [],
225 };
226 groups.set(key, group);
227 }
228 group.calls += row.calls;
229 group.totalMs += row.totalMs;
230 group.findings += row.findings;
231 group.samples.push(row.p50);
232 }
233 }
234 return [...groups.values()]
235 .map(group => ({
236 engine: group.engine,
237 phase: group.phase,
238 ruleId: group.ruleId,
239 calls: group.calls,
240 totalMs: roundMs(group.totalMs),
241 avgMs: roundMs(group.totalMs / Math.max(1, group.calls)),
242 p50: roundMs(percentile(group.samples, 50)),
243 p95: roundMs(percentile(group.samples, 95)),
244 findings: group.findings,
245 }))
246 .sort((a, b) => b.totalMs - a.totalMs);
247}
248
249await detectHtml(path.join(FIXTURES_DIR, 'should-pass.html'), { profile: createDetectorProfile() });
250const fixtureData = [];
251for (const fixture of FIXTURES) {
252 fixtureData.push(await measureFixture(fixture));
253}
254const globalProfile = aggregateProfile(fixtureData);
255const totalStaticMs = roundMs(fixtureData.reduce((sum, item) => sum + item.staticTotalMs, 0));
256const totalFindings = fixtureData.reduce((sum, item) => sum + item.staticFindings, 0);
257const slowestRow = globalProfile[0];
258const groups = [...new Set(fixtureData.map(item => item.group))];
259const dashboardData = {
260 generatedAt: new Date().toISOString(),
261 fixtures: fixtureData,
262 globalProfile,
263};
264const dashboardJson = JSON.stringify(dashboardData).replace(/</g, '\\u003c');
265---
266
267<Base
268 title="Detector Lab | Impeccable"
269 description="A fixture browser and timing dashboard for Impeccable's anti-pattern detector."
270 bodyClass="sub-page detector-lab-page"
271 mainClass="detector-main-shell"
272 noIndex
273 hideHeader
274 hideFooter
275>
276 <section class="detector-workbench" aria-label="Detector fixture explorer">
277 <aside class="detector-fixture-nav" aria-label="Fixture navigation">
278 <div class="detector-nav-head">
279 <a class="detector-home-link" href="/" aria-label="Back to Impeccable">
280 <span>/</span>
281 <strong>Impeccable</strong>
282 </a>
283 <h1>Detector Lab</h1>
284 <p>Internal fixture browser and speed surface.</p>
285 <dl class="detector-summary-grid" aria-label="Detector benchmark summary">
286 <div>
287 <dt>Fixtures</dt>
288 <dd>{fixtureData.length}</dd>
289 </div>
290 <div>
291 <dt>Static</dt>
292 <dd>{displayMs(totalStaticMs)}</dd>
293 </div>
294 <div>
295 <dt>Findings</dt>
296 <dd>{totalFindings}</dd>
297 </div>
298 <div>
299 <dt>Slowest</dt>
300 <dd>{slowestRow?.ruleId || 'none'}</dd>
301 </div>
302 </dl>
303 </div>
304
305 <nav class="detector-nav-scroll">
306 {groups.map(group => (
307 <div class="detector-nav-group">
308 <h2>{group}</h2>
309 <div class="detector-nav-list">
310 {fixtureData.filter(item => item.group === group).map(item => (
311 <button
312 type="button"
313 class="detector-fixture-button"
314 data-fixture-id={item.id}
315 aria-pressed={item.id === fixtureData[0].id ? 'true' : 'false'}
316 >
317 <span>{item.title}</span>
318 <span>{item.staticFindings}</span>
319 </button>
320 ))}
321 </div>
322 </div>
323 ))}
324 </nav>
325 </aside>
326
327 <div class="detector-workspace">
328 <header class="detector-preview-head">
329 <div>
330 <p class="detector-panel-label" data-fixture-group>{fixtureData[0].group}</p>
331 <h2 data-fixture-title>{fixtureData[0].title}</h2>
332 <p data-fixture-summary>{fixtureData[0].summary}</p>
333 </div>
334 <div class="detector-preview-actions">
335 <a href={fixtureData[0].url} target="_blank" rel="noopener" data-open-fixture>Open fixture</a>
336 <button type="button" data-rerun-browser>Run browser scan</button>
337 </div>
338 </header>
339
340 <div class="detector-workspace-scroll">
341 <div class="detector-content-grid">
342 <div class="detector-preview-column">
343 <div class="detector-frame-wrap">
344 <iframe
345 src={fixtureData[0].url}
346 title={`${fixtureData[0].title} detector fixture`}
347 data-fixture-frame
348 ></iframe>
349 </div>
350 </div>
351
352 <aside class="detector-metrics" aria-label="Detector timings">
353 <section class="detector-metric-panel" aria-labelledby="browser-timing-title">
354 <div class="detector-panel-head">
355 <p class="detector-panel-label">Live browser</p>
356 <h2 id="browser-timing-title">Current iframe scan</h2>
357 </div>
358 <dl class="detector-live-stats">
359 <div>
360 <dt>Pure detect</dt>
361 <dd data-browser-ms>waiting</dd>
362 </div>
363 <div>
364 <dt>Findings</dt>
365 <dd data-browser-findings>waiting</dd>
366 </div>
367 <div>
368 <dt>Visual candidates</dt>
369 <dd data-browser-visual-candidates>waiting</dd>
370 </div>
371 </dl>
372 <div class="detector-rule-pills" data-browser-rules aria-label="Browser findings by rule"></div>
373 </section>
374
375 <section class="detector-metric-panel" aria-labelledby="static-fixture-title">
376 <div class="detector-panel-head">
377 <p class="detector-panel-label">Static HTML/CSS</p>
378 <h2 id="static-fixture-title">Selected fixture</h2>
379 </div>
380 <dl class="detector-live-stats">
381 <div>
382 <dt>Total</dt>
383 <dd data-static-ms>{displayMs(fixtureData[0].staticTotalMs)}</dd>
384 </div>
385 <div>
386 <dt>Findings</dt>
387 <dd data-static-findings>{fixtureData[0].staticFindings}</dd>
388 </div>
389 </dl>
390 <div class="detector-rule-pills" data-static-rules aria-label="Static findings by rule"></div>
391 </section>
392 </aside>
393 </div>
394
395 <section class="detector-tables" aria-label="Detector timing tables">
396 <div class="detector-table-panel">
397 <header>
398 <div>
399 <p class="detector-panel-label">Per fixture</p>
400 <h2 data-selected-table-title>{fixtureData[0].title} timings</h2>
401 </div>
402 <p>Rows come directly from the profiling API.</p>
403 </header>
404 <div class="detector-table-scroll">
405 <table>
406 <thead>
407 <tr>
408 <th>Phase</th>
409 <th>Check</th>
410 <th>Calls</th>
411 <th>Total</th>
412 <th>P95</th>
413 <th>Findings</th>
414 </tr>
415 </thead>
416 <tbody data-selected-timings></tbody>
417 </table>
418 </div>
419 </div>
420
421 <div class="detector-table-panel">
422 <header>
423 <div>
424 <p class="detector-panel-label">All fixtures</p>
425 <h2>Slowest checks</h2>
426 </div>
427 <p>Aggregated across every fixture in this lab.</p>
428 </header>
429 <div class="detector-table-scroll">
430 <table>
431 <thead>
432 <tr>
433 <th>Engine</th>
434 <th>Phase</th>
435 <th>Check</th>
436 <th>Calls</th>
437 <th>Total</th>
438 <th>P95</th>
439 <th>Findings</th>
440 </tr>
441 </thead>
442 <tbody>
443 {globalProfile.slice(0, 24).map(row => (
444 <tr>
445 <td>{row.engine}</td>
446 <td>{row.phase}</td>
447 <td><code>{row.ruleId}</code></td>
448 <td>{row.calls}</td>
449 <td>{row.totalMs} ms</td>
450 <td>{row.p95} ms</td>
451 <td>{row.findings}</td>
452 </tr>
453 ))}
454 </tbody>
455 </table>
456 </div>
457 </div>
458 </section>
459 </div>
460 </div>
461 </section>
462
463 <script id="detector-dashboard-data" type="application/json" set:html={dashboardJson}></script>
464 <script>
465 const data = JSON.parse(document.getElementById('detector-dashboard-data').textContent);
466 const fixtures = data.fixtures;
467 const byId = new Map(fixtures.map(item => [item.id, item]));
468
469 const workspaceScrollEl = document.querySelector('.detector-workspace-scroll');
470 const frame = document.querySelector('[data-fixture-frame]');
471 const titleEl = document.querySelector('[data-fixture-title]');
472 const groupEl = document.querySelector('[data-fixture-group]');
473 const summaryEl = document.querySelector('[data-fixture-summary]');
474 const openLink = document.querySelector('[data-open-fixture]');
475 const staticMsEl = document.querySelector('[data-static-ms]');
476 const staticFindingsEl = document.querySelector('[data-static-findings]');
477 const staticRulesEl = document.querySelector('[data-static-rules]');
478 const browserMsEl = document.querySelector('[data-browser-ms]');
479 const browserFindingsEl = document.querySelector('[data-browser-findings]');
480 const browserVisualCandidatesEl = document.querySelector('[data-browser-visual-candidates]');
481 const browserRulesEl = document.querySelector('[data-browser-rules]');
482 const selectedTimingBody = document.querySelector('[data-selected-timings]');
483 const selectedTableTitle = document.querySelector('[data-selected-table-title]');
484 let currentFixtureId = fixtures[0]?.id || '';
485 let latestBrowserSummary = null;
486 let detachVisualResolutionListener = null;
487
488 function formatMs(value) {
489 if (!Number.isFinite(value)) return '0 ms';
490 if (value < 1) return `${value.toFixed(3)} ms`;
491 if (value < 10) return `${value.toFixed(2)} ms`;
492 return `${value.toFixed(1)} ms`;
493 }
494
495 function renderPills(container, rows, emptyText) {
496 container.replaceChildren();
497 if (!rows || rows.length === 0) {
498 const empty = document.createElement('span');
499 empty.className = 'detector-empty-pill';
500 empty.textContent = emptyText;
501 container.append(empty);
502 return;
503 }
504 for (const row of rows.slice(0, 12)) {
505 const pill = document.createElement('span');
506 pill.className = 'detector-rule-pill';
507 pill.innerHTML = `<code></code><span></span>`;
508 pill.querySelector('code').textContent = row.ruleId;
509 pill.querySelector('span').textContent = row.count;
510 container.append(pill);
511 }
512 }
513
514 function countBrowserRules(groups) {
515 const counts = new Map();
516 for (const group of groups || []) {
517 for (const finding of group.findings || []) {
518 counts.set(finding.type, (counts.get(finding.type) || 0) + 1);
519 }
520 }
521 return [...counts.entries()]
522 .map(([ruleId, count]) => ({ ruleId, count }))
523 .sort((a, b) => b.count - a.count || a.ruleId.localeCompare(b.ruleId));
524 }
525
526 function countBrowserFindings(groups) {
527 return (groups || []).reduce((sum, group) => sum + (group.findings?.length || 0), 0);
528 }
529
530 function renderBrowserSummary(summary) {
531 const visualAnalyses = summary.visualAnalyses || [];
532 const visualCandidates = visualAnalyses.length > 0 ? visualAnalyses : (summary.visualCandidates || []);
533 const visualFailures = visualAnalyses.filter(result => result.status === 'fail').length;
534 const visualPasses = visualAnalyses.filter(result => result.status === 'pass').length;
535 const visualUnresolved = visualAnalyses.filter(result => result.status === 'unresolved').length;
536 const groupFindingCount = countBrowserFindings(summary.groups);
537 const count = summary.useVisualPipeline
538 ? (summary.nonVisualFindingCount + visualFailures)
539 : (groupFindingCount + visualFailures);
540 const ruleRows = countBrowserRules(summary.groups)
541 .filter(row => !(summary.useVisualPipeline && row.ruleId === 'low-contrast'));
542
543 if (visualUnresolved > 0) {
544 ruleRows.unshift({ ruleId: 'visual-contrast-unresolved', count: visualUnresolved });
545 }
546 if (visualPasses > 0) {
547 ruleRows.unshift({ ruleId: 'visual-contrast-pass', count: visualPasses });
548 }
549 if (visualFailures > 0) {
550 ruleRows.unshift({ ruleId: 'low-contrast:visual', count: visualFailures });
551 }
552 if (visualCandidates.length > 0) {
553 ruleRows.unshift({ ruleId: 'visual-contrast-candidate', count: visualCandidates.length });
554 }
555
556 browserMsEl.textContent = formatMs(summary.elapsed);
557 browserFindingsEl.textContent = String(count);
558 browserVisualCandidatesEl.textContent = visualAnalyses.length > 0
559 ? `${visualCandidates.length} (${visualFailures} fail, ${visualPasses} pass, ${visualUnresolved} unresolved, ${formatMs(summary.visualElapsed)})`
560 : String(visualCandidates.length);
561 renderPills(browserRulesEl, ruleRows, 'No browser findings');
562 }
563
564 function bindVisualResolutionUpdates(win) {
565 if (detachVisualResolutionListener) {
566 detachVisualResolutionListener();
567 detachVisualResolutionListener = null;
568 }
569 const updateSummary = () => {
570 if (!latestBrowserSummary || latestBrowserSummary.win !== win) return;
571 const visualAnalyses = typeof win.impeccableGetLastVisualContrastAnalyses === 'function'
572 ? win.impeccableGetLastVisualContrastAnalyses()
573 : latestBrowserSummary.visualAnalyses;
574 latestBrowserSummary = {
575 ...latestBrowserSummary,
576 visualAnalyses,
577 visualCandidates: visualAnalyses.length > 0 ? visualAnalyses : latestBrowserSummary.visualCandidates,
578 };
579 renderBrowserSummary(latestBrowserSummary);
580 };
581 win.addEventListener('impeccable-visual-contrast-resolved', updateSummary);
582 detachVisualResolutionListener = () => {
583 win.removeEventListener('impeccable-visual-contrast-resolved', updateSummary);
584 };
585 }
586
587 function renderTimingRows(fixture) {
588 selectedTimingBody.replaceChildren();
589 for (const row of fixture.profile.slice(0, 36)) {
590 const tr = document.createElement('tr');
591 tr.innerHTML = `
592 <td></td>
593 <td><code></code></td>
594 <td></td>
595 <td></td>
596 <td></td>
597 <td></td>
598 `;
599 tr.children[0].textContent = row.phase;
600 tr.querySelector('code').textContent = row.ruleId;
601 tr.children[2].textContent = row.calls;
602 tr.children[3].textContent = `${row.totalMs} ms`;
603 tr.children[4].textContent = `${row.p95} ms`;
604 tr.children[5].textContent = row.findings;
605 selectedTimingBody.append(tr);
606 }
607 }
608
609 function waitForDetector(win, attempts = 30) {
610 return new Promise((resolve, reject) => {
611 const tick = () => {
612 if (win && typeof win.impeccableDetect === 'function') {
613 resolve(win);
614 return;
615 }
616 attempts -= 1;
617 if (attempts <= 0) {
618 reject(new Error('Detector script did not initialize'));
619 return;
620 }
621 setTimeout(tick, 100);
622 };
623 tick();
624 });
625 }
626
627 function clearLabOverlays(win) {
628 const doc = win?.document;
629 if (!doc) return;
630 for (const node of doc.querySelectorAll('[data-impeccable-lab-overlay]')) {
631 node.remove();
632 }
633 }
634
635 function ensureLabOverlayStyle(win) {
636 const doc = win?.document;
637 if (!doc || doc.getElementById('impeccable-detector-lab-overlay-style')) return;
638 const style = doc.createElement('style');
639 style.id = 'impeccable-detector-lab-overlay-style';
640 style.setAttribute('data-impeccable-lab-overlay', 'style');
641 style.textContent = `
642 [data-impeccable-lab-overlay="box"] {
643 position: absolute;
644 z-index: 2147483646;
645 box-sizing: border-box;
646 border: 2px solid oklch(65% 0.28 350);
647 border-radius: 6px;
648 background: color-mix(in oklch, oklch(65% 0.28 350), transparent 88%);
649 box-shadow: 0 0 0 1px rgb(255 255 255 / 0.92), 0 0 0 5px rgb(218 0 132 / 0.14);
650 pointer-events: none;
651 }
652 [data-impeccable-lab-overlay="label"] {
653 position: absolute;
654 z-index: 2147483647;
655 max-width: 260px;
656 padding: 4px 7px;
657 border-radius: 6px;
658 background: oklch(42% 0.24 350);
659 color: white;
660 font: 700 11px/1.25 system-ui, sans-serif;
661 letter-spacing: 0;
662 box-shadow: 0 8px 20px rgb(0 0 0 / 0.22);
663 pointer-events: none;
664 }
665 `;
666 doc.head.append(style);
667 }
668
669 function overlayLabelFor(row) {
670 if (row.kind === 'visual') return 'low-contrast visual';
671 const types = (row.findings || []).map(finding => finding.type).filter(Boolean);
672 return [...new Set(types)].slice(0, 2).join(', ') || 'finding';
673 }
674
675 function renderLabOverlays(win, groups, visualAnalyses, options = {}) {
676 const doc = win?.document;
677 if (!doc?.body) return;
678 clearLabOverlays(win);
679 if (!options.showVisualOverlays) return;
680 ensureLabOverlayStyle(win);
681 const existingFindingSelectors = new Set(
682 (groups || [])
683 .filter(group => group.selector && group.findings?.length)
684 .map(group => group.selector)
685 );
686 const rows = [];
687 for (const result of visualAnalyses || []) {
688 if (result.status !== 'fail' || !result.selector) continue;
689 if (existingFindingSelectors.has(result.selector)) continue;
690 rows.push({ selector: result.selector, kind: 'visual' });
691 }
692
693 for (const row of rows) {
694 let el = null;
695 try {
696 el = doc.querySelector(row.selector);
697 } catch {
698 el = null;
699 }
700 if (el?._impeccableOverlay) continue;
701 if (!el || !el.getBoundingClientRect) continue;
702 const rect = el.getBoundingClientRect();
703 if (rect.width < 2 || rect.height < 2) continue;
704 const left = rect.left + win.scrollX;
705 const top = rect.top + win.scrollY;
706 const box = doc.createElement('div');
707 box.setAttribute('data-impeccable-lab-overlay', 'box');
708 box.style.left = `${Math.max(0, left - 3)}px`;
709 box.style.top = `${Math.max(0, top - 3)}px`;
710 box.style.width = `${Math.max(4, rect.width + 6)}px`;
711 box.style.height = `${Math.max(4, rect.height + 6)}px`;
712 doc.body.append(box);
713
714 const label = doc.createElement('div');
715 label.setAttribute('data-impeccable-lab-overlay', 'label');
716 label.textContent = overlayLabelFor(row);
717 label.style.left = `${Math.max(0, left - 3)}px`;
718 label.style.top = `${Math.max(0, top - 28)}px`;
719 doc.body.append(label);
720 }
721 }
722
723 async function runBrowserScan() {
724 browserMsEl.textContent = 'scanning';
725 browserFindingsEl.textContent = 'scanning';
726 browserVisualCandidatesEl.textContent = 'scanning';
727 browserRulesEl.replaceChildren();
728 try {
729 const win = await waitForDetector(frame.contentWindow);
730 const restoreWorkspaceScroll = workspaceScrollEl?.scrollTop || 0;
731 const restoreFrameScroll = { x: win.scrollX, y: win.scrollY };
732 clearLabOverlays(win);
733 const useVisualPipeline = currentFixtureId === 'visual-contrast';
734 const started = performance.now();
735 const groups = useVisualPipeline && typeof win.impeccableScanAsync === 'function'
736 ? await win.impeccableScanAsync({ visualContrast: true, visualContrastMaxCandidates: 99 })
737 : await win.impeccableDetect({ decorate: false, serialize: true });
738 const elapsed = performance.now() - started;
739 const visualStarted = performance.now();
740 const visualAnalyses = useVisualPipeline && typeof win.impeccableGetLastVisualContrastAnalyses === 'function'
741 ? win.impeccableGetLastVisualContrastAnalyses()
742 : (typeof win.impeccableAnalyzeVisualContrast === 'function'
743 ? await win.impeccableAnalyzeVisualContrast({ maxCandidates: 99, scrollOffscreen: false })
744 : []);
745 const visualElapsed = useVisualPipeline ? elapsed : performance.now() - visualStarted;
746 const visualCandidates = visualAnalyses.length > 0
747 ? visualAnalyses
748 : (typeof win.impeccableCollectVisualContrastCandidates === 'function'
749 ? win.impeccableCollectVisualContrastCandidates({ maxCandidates: 99 })
750 : []);
751 const visualFailures = visualAnalyses.filter(result => result.status === 'fail').length;
752 const nonVisualFindingCount = useVisualPipeline
753 ? Math.max(0, countBrowserFindings(groups) - visualFailures)
754 : countBrowserFindings(groups);
755 if (workspaceScrollEl) workspaceScrollEl.scrollTop = restoreWorkspaceScroll;
756 win.scrollTo(restoreFrameScroll.x, restoreFrameScroll.y);
757 latestBrowserSummary = {
758 win,
759 groups,
760 visualAnalyses,
761 visualCandidates,
762 elapsed,
763 visualElapsed,
764 useVisualPipeline,
765 nonVisualFindingCount,
766 };
767 bindVisualResolutionUpdates(win);
768 renderBrowserSummary(latestBrowserSummary);
769 renderLabOverlays(win, groups, visualAnalyses, {
770 showVisualOverlays: currentFixtureId === 'visual-contrast',
771 });
772 } catch (err) {
773 browserMsEl.textContent = 'unavailable';
774 browserFindingsEl.textContent = '0';
775 browserVisualCandidatesEl.textContent = '0';
776 renderPills(browserRulesEl, [], err.message || 'No browser data');
777 }
778 }
779
780 function selectFixture(id, updateHash = true) {
781 const fixture = byId.get(id) || fixtures[0];
782 currentFixtureId = fixture.id;
783 for (const button of document.querySelectorAll('[data-fixture-id]')) {
784 button.setAttribute('aria-pressed', button.dataset.fixtureId === fixture.id ? 'true' : 'false');
785 }
786 titleEl.textContent = fixture.title;
787 groupEl.textContent = fixture.group;
788 summaryEl.textContent = fixture.summary;
789 openLink.href = fixture.url;
790 staticMsEl.textContent = formatMs(fixture.staticTotalMs);
791 staticFindingsEl.textContent = String(fixture.staticFindings);
792 selectedTableTitle.textContent = `${fixture.title} timings`;
793 renderPills(staticRulesEl, fixture.findingsByRule, 'No static findings');
794 renderTimingRows(fixture);
795 if (workspaceScrollEl) workspaceScrollEl.scrollTop = 0;
796 browserMsEl.textContent = 'loading';
797 browserFindingsEl.textContent = 'loading';
798 browserVisualCandidatesEl.textContent = 'loading';
799 browserRulesEl.replaceChildren();
800 latestBrowserSummary = null;
801 if (detachVisualResolutionListener) {
802 detachVisualResolutionListener();
803 detachVisualResolutionListener = null;
804 }
805 frame.title = `${fixture.title} detector fixture`;
806 frame.src = fixture.url;
807 if (updateHash) history.replaceState(null, '', `#${fixture.id}`);
808 }
809
810 frame.addEventListener('load', () => {
811 try {
812 frame.contentWindow?.scrollTo(0, 0);
813 } catch {
814 // Ignore iframe scroll reset failures.
815 }
816 setTimeout(runBrowserScan, 160);
817 });
818
819 document.querySelector('[data-rerun-browser]').addEventListener('click', runBrowserScan);
820 for (const button of document.querySelectorAll('[data-fixture-id]')) {
821 button.addEventListener('click', () => selectFixture(button.dataset.fixtureId));
822 }
823
824 window.addEventListener('hashchange', () => {
825 const nextId = location.hash ? location.hash.slice(1) : fixtures[0].id;
826 selectFixture(byId.has(nextId) ? nextId : fixtures[0].id, false);
827 });
828
829 const initialId = location.hash ? location.hash.slice(1) : fixtures[0].id;
830 selectFixture(byId.has(initialId) ? initialId : fixtures[0].id, false);
831 </script>
832</Base>