index.astro

  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>