Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 | 1x 1x 1x 6x 44x 5x 5x 2x 2x 2x 2x 5x 2x 2x 1x 1x 2x 7x 2x 2x 7x 7x 7x 2x 4x 2x 2x 2x 2x 2x 4x 2x 2x 2x 1x 4x 2x 2x 2x 2x 4x 4x 4x 2x 6x 6x 1x 4x 18x 16x 16x 16x 5x 5x 5x 5x 5x 5x 5x 5x 2x 5x 1x 6x 3x 3x 3x 3x 3x 3x 2x 2x 2x 2x 3x 3x 3x 3x 3x 3x 3x 3x 1x 2x 1x 1x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 4x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 4x 2x 2x 2x 2x 2x 2x 2x 2x 2x | import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { DiffScreenshotResult, StyleChange } from './flow';
import { ChangedDecl, changedSelectors, isUntestableSelector } from './css-diff';
// Builds the self-contained visual-report.html and the PR-comment markdown
// summary from a differential run's screenshots, diffs, and CSS-bundle diff.
// Static report chrome lives in sibling files so the CSS/JS can be edited with
// real tooling; they carry no interpolation and are inlined verbatim below.
const here = path.dirname(fileURLToPath(import.meta.url));
const REPORT_CSS = fs.readFileSync(path.join(here, 'report.css'), 'utf8');
const REPORT_JS = fs.readFileSync(path.join(here, 'report.client.js'), 'utf8');
export interface ReportInput {
prLabel: string; // e.g. "PR #1623" or "working"
baselineLabel: string; // e.g. "develop"
results: DiffScreenshotResult[];
// Every declaration the PR changed; used to render "prop: old → new" per selector.
changes: ChangedDecl[];
// CI run page URL, if running in CI. Links to the run rather than the report
// itself since GitHub only serves the `vrt-report` artifact as a zip.
runUrl?: string;
// The PR's web URL, if known; omitted for local/working-tree runs (no PR).
prUrl?: string;
}
function dataUri(pngPath: string | null): string | null {
Eif (!pngPath || !fs.existsSync(pngPath)) return null;
const b64 = fs.readFileSync(pngPath).toString('base64');
return `data:image/png;base64,${b64}`;
}
function esc(s: string): string {
return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!);
}
// Build a selector → changed-declaration list index for fast per-page lookup.
function indexBySelector(changes: ChangedDecl[]): Map<string, ChangedDecl[]> {
const idx = new Map<string, ChangedDecl[]>();
for (const c of changes) {
const bare = c.selector.includes(' | ')
? c.selector.slice(c.selector.lastIndexOf(' | ') + 3).trim()
: c.selector.trim();
const list = idx.get(bare) ?? [];
list.push(c);
idx.set(bare, list);
}
return idx;
}
// `selector { prop: before → after }`; the full selector says which rule the PR touched.
function valueText(c: ChangedDecl): string {
return `${c.selector} { ${c.prop}: ${c.valueA ?? '(none)'} → ${c.valueB ?? '(none)'} }`;
}
function declText(c: ChangedDecl): string {
if (c.kind === 'changed') return `${c.prop}: ${c.valueA} → ${c.valueB}`;
Eif (c.kind === 'added') return `${c.prop}: ${c.valueB} (added)`;
return `${c.prop}: ${c.valueA} (deleted)`;
}
const KIND_LABEL = { added: 'Added', changed: 'Changed', removed: 'Removed' };
type PropChange = StyleChange['props'][number];
// Computed style lists every longhand twice (physical + logical). Drop the logical copies and fold
// four equal sides into the shorthand, so one `border-color` edit reads as one line, not eight.
function collapseProps(props: PropChange[]): PropChange[] {
const physical = props.filter(
(p) => !/(^|\s|-)(block|inline)(-|$)|(^|\s)(min-|max-)?(block|inline)-size$/.test(p.prop),
);
const groups = new Map<string, PropChange[]>();
for (const p of physical) {
const short = p.prop
.replace(/-(top|bottom)-(left|right)-radius$/, '-radius')
.replace(/-(top|right|bottom|left)(?=-|$)/, '');
const key = `${short}|${p.a}|${p.b}`;
groups.set(key, [...(groups.get(key) ?? []), p]);
}
return [...groups.entries()].flatMap(([key, ps]) =>
ps.length === 4 && ps[0].prop !== key.split('|')[0] ? [{ ...ps[0], prop: key.split('|')[0] }] : ps,
);
}
// One row per component: its changed elements and the distinct style changes across them.
function groupByComponent(
elements: StyleChange[],
): { component: string; elements: string[]; props: PropChange[]; rules: string[] }[] {
type Row = { elements: string[]; props: Map<string, PropChange>; rules: Set<string> };
const rows = new Map<string, Row>();
for (const e of elements) {
const component = e.components.join(', ') || '—';
const row: Row = rows.get(component) ?? { elements: [], props: new Map(), rules: new Set() };
row.elements.push(e.element);
for (const p of collapseProps(e.props)) row.props.set(`${p.prop}|${p.a}|${p.b}`, p);
(e.rules ?? []).forEach((r) => row.rules.add(r));
rows.set(component, row);
}
return [...rows.entries()].map(([component, r]) => ({
component,
elements: r.elements,
props: [...r.props.values()],
rules: [...r.rules],
}));
}
// A declared prop explains a computed one when it is that prop, its shorthand (`border` → `border-color`),
// or a size bound that moves the computed size (`min-width` → `width`).
const explains = (declProp: string, computedProp: string): boolean =>
declProp === computedProp ||
computedProp.startsWith(`${declProp}-`) ||
declProp.replace(/^(min|max)-/, '') === computedProp;
// Authored declarations (from the PR's CSS diff) behind a row's computed changes, plus the computed
// changes nothing explains (token or inherited changes), which are shown as computed values.
function declaredFor(
props: PropChange[],
rules: string[],
idx: Map<string, ChangedDecl[]>,
): { decls: ChangedDecl[]; rest: PropChange[] } {
const candidates = rules.flatMap((selector) => idx.get(selector) ?? []);
const used = new Set<ChangedDecl>();
const rest: PropChange[] = [];
for (const p of props) {
const hits = candidates.filter((d) => explains(d.prop, p.prop));
hits.forEach((h) => used.add(h));
if (!hits.length) rest.push(p);
}
return { decls: [...used], rest };
}
// Components whose own elements' computed style changed on this page. Without style evidence
// (e.g. replayed shots), declared components are suspects only if a changed rule matched the page.
function changedComponents(r: DiffScreenshotResult): string[] {
Iif (!statusOf(r).changed) return [];
if (r.styleDiff?.comparable) return r.styleDiff.components;
return r.matchedSelectors?.length ? (r.components ?? []) : [];
}
function pct(r: DiffScreenshotResult): string {
return r.dimensionMismatch ? '—' : `${(r.ratio * 100).toFixed(3)}%`;
}
function statusOf(r: DiffScreenshotResult): { icon: string; text: string; changed: boolean } {
if (r.missed) return { icon: '⛔', text: r.missed.error ? 'Not captured' : 'Skipped', changed: false };
Iif (r.noise) return { icon: '✅', text: 'No change', changed: false };
Iif (r.dimensionMismatch) return { icon: '⚠', text: 'Reflow', changed: true };
Eif (r.changedPixels > 0) return { icon: '⚠', text: 'Review', changed: true };
return { icon: '✅', text: 'No change', changed: false };
}
// An "orphan" is a changed selector that matched zero elements on every tested
// page — a silent blind spot. Pseudo/state selectors querySelectorAll can't
// parse are split into "untestable" rather than counted as orphans, since we
// can't know either way without rendering that state.
export interface Coverage {
// Changed selectors that matched ≥1 element on ≥1 tested page.
covered: string[];
// Changed, statically testable, but matched nothing anywhere — the blind spot.
orphans: string[];
// Changed but unparseable by querySelectorAll (pseudo/state) — untestable here.
untestable: string[];
}
function computeCoverage(changes: ChangedDecl[], results: DiffScreenshotResult[]): Coverage {
const changed = changedSelectors(changes); // bare selectors, at-rule prefix stripped
// Union of every selector that matched on any page.
const matched = new Set<string>();
for (const r of results) {
for (const m of r.matchedSelectors ?? []) matched.add(m.selector);
}
const covered: string[] = [];
const orphans: string[] = [];
const untestable: string[] = [];
for (const sel of changed) {
if (matched.has(sel)) covered.push(sel);
else Eif (isUntestableSelector(sel)) untestable.push(sel);
else orphans.push(sel);
}
return { covered, orphans, untestable };
}
function missedText(r: DiffScreenshotResult): string {
return r.missed?.error ?? `Skipped: depends on ${r.missed?.dependsOn}, which failed.`;
}
// Run time in readable UTC, so a downloaded report or comment can be told apart from a stale one.
function ranAtUtc(): string {
return new Date().toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
}
export function buildCommentMarkdown(input: ReportInput): string {
const { results, baselineLabel, prLabel, runUrl } = input;
const idx = indexBySelector(input.changes);
const changedCount = results.filter((r) => statusOf(r).changed).length;
const cov = computeCoverage(input.changes, results);
// Kept simple: "Likely Cause" shows only the bare selector (a full value dump
// previously overflowed the table); full attribution lives in the HTML report.
const changedRows = results
.filter((r) => statusOf(r).changed)
.map((r) => {
const sels = Array.from(new Set((r.matchedSelectors ?? []).map((m) => m.selector)));
const cause = sels.length
? sels
.slice(0, 2)
.map((s) => `\`${s}\``)
.join(', ') + (sels.length > 2 ? ` +${sels.length - 2}` : '')
: '_see report_';
const comps = changedComponents(r).join(', ') || '_see report_';
return `| ${r.name} | ${comps} | **${pct(r)}** | ${cause} |`;
});
// Orphan warning only; the happy-path "all clear" line is left to the HTML report.
const orphanLine =
cov.orphans.length > 0
? `> ⚠️ **Not tested: ${cov.orphans.length} changed rule${cov.orphans.length === 1 ? '' : 's'}.** No tested page has an element ${cov.orphans.length === 1 ? 'it matches' : 'they match'}, so ${cov.orphans.length === 1 ? 'its' : 'their'} impact wasn't checked: ${cov.orphans.map((s) => `\`${s}\``).join(', ')}.`
: '';
const reportLine = runUrl
? `📄 **HTML Report** → [open this run](${runUrl}#artifacts) → download the \`vrt-report-*\` artifact for CSS attribution & root cause`
: '📄 **HTML Report** → download the `vrt-report-*` artifact for CSS attribution & root cause';
const missed = results.filter((r) => r.missed);
// No CSS diff means no pages ran (see diff.ts); this is the only pass with nothing tested.
const noCss = !input.changes.length && !results.length;
// Never claim a clean pass when pages went untested.
const status = noCss
? '✅ No SLDS CSS Changes'
: changedCount > 0
? `⚠️ Review Required${missed.length ? ' (incomplete)' : ''}`
: missed.length
? '⚠️ Incomplete'
: '✅ No Visual Change';
const missedRows = missed.map((r) => `| ${r.name} | ${missedText(r).replace(/\|/g, '\\|')} |`);
// The sticky comment keeps GitHub's original creation date ("N days ago"), so this is the real run time.
const ranAt = ranAtUtc();
if (noCss)
return [
`# 🖼️ SDS Core VRT Report`,
'',
`**${baselineLabel} → ${prLabel}**`,
`_Last run: ${ranAt}_`,
'',
'## Summary',
'',
'| Result | Value |',
'| ------ | ----- |',
`| Status | ${status} |`,
'',
`${prLabel} builds the same SLDS 2 CSS as ${baselineLabel}, so no Core pages were tested.`,
'<!-- VRT_CORE_COMMENT -->',
'',
].join('\n');
return [
`# 🖼️ SDS Core VRT Report`,
'',
`**${baselineLabel} → ${prLabel}**`,
`_Last run: ${ranAt}_`,
'',
'## Summary',
'',
'| Result | Value |',
'| ------ | ----- |',
`| Pages Tested | ${results.length} |`,
`| Pages Changed | ${changedCount} |`,
...(missed.length ? [`| Pages Not Captured | ${missed.length} |`] : []),
`| Status | ${status} |`,
'',
...(changedCount > 0
? [
"**Your changes to SLDS caused visual differences on Core pages and need a review.** Check the report. If the differences are a problem, fix them. If they're intended, or you find a false positive or an environment issue, add the `vrt:core-approved` label to pass the job.",
'',
]
: []),
...(missedRows.length
? ['## Not Captured', '', '| Page | Reason |', '| ---- | ------ |', ...missedRows, '']
: []),
// Skip the table entirely when nothing changed (an empty table reads as broken).
...(changedRows.length
? [
'## Changed Pages',
'',
'| Page | Changed Components | Visual Diff | Likely Cause |',
'| ---- | ------------------ | ----------: | ------------ |',
...changedRows,
'',
]
: []),
...(orphanLine ? [orphanLine, ''] : []),
'## Review Results',
'',
reportLine,
'',
'---',
'',
`_Differential VRT: ${baselineLabel} CSS vs ${prLabel} CSS on the same live page. Environmental drift eliminated by design. Regressions already present in ${baselineLabel} are not flagged._`,
'<!-- VRT_CORE_COMMENT -->',
// Trailing newline: CI appends this to a GITHUB_OUTPUT heredoc, and without
// it the closing delimiter lands on the marker's line ("Matching delimiter not found").
'',
].join('\n');
}
// The run stopped before any page: the org was unreachable or login failed. `reason` must be redacted.
export function buildOrgUnavailableMarkdown(
input: Pick<ReportInput, 'prLabel' | 'baselineLabel'> & { reason: string; docUrl?: string },
): string {
const org = input.docUrl
? `[the test org](${input.docUrl})`
: 'the test org section of the VRT Core README';
return [
`# 🖼️ SDS Core VRT Report`,
'',
`**${input.baselineLabel} → ${input.prLabel}**`,
`_Last run: ${ranAtUtc()}_`,
'',
`❌ **Org unavailable.** The shared test org couldn't be used, so no pages were tested. It may be down or expired, or its login changed: see ${org}.`,
'',
// Code span: the reason can carry PR-controlled step text, so keep it from rendering as Markdown/HTML.
`_Reason:_ \`${input.reason.replace(/\s+/g, ' ').replace(/`/g, "'")}\``,
'<!-- VRT_CORE_COMMENT -->',
'',
].join('\n');
}
export function buildReportHtml(input: ReportInput): string {
const { results, baselineLabel, prLabel, prUrl } = input;
const idx = indexBySelector(input.changes);
const cov = computeCoverage(input.changes, results);
// Surfaces orphans (unverified) and untestable (pseudo/state) selectors from computeCoverage.
const covList = (sels: string[]) => sels.map((s) => `<code>${esc(s)}</code>`).join(', ');
// Only the actionable case: an all-covered run needs no banner.
const coverageBanner =
cov.orphans.length > 0
? `<div class="coverage warn">
<strong>⚠ Not tested: ${cov.orphans.length} changed rule${cov.orphans.length === 1 ? '' : 's'}.</strong>
No tested page has an element ${cov.orphans.length === 1 ? 'it matches' : 'they match'}, so ${cov.orphans.length === 1 ? 'its' : 'their'} impact wasn't checked. Review ${cov.orphans.length === 1 ? 'it' : 'them'} by hand, or add a page that shows ${cov.orphans.length === 1 ? 'it' : 'them'} to core-pages.json.
<div class="sels">${covList(cov.orphans)}</div>
${cov.untestable.length ? `<div class="note">Also not checked: ${cov.untestable.length} rule${cov.untestable.length === 1 ? '' : 's'} that only apply in a state (hover, focus, ...): ${covList(cov.untestable)}</div>` : ''}
</div>`
: '';
// Links to the PR in a new tab when its URL is known, else plain text.
const prLabelHtml = prUrl
? `<a href="${esc(prUrl)}" target="_blank" rel="noopener">${esc(prLabel)}</a>`
: esc(prLabel);
const sections = results
.map((r) => {
Iif (r.missed) {
const reason = r.missed.error
? `<p class="reflow">⛔ Not captured. A step on the way to this page failed: <code>${esc(r.missed.error)}</code></p>`
: `<p class="muted">Skipped: depends on <a href="#${esc(r.missed.dependsOn!)}">${esc(r.missed.dependsOn!)}</a>, which failed.</p>`;
return `
<section id="${esc(r.name)}" class="missed" data-components="${esc((r.components ?? []).join('|'))}">
<h2>${esc(r.name)} <span class="badge">⛔ ${r.missed.error ? 'Not captured' : 'Skipped'}</span></h2>
${reason}
</section>`;
}
const componentsLine = r.components?.length
? `<p class="components"><strong>Components Covered:</strong> ${esc(r.components.join(', '))}</p>`
: '';
const st = statusOf(r);
// Left panel is the static develop reference; the right panel is an
// interactive viewer that swaps in place among develop/pr/highlight.
const aUri = dataUri(r.shotAPath);
const bUri = dataUri(r.shotBPath);
const diffUri = dataUri(r.diffPath);
const causedRows = (r.matchedSelectors ?? [])
.flatMap((m) => {
const decls = idx.get(m.selector) ?? [];
Iif (!decls.length) {
return [
`<tr><td class="sel">${esc(m.selector)}</td><td><em>changed elsewhere in bundle</em></td></tr>`,
];
}
return decls.map(
(d) => `<tr><td class="sel">${esc(m.selector)}</td><td>${esc(declText(d))}</td></tr>`,
);
})
.join('');
const sd = r.styleDiff;
const rules = new Map<string, ChangedDecl['kind']>();
for (const g of groupByComponent(sd?.elements ?? [])) {
for (const d of declaredFor(g.props, g.rules, idx).decls) rules.set(valueText(d), d.kind);
}
const styleBlock =
st.changed && sd?.comparable && rules.size
? `<table class="caused"><thead><tr><th>Changed rules found on this page</th></tr></thead><tbody>${[
...rules,
]
.map(
([t, kind]) =>
`<tr><td><span class="tag ${kind}">${KIND_LABEL[kind]}</span> <code>${esc(t)}</code></td></tr>`,
)
.join('')}</tbody></table>`
: '';
const px = `${r.changedPixels.toLocaleString()} px differ`;
const noiseText = {
'no-css': `${px} but this PR changes no SLDS CSS, so the change is unrelated to SLDS. Ignored.`,
unreachable: `${px} but none of the changed rules match anything on this page, so the change is unrelated to SLDS. Ignored.`,
style: `${px} but no SLDS component's computed style changed, so this is rendering noise and is ignored.`,
};
const noiseNote = r.noise
? `<p class="muted">${noiseText[r.noiseReason ?? (sd?.comparable ? 'style' : 'unreachable')]}</p>`
: '';
const causedBlock = r.noise
? noiseNote
: styleBlock
? styleBlock
: st.changed && causedRows
? `<table class="caused"><thead><tr><th>Selector</th><th>CSS Change</th></tr></thead><tbody>${causedRows}</tbody></table>`
: st.changed
? `<p class="muted">No changed rule matched elements on this page. Pseudo/state rules can't be checked statically, so review with Highlight: ${changedSelectors(
input.changes,
)
.filter(isUntestableSelector)
.map((s) => `<code>${esc(s)}</code>`)
.join(', ')}</p>`
: `<p class="muted">No visual change.</p>`;
const reflowNote = r.dimensionMismatch
? `<p class="reflow">⚠ Page reflowed between shots (A ${r.dimensionMismatch.a.join('×')} vs B ${r.dimensionMismatch.b.join('×')}). Pixel diff not meaningful — review the two columns directly.</p>`
: '';
// Default mode is "pr"; develop has no button since it already owns the
// left column (still reachable on the right via hold-to-peek/Space/Blink).
const modes = [
{ key: 'pr', label: esc(prLabel), uri: bUri },
{ key: 'highlight', label: 'Highlight', uri: diffUri },
].filter((m) => m.uri);
const defaultMode = bUri ? 'pr' : diffUri ? 'highlight' : 'develop';
const canBlink = !!(aUri && bUri);
// Render whenever any frame exists, even with no button modes (e.g. only
// develop captured) — the script's init fallback still shows it.
const viewer =
aUri || bUri || diffUri
? `
<div class="viewer" data-default="${defaultMode}"
${aUri ? `data-develop="${aUri}"` : ''}
${bUri ? `data-pr="${bUri}"` : ''}
${diffUri ? `data-highlight="${diffUri}"` : ''}>
<div class="controls" role="group" aria-label="Compare ${esc(baselineLabel)} and ${esc(prLabel)}">
${modes
.map(
(m) =>
`<button type="button" class="mode${m.key === defaultMode ? ' active' : ''}" data-mode="${m.key}">${m.label}</button>`,
)
.join('')}
${canBlink ? `<button type="button" class="mode blink" data-mode="blink">⚡ Blink</button>` : ''}
<span class="hint">hold image to peek ${esc(baselineLabel)}</span>
</div>
<img class="frame" alt="${esc(prLabel)}">
</div>`
: '<p class="muted">no image</p>';
// The right figure's header is its .controls row rather than a figcaption,
// sharing a bottom-aligned band with the left figcaption (see CSS) so both
// screenshots start on the same row.
const cols = `
<div class="cols">
<figure><figcaption>${esc(baselineLabel)} (reference)</figcaption>
${aUri ? `<img src="${aUri}" alt="${esc(baselineLabel)}">` : '<p class="muted">no image</p>'}</figure>
<figure>${viewer}</figure>
</div>`;
return `
<section id="${esc(r.name)}" class="${st.changed ? 'changed' : 'clean'}" data-components="${esc((r.components ?? []).join('|'))}">
<h2>${esc(r.name)} <span class="badge${st.changed ? ' review' : ''}">${st.icon} ${st.text}</span></h2>
<p class="stat">${r.changedPixels.toLocaleString()} px ${r.noise ? 'differ (noise)' : 'changed'} · ${pct(r)}${r.flowName ? ` · ${esc(r.flowName)}` : ''}</p>
${componentsLine}
${reflowNote}
${cols}
${causedBlock}
</section>`;
})
.join('\n');
// Component -> pages that list it, in flow order; the dropdowns jump to the first matching page.
const byComponent = new Map<string, DiffScreenshotResult[]>();
for (const r of results) {
for (const c of r.components ?? []) byComponent.set(c, [...(byComponent.get(c) ?? []), r]);
}
// A proven change counts even when the flow didn't declare that component for the page.
for (const r of results) {
for (const c of changedComponents(r)) {
const rs = byComponent.get(c) ?? [];
Eif (!rs.includes(r)) byComponent.set(c, [...rs, r]);
}
}
const components = [...byComponent.keys()].sort((a, b) => a.localeCompare(b));
const differing = components.filter((c) =>
byComponent.get(c)!.some((r) => changedComponents(r).includes(c)),
);
// Hides every page that doesn't cover the chosen component; components with a proven change are marked.
const filter = `<label class="jump">Show pages using
<select id="component-filter">
<option value="">--</option>
${components.map((c) => `<option value="${esc(c)}">${differing.includes(c) ? '⚠ ' : ''}${esc(c)}</option>`).join('')}
</select></label>`;
const changedPages = results.filter((r) => statusOf(r).changed);
const unchangedPages = results.filter((r) => !statusOf(r).changed && !r.missed);
const missedPages = results.filter((r) => r.missed);
const group = (label: string, rs: DiffScreenshotResult[]) =>
`<optgroup label="${label} (${rs.length})">${
rs.length
? rs.map((r) => `<option value="${esc(r.name)}">${esc(r.name)}</option>`).join('')
: '<option disabled>None</option>'
}</optgroup>`;
const pagesDropdown = `<label class="jump">Pages (${results.length})
<select>
<option value="">Select…</option>
${group('Changed', changedPages)}${group('Unchanged', unchangedPages)}${missedPages.length ? group('Not captured', missedPages) : ''}
</select></label>`;
// Defaults to changed pages (and any not captured) when there are any, so review starts where it matters.
const changedOnly = `<label class="jump"><input type="checkbox" id="changed-only"${
changedPages.length || missedPages.length ? ' checked' : ''
}> Changed pages only</label>`;
const pager = `<div class="pager"><button type="button" id="prev-page">‹ Prev</button><span id="page-pos"></span><button type="button" id="next-page">Next ›</button></div>`;
const nav = [filter, pagesDropdown, changedOnly, pager].join('');
// One-line verdict so the outcome is clear before any detail.
const cssChanged = input.changes.length > 0;
const plural = (n: number) => `${n} page${n === 1 ? '' : 's'}`;
const verdict = missedPages.length
? {
cls: 'review',
text: `⚠ Incomplete: ${missedPages.length} of ${plural(results.length)} not captured (see Pages ▸ Not captured). ${
changedPages.length
? `Visual changes found on ${plural(changedPages.length)}.`
: `No visual changes on the ${plural(results.length - missedPages.length)} captured.`
}`,
}
: changedPages.length > 0
? {
cls: 'review',
text: `⚠ Visual changes found on ${plural(changedPages.length)}.`,
}
: cssChanged
? {
cls: 'pass',
text: `✅ No visible impact. The CSS changed (${input.changes.length} declaration${input.changes.length === 1 ? '' : 's'}) but none of the ${results.length} tested pages render differently.`,
}
: {
cls: 'pass',
text: `✅ No changes caused. The ${esc(prLabel)} CSS is identical to ${esc(baselineLabel)}, so nothing can render differently.`,
};
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SDS Core VRT Report • ${esc(baselineLabel)} → ${esc(prLabel)}</title>
<style>
${REPORT_CSS}</style></head>
<body>
<div class="sticky-top">
<header>
<h1>🖼️ SDS Core VRT Report</h1>
<div class="verdict ${verdict.cls}">${verdict.text}</div>
<div class="sub">${esc(baselineLabel)} → ${prLabelHtml} · Differential VRT (same live page, two CSS bundles, one load) · Last run: ${ranAtUtc()}</div>
</header>
${coverageBanner}
<nav>${nav}</nav>
</div>
<main>${sections}</main>
<script>
${REPORT_JS}</script>
<footer>
Differential VRT report. Each page was loaded once; ${esc(baselineLabel)} CSS and ${esc(prLabel)} CSS were injected
into that single load, so everything except the CSS cancels out of the diff. The right panel swaps among
<strong>${esc(prLabel)}</strong> (the clean new design), <strong>Highlight</strong> (magenta = changed pixels), and
<strong>Blink</strong> (alternates ${esc(baselineLabel)}⇄${esc(prLabel)} so small shifts pop) — or hold the image /
press Space to peek ${esc(baselineLabel)}. "Caused by" lists changed rules that appear on the page (correlation, not
per-pixel proof); selectors using pseudo/state are not statically testable and may be omitted.
</footer>
</body></html>`;
}
|