diff --git a/public/js/compare/filmstrip.js b/public/js/compare/filmstrip.js
index 04af4c1..2a3a886 100644
--- a/public/js/compare/filmstrip.js
+++ b/public/js/compare/filmstrip.js
@@ -69,22 +69,33 @@ function getFilmstripForPage(har, pageIndex) {
ms: ms,
time: (ms / 1000).toFixed(2),
img: dataBase + '/filmstrip/' + runId + '/ms_' +
- String(ms).padStart(6, '0') + '.jpg'
+ String(ms).padStart(6, '0') + '.jpg',
+ // Visual-progress percent at this change point. Carries
+ // through padFrames so each padded cell knows how rendered
+ // the page was at that moment — used to flag divergence
+ // between the two HARs.
+ progress: vp[ms]
};
});
}
}
// Legacy WPT path — the HAR's page object has a filmstrip array
- // directly. Translate into the same shape.
+ // directly. Translate into the same shape. WPT entries usually carry
+ // a `VisuallyComplete` percent on each frame; surface it as
+ // progress so divergence colouring still works.
const legacy = page.filmstrip || (page._wpt && page._wpt.filmstrip);
if (Array.isArray(legacy) && legacy.length > 0) {
return legacy.map(function (f) {
const ms = Math.round((f.time || 0) * 1000);
+ const prog = typeof f.progress === 'number' ? f.progress
+ : typeof f.VisuallyComplete === 'number' ? f.VisuallyComplete
+ : null;
return {
ms: ms,
time: (ms / 1000).toFixed(2),
- img: f.file || f.image || ''
+ img: f.file || f.image || '',
+ progress: prog
};
});
}
@@ -92,18 +103,74 @@ function getFilmstripForPage(har, pageIndex) {
return null;
}
+/**
+ * Resample a change-point frame list onto a uniform time grid so the
+ * rendered strip reads like an actual filmstrip: every cell represents
+ * the same time slice, repeating the last-known frame until the page
+ * visibly changes again. Without this the cells are spaced by *visual
+ * progress*, which makes "nothing happened for 2 s" look identical to
+ * "everything changed in 50 ms".
+ */
+function padFrames(frames, maxMs, stepMs) {
+ if (frames.length === 0) return [];
+ const padded = [];
+ let lastFrame = frames[0];
+ let nextIdx = 0;
+ for (let t = 0; t <= maxMs; t += stepMs) {
+ while (nextIdx < frames.length && frames[nextIdx].ms <= t) {
+ lastFrame = frames[nextIdx];
+ nextIdx++;
+ }
+ padded.push({
+ ms: t,
+ time: (t / 1000).toFixed(1),
+ img: lastFrame.img,
+ sourceMs: lastFrame.ms,
+ progress: lastFrame.progress
+ });
+ }
+ // Always end on the exact LastVisualChange frame so the final visual
+ // state is shown, even if it falls between two grid steps.
+ const lastAvailable = frames[frames.length - 1];
+ if (lastAvailable.ms !== padded[padded.length - 1].ms) {
+ padded.push({
+ ms: lastAvailable.ms,
+ time: (lastAvailable.ms / 1000).toFixed(1),
+ img: lastAvailable.img,
+ sourceMs: lastAvailable.ms,
+ progress: lastAvailable.progress
+ });
+ }
+ return padded;
+}
+
/**
* Build filmstrip data for both HARs in the comparison.
* Returns null if neither HAR has frames available.
*/
function getFilmstrip(har1, run1, har2, run2) {
- const frames1 = getFilmstripForPage(har1, run1) || [];
- const frames2 = getFilmstripForPage(har2, run2) || [];
- if (frames1.length === 0 && frames2.length === 0) return null;
+ const raw1 = getFilmstripForPage(har1, run1) || [];
+ const raw2 = getFilmstripForPage(har2, run2) || [];
+ if (raw1.length === 0 && raw2.length === 0) return null;
const maxMs = Math.max(
- frames1.length ? frames1[frames1.length - 1].ms : 0,
- frames2.length ? frames2[frames2.length - 1].ms : 0
+ raw1.length ? raw1[raw1.length - 1].ms : 0,
+ raw2.length ? raw2[raw2.length - 1].ms : 0
);
- return { frames1: frames1, frames2: frames2, maxMs: maxMs };
+
+ // 100 ms is sitespeed.io's native capture cadence, so every cell
+ // maps to a real on-disk frame whenever the page is actually
+ // changing. For very long pages we widen the step so the rail
+ // stops at ~120 cells.
+ let stepMs = 100;
+ while (Math.floor(maxMs / stepMs) + 1 > 120) {
+ stepMs *= 2;
+ }
+
+ return {
+ frames1: padFrames(raw1, maxMs, stepMs),
+ frames2: padFrames(raw2, maxMs, stepMs),
+ maxMs: maxMs,
+ stepMs: stepMs
+ };
}
diff --git a/public/js/compare/filmstripNav.js b/public/js/compare/filmstripNav.js
new file mode 100644
index 0000000..7bd7bb6
--- /dev/null
+++ b/public/js/compare/filmstripNav.js
@@ -0,0 +1,114 @@
+/* exported initFilmstripNav */
+
+//
+// Filmstrip navigation enhancements.
+//
+// - Mouse drag scrolls the rail horizontally (more discoverable than
+// the native scrollbar for long strips). We only kick in if the
+// user actually moves while holding down; a plain click still goes
+// through to the lightbox-trigger inside the cell.
+// - Arrow keys move focus between cells when one is focused. Home /
+// End jump to the start / end of the strip.
+//
+// Delegation-based so newly-generated rails (after Switch or after
+// uploading a new HAR pair) pick up the same behaviour without a
+// re-wire.
+//
+
+function initFilmstripNav() {
+ if (window.__filmstripNavInstalled) return;
+ window.__filmstripNavInstalled = true;
+
+ const DRAG_THRESHOLD_PX = 4;
+
+ document.body.addEventListener('mousedown', function (e) {
+ if (!(e.target instanceof HTMLElement)) return;
+ const rail = e.target.closest('.filmstrip-rail');
+ if (!rail) return;
+ // Ignore mousedown on the actual zoom button — let the lightbox
+ // handle that click. Drag-scroll triggers on mousedown anywhere
+ // else inside the rail (the padding, the column gap, etc.).
+ if (e.target.closest('.lightbox-trigger')) {
+ // Still want to allow drag if user moves before releasing; we
+ // arm a "candidate" state and promote to dragging only after
+ // crossing the threshold.
+ }
+ if (e.button !== 0) return;
+
+ const startX = e.pageX;
+ const startScroll = rail.scrollLeft;
+ let dragging = false;
+
+ function onMove(ev) {
+ const dx = ev.pageX - startX;
+ if (!dragging && Math.abs(dx) >= DRAG_THRESHOLD_PX) {
+ dragging = true;
+ rail.classList.add('is-dragging');
+ }
+ if (dragging) {
+ rail.scrollLeft = startScroll - dx;
+ ev.preventDefault();
+ }
+ }
+
+ function onUp(ev) {
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ if (dragging) {
+ rail.classList.remove('is-dragging');
+ // Swallow the click that would otherwise follow the
+ // mouseup — otherwise a drag would also open the lightbox.
+ ev.preventDefault();
+ ev.stopPropagation();
+ const swallow = function (ce) {
+ ce.stopPropagation();
+ ce.preventDefault();
+ document.removeEventListener('click', swallow, true);
+ };
+ document.addEventListener('click', swallow, true);
+ }
+ }
+
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ });
+
+ document.body.addEventListener('keydown', function (e) {
+ if (!(e.target instanceof HTMLElement)) return;
+ const cell = e.target.closest('.filmstrip-cell');
+ if (!cell) return;
+ const column = cell.closest('.filmstrip-column');
+ if (!column) return;
+
+ let next = null;
+ if (e.key === 'ArrowRight') {
+ const nextCol = column.nextElementSibling;
+ next = nextCol && nextCol.querySelector('.filmstrip-cell:not(.filmstrip-cell--missing)');
+ } else if (e.key === 'ArrowLeft') {
+ const prevCol = column.previousElementSibling;
+ next = prevCol && prevCol.querySelector('.filmstrip-cell:not(.filmstrip-cell--missing)');
+ } else if (e.key === 'ArrowDown') {
+ // Move from HAR1 cell to HAR2 cell within the same column.
+ next = column.querySelectorAll('.filmstrip-cell')[1];
+ } else if (e.key === 'ArrowUp') {
+ next = column.querySelectorAll('.filmstrip-cell')[0];
+ } else if (e.key === 'Home') {
+ const rail = column.parentElement;
+ next = rail && rail.querySelector('.filmstrip-cell:not(.filmstrip-cell--missing)');
+ } else if (e.key === 'End') {
+ const rail = column.parentElement;
+ const all = rail && rail.querySelectorAll('.filmstrip-cell:not(.filmstrip-cell--missing)');
+ next = all && all[all.length - 1];
+ } else {
+ return;
+ }
+
+ if (next) {
+ e.preventDefault();
+ next.focus();
+ next.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'smooth' });
+ }
+ });
+}
+
+document.addEventListener('DOMContentLoaded', initFilmstripNav);
diff --git a/public/js/compare/generate.js b/public/js/compare/generate.js
index 8807201..f1ddd46 100644
--- a/public/js/compare/generate.js
+++ b/public/js/compare/generate.js
@@ -65,6 +65,22 @@ function addVisualProgress(pageXray1, pageXray2, config, filmstrip) {
'visualProgressContent'
);
+ // Marker timings — vertical guide lines on the chart anchor the
+ // comparison in time so "when did this happen?" is answerable at
+ // a glance. We pick the standard set: First Visual Change, FCP,
+ // LCP and Speed Index. Each marker is per-HAR so a regressed LCP
+ // shows up as two side-by-side red lines rather than one.
+ function markerSet(p) {
+ const vm = p.visualMetrics || {};
+ const gw = p.googleWebVitals || {};
+ return [
+ { label: 'FVC', time: vm.FirstVisualChange, kind: 'fvc' },
+ { label: 'FCP', time: gw.firstContentfulPaint, kind: 'fcp' },
+ { label: 'LCP', time: gw.largestContentfulPaint, kind: 'lcp' },
+ { label: 'Speed Index', time: vm.SpeedIndex, kind: 'si' }
+ ].filter(function (m) { return typeof m.time === 'number' && m.time > 0; });
+ }
+
generateVisualProgress(
pageXray1.visualMetrics.VisualProgress,
pageXray2.visualMetrics.VisualProgress,
@@ -73,7 +89,9 @@ function addVisualProgress(pageXray1, pageXray2, config, filmstrip) {
thumbnails1: filmstrip ? sampleFrames(filmstrip.frames1, 6) : [],
thumbnails2: filmstrip ? sampleFrames(filmstrip.frames2, 6) : [],
label1: config.har1.label,
- label2: config.har2.label
+ label2: config.har2.label,
+ markers1: markerSet(pageXray1),
+ markers2: markerSet(pageXray2)
}
);
}
@@ -178,6 +196,12 @@ function generate(config) {
const slider = document.getElementById('harBlendSlider');
if (slider) slider.value = 0;
blendWaterfalls(0);
+ // Restore the user's saved side-by-side / overlay preference now
+ // that the waterfall DOM is populated. No-op when the wrapper is
+ // missing (e.g. during the loading view).
+ if (typeof applyWaterfallLayoutPreference === 'function') {
+ applyWaterfallLayoutPreference();
+ }
parseTemplate(
'pageXrayTemplate',
diff --git a/public/js/compare/generateVisualProgress.js b/public/js/compare/generateVisualProgress.js
index 2cf5f38..37b9e60 100644
--- a/public/js/compare/generateVisualProgress.js
+++ b/public/js/compare/generateVisualProgress.js
@@ -111,6 +111,46 @@ function generateVisualProgress(visualProgress1, visualProgress2, id, opts) {
add('path', { class: 'vp-series--1', d: stepPath(series[0]) });
add('path', { class: 'vp-series--2', d: stepPath(series[1]) });
+ // Marker lines — vertical guides at FCP / LCP / FVC / Speed Index
+ // for each HAR. Drawn under the series paths so the curves stay
+ // dominant, with a small label at the top so the eye can match a
+ // line to its metric without hovering. HAR1 markers go above the
+ // chart top, HAR2 markers go to the same line but a smaller font
+ // and a different colour series.
+ function drawMarkers(markers, hark) {
+ if (!markers || !markers.length) return;
+ // Cluster markers that fall within 2% of plot width so labels
+ // don't overlap when, say, FCP and FVC coincide.
+ const minGapPx = plotW * 0.02;
+ const sorted = markers.slice().sort(function (a, b) { return a.time - b.time; });
+ let lastX = -Infinity;
+ sorted.forEach(function (m) {
+ const t = m.time / 1000;
+ const xv = x(t);
+ const cls = 'vp-marker vp-marker--' + hark + ' vp-marker--' + m.kind;
+ add('line', {
+ class: cls,
+ x1: xv, x2: xv,
+ y1: y(0), y2: y(100)
+ });
+ // Stagger label Y when two markers are too close horizontally so
+ // their text doesn't collide.
+ const close = (xv - lastX) < minGapPx;
+ const labelY = hark === 1
+ ? (close ? padT + 22 : padT + 10)
+ : (close ? H - padB - 4 : H - padB - 16);
+ add('text', {
+ class: 'vp-marker-label vp-marker-label--' + hark + ' vp-marker--' + m.kind,
+ x: xv + 3,
+ y: labelY,
+ 'text-anchor': 'start'
+ }, m.label);
+ lastX = xv;
+ });
+ }
+ drawMarkers(opts.markers1, 1);
+ drawMarkers(opts.markers2, 2);
+
container.appendChild(svg);
// Thumbnail strips below the chart, aligned to the same time axis
diff --git a/public/js/compare/lightbox.js b/public/js/compare/lightbox.js
new file mode 100644
index 0000000..bd9a30a
--- /dev/null
+++ b/public/js/compare/lightbox.js
@@ -0,0 +1,112 @@
+/* exported initLightbox */
+
+//
+// Lightbox overlay for screenshots and filmstrip frames.
+//
+// Replaces the old "open image in a new tab" behaviour. A click on
+// any thumbnail wired to the lightbox shows the full image in an
+// in-page overlay so the user keeps their place in the comparison
+// instead of fighting the browser's tab management.
+//
+// Delegation-based: a single click listener on document.body catches
+// clicks on:
+// - .pageXrayCapture (final-screenshot row in page-x-ray)
+// - .filmstrip-frame img (filmstrip cells)
+// - any element with data-lightbox-src
+//
+// Esc closes; clicking the backdrop closes; clicking the image itself
+// stays open so accidental drags don't dismiss.
+//
+
+function initLightbox() {
+ if (document.getElementById('lightbox')) return;
+
+ const overlay = document.createElement('div');
+ overlay.id = 'lightbox';
+ overlay.className = 'lightbox';
+ overlay.setAttribute('role', 'dialog');
+ overlay.setAttribute('aria-modal', 'true');
+ overlay.setAttribute('aria-label', 'Image viewer');
+ overlay.hidden = true;
+ overlay.innerHTML =
+ '' +
+ '' +
+ '' +
+ '' +
+ '';
+ document.body.appendChild(overlay);
+
+ const img = overlay.querySelector('.lightbox-img');
+ const caption = overlay.querySelector('.lightbox-caption');
+ const closeBtn = overlay.querySelector('.lightbox-close');
+ const figure = overlay.querySelector('.lightbox-figure');
+
+ let lastFocused = null;
+
+ function open(src, alt) {
+ if (!src) return;
+ lastFocused = document.activeElement;
+ img.src = src;
+ img.alt = alt || '';
+ caption.textContent = alt || '';
+ overlay.hidden = false;
+ // Force a reflow so the opacity transition runs.
+ void overlay.offsetWidth;
+ overlay.classList.add('lightbox--open');
+ closeBtn.focus();
+ document.addEventListener('keydown', onKey);
+ }
+
+ function close() {
+ overlay.classList.remove('lightbox--open');
+ overlay.hidden = true;
+ img.src = '';
+ document.removeEventListener('keydown', onKey);
+ if (lastFocused && typeof lastFocused.focus === 'function') {
+ lastFocused.focus();
+ }
+ }
+
+ function onKey(e) {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ close();
+ }
+ }
+
+ overlay.addEventListener('click', function (e) {
+ // Anywhere except the figure itself dismisses the overlay.
+ if (e.target === overlay) close();
+ });
+ figure.addEventListener('click', function (e) {
+ // Clicks on the close button bubble here; everything else inside
+ // the figure (image, caption) is a no-op so the dialog stays open
+ // when the user re-clicks the image.
+ if (e.target.closest('.lightbox-close')) close();
+ });
+
+ // Click delegation — any .lightbox-trigger button containing an
+ // opens that image in the overlay. Buttons handle keyboard
+ // activation (Enter / Space) natively, no extra wiring needed.
+ document.body.addEventListener('click', function (e) {
+ const target = e.target instanceof HTMLElement ? e.target : null;
+ if (!target) return;
+ const trigger = target.closest('.lightbox-trigger');
+ if (!trigger) return;
+ const innerImg = trigger.querySelector('img');
+ if (!innerImg) return;
+ const src = innerImg.getAttribute('src');
+ let alt = innerImg.getAttribute('alt') || '';
+ if (!alt) {
+ // Filmstrip frames carry the timestamp in the sibling
+ // ; surface that as the lightbox caption.
+ const fig = trigger.closest('.filmstrip-frame');
+ const cap = fig && fig.querySelector('figcaption');
+ if (cap) alt = cap.textContent;
+ }
+ e.preventDefault();
+ open(src, alt);
+ });
+}
+
+document.addEventListener('DOMContentLoaded', initLightbox);
diff --git a/public/js/compare/templates.js b/public/js/compare/templates.js
index a566c14..95d4c6e 100644
--- a/public/js/compare/templates.js
+++ b/public/js/compare/templates.js
@@ -68,20 +68,82 @@ function pageXrayTemplate(d) {
function section(title, kind) {
const cls = 'pageXraySection' + (kind ? ' pageXraySection--' + kind : '');
- return '
' + h(title) + '
';
+ return '
' + h(title) + '
';
}
+ // Compute a Δ cell for a numeric metric. Every metric in this table
+ // is "lower is better" (request count, byte size, paint timing, CLS,
+ // long-task count), so a positive HAR2−HAR1 delta is a regression
+ // and renders in error red; a negative delta is an improvement and
+ // renders in success green. Non-numeric inputs produce an empty cell.
+ function diffCell(a, b, formatter) {
+ if (typeof a !== 'number' || typeof b !== 'number' ||
+ !isFinite(a) || !isFinite(b)) {
+ return '
';
+ }
+ const delta = b - a;
+ if (Math.abs(delta) < 1e-4) {
+ return '
';
+ }
+
+ // CLS is a unitless float; browsers report it at full precision
+ // (e.g. 0.05641193152186011). Three decimals is the granularity that
+ // matters for comparison and matches the sitespeed.io HTML report.
+ function fmtCLS(v) {
+ return typeof v === 'number' ? v.toFixed(3) : (v == null ? '' : v);
+ }
+
+ // Empty Δ cell — for rows where a delta wouldn't make sense
+ // (URLs, dates, captures).
+ const emptyDiff = '
';
+
+ // "Only show differences" toggle — read the user's last choice from
+ // localStorage so the preference survives reload. The button itself
+ // is rendered below; this just controls the initial class on the
+ // table so the page renders in the desired state without a flash.
+ const diffOnlyOn = (function () {
+ try { return localStorage.getItem('compare.diffOnly') === '1'; }
+ catch (e) { return false; }
+ })();
+ const diffOnlyClass = diffOnlyOn ? ' pageXrayTable--diff-only' : '';
+ const diffOnlyPressed = diffOnlyOn ? 'true' : 'false';
+
let html = '';
- html += '
';
+ html += '
';
+ // Visually-hidden caption — useful for screen readers and gives the
+ // table a programmatic name without taking up real estate.
+ html += '
';
// Setup (top of the table — first rows after the header, no section
@@ -103,31 +165,36 @@ function pageXrayTemplate(d) {
});
html += '';
}
- html += '';
+ html += '' + emptyDiff + '';
}
html += '
' +
+ diffCell(img1.transferSize, img2.transferSize, formatBytes) + '';
if (p1.renderBlocking && p2.renderBlocking) {
html += section('Render blocking', 'blocking');
html += '
Render blocking
' +
'
' + p1.renderBlocking.blocking + '
' +
- '
' + p2.renderBlocking.blocking + '
';
+ '
' + p2.renderBlocking.blocking + '
' +
+ diffCell(p1.renderBlocking.blocking, p2.renderBlocking.blocking) + '';
html += '
Potentially blocking
' +
'
' + p1.renderBlocking.potentiallyBlocking + '
' +
- '
' + p2.renderBlocking.potentiallyBlocking + '
';
+ '
' + p2.renderBlocking.potentiallyBlocking + '
' +
+ diffCell(p1.renderBlocking.potentiallyBlocking, p2.renderBlocking.potentiallyBlocking) + '';
html += '
In body parser blocking
' +
'
' + p1.renderBlocking.in_body_parser_blocking + '
' +
- '
' + p2.renderBlocking.in_body_parser_blocking + '
';
+ '
' + p2.renderBlocking.in_body_parser_blocking + '
' +
+ diffCell(p1.renderBlocking.in_body_parser_blocking, p2.renderBlocking.in_body_parser_blocking) + '';
}
if (p1.visualMetrics) {
@@ -174,112 +246,154 @@ function pageXrayTemplate(d) {
if (p1.visualMetrics[key] && p2.visualMetrics && p2.visualMetrics[key]) {
vmHtml += '
' + label + '
' +
'
' + formatTime(p1.visualMetrics[key]) + '
' +
- '
' + formatTime(p2.visualMetrics[key]) + '
';
+ '
' + formatTime(p2.visualMetrics[key]) + '
' +
+ diffCell(p1.visualMetrics[key], p2.visualMetrics[key], formatTime) + '';
}
});
if (vmHtml) html += section('Visual metrics', 'visual') + vmHtml;
}
if (p1.googleWebVitals && p2.googleWebVitals) {
- // CLS is a unitless float that browsers report at full precision
- // (e.g. 0.05641193152186011). Round to three decimals — that's
- // the granularity that matters for comparison and matches the
- // convention used in the sitespeed.io HTML report.
- function fmtCLS(v) {
- return typeof v === 'number' ? v.toFixed(3) : (v == null ? '' : v);
- }
html += section('Core Web Vitals', 'cwv');
html += '
' +
+ diffCell(p1.cpu.longTasks.tasks, p2.cpu.longTasks.tasks) + '';
+ }
+ if (cpuHtml) {
+ ensureCpuSection();
+ html += cpuHtml;
+ cpuHtml = '';
}
- if (cpuHtml) html += section('CPU', 'cpu') + cpuHtml;
+ }
+
+
+ // Sub-table for the CPU disclosure rows. The two HARs may report
+ // different sets of categories/events, so we union the names and
+ // render one row per name with HAR1 / HAR2 / Δ — same red-green
+ // pattern as the parent table, so a regression in scripting time is
+ // visible at the same glance as a regression in total bytes.
+ function cpuBreakdownTable(list1, list2, formatter, eventLabel) {
+ const map1 = {}, map2 = {};
+ (list1 || []).forEach(function (e) { map1[e.name] = e.value; });
+ (list2 || []).forEach(function (e) { map2[e.name] = e.value; });
+ const names = Object.keys(Object.assign({}, map1, map2)).sort();
+ let body = '';
+ names.forEach(function (name) {
+ const v1 = map1[name];
+ const v2 = map2[name];
+ body += '
' +
+ '
' + h(name) + '
' +
+ '
' + (v1 == null ? '—' : h(formatter(v1))) + '
' +
+ '
' + (v2 == null ? '—' : h(formatter(v2))) + '
' +
+ diffCell(v1, v2, formatter) +
+ '
';
+ });
+ return '
' +
+ '
' + h(eventLabel) + '
' +
+ '
' + h(config.har1.label) + '
' +
+ '
' + h(config.har2.label) + '
' +
+ '
Δ
' +
+ '
' + body + '
';
+ }
+
+ function fmtCategoryValue(v) {
+ return typeof v === 'number' ? formatTime(v) : (v == null ? '' : String(v));
+ }
+ function fmtEventValue(v) {
+ return typeof v === 'number' ? v.toFixed(3) : (v == null ? '' : String(v));
}
if (d.cpuCategories1 && d.cpuCategories2) {
- // Same group as the CPU long-task rows above when those exist;
- // emit the header here only if we didn't already (in which case
- // CPU has no long-task data but we still want the disclosure rows
- // visually under a heading).
- if (!(p1.cpu && p2.cpu && p1.cpu.longTasks && p2.cpu.longTasks)) {
- html += section('CPU', 'cpu');
- }
- html += '
CPU time spent by category ' +
- '
';
}
if (p1.meta.result && p2.meta.result) {
capturesHtml += '
' +
+ emptyDiff + '';
}
if (capturesHtml) html += section('Captures', 'captures') + capturesHtml;
@@ -288,41 +402,79 @@ function pageXrayTemplate(d) {
}
//
-// filmstripTemplate — two horizontal frame rails (HAR1 above HAR2),
-// each labeled, each scrollable. Frames are lazy-loaded so a 20-frame
-// run doesn't bombard the network on page load.
+// filmstripTemplate — one rail of *columns*, each column representing
+// the same timestamp in both HARs (HAR1 cell on top, HAR2 below,
+// shared timestamp underneath). The two padded frame arrays come in
+// the same length and grid step from getFilmstrip(), so iterating by
+// index lines them up cell-for-cell.
+//
+// Columns where the two HARs disagree on visual progress are tinted
+// red/amber so a regression like "HAR2 was still blank at 800 ms"
+// shows up before the user has to actually look at the pixels.
//
function filmstripTemplate(d) {
const config = d.config;
const fs1 = (d.filmstrip && d.filmstrip.frames1) || [];
const fs2 = (d.filmstrip && d.filmstrip.frames2) || [];
- function row(label, frames) {
- if (frames.length === 0) {
- return '
';
+ }
+
+ // Both arrays have the same length and grid step after padFrames(),
+ // but guard the lookup so a one-sided strip still renders.
+ const cells = Math.max(fs1.length, fs2.length);
+
+ function cellHtml(f, label, time, hark) {
+ const harkCls = ' filmstrip-cell--har' + hark;
+ const badge = '' + hark + '';
+ if (!f) {
+ return '