diff --git a/index.html b/index.html index 10bcfb9..1516141 100644 --- a/index.html +++ b/index.html @@ -50,6 +50,10 @@ + + + + @@ -70,56 +74,77 @@ -
+
-
-
-
-

- Drop HAR files here (max two) or click to upload. You can add .har - and .har.gz files. -

-
-

- Or add the URL to your HAR files and press compare: -

-
- - -

- You can also use a - configuration file - or a - Github gist. -

-

- - +

+

See what changed between two HAR files

+

+ Understand regressions by comparing your HAR files: metrics with + statistics across all runs, video, filmstrip and waterfalls on one + timeline. +

+ +
+ +

Drop your HAR files here

+

+ One or two .har or .har.gz files, or a saved comparison.

+ Choose files +
+

The files stay in your browser. Nothing is uploaded.

+

+ Tip + HAR files from sitespeed.io and + Browsertime + also give you visual metrics, CPU timings and more runs to compare. When the + HAR links to the result (sitespeed.io with --resultBaseURL), you + also get the video and filmstrip. +

+ +
or
+ +

- Read the full documentation + Add the URL to your HAR files and press compare:

- - -
-
+ + +
+ + +
+ +
+ +
@@ -133,17 +158,47 @@

Compare

-
+
+ + +

HAR comparison

-
-
-
-
+
+
+
+
+
+
+

Timeline

+ +
+
+
+
+ + + + + + + + + + 0.000 s +
+
+

Visual progress

+

Lines: the selected runs. Light color: from the slowest to the fastest run.

+
+

Shaded red: the two runs differ by 20 percentage points or more in visual progress.

+
+
+

Waterfall

+ +
Waterfall
-
-
-
-
+
+

All metrics

+
+
+
+ + +
+

+
+
+
+

Request changes

+
+

+ +
+
+
diff --git a/public/js/compare/annotate.js b/public/js/compare/annotate.js new file mode 100644 index 0000000..3d877e0 --- /dev/null +++ b/public/js/compare/annotate.js @@ -0,0 +1,365 @@ +/* exported addDrawingLayer, setDrawTool, undoDrawing, clearDrawings, saveDrawingPng */ + +// Lines, circles and text drawn on the waterfalls. A shape keeps its +// position in milliseconds (x) and waterfall rows (y), so it stays on +// the same request when the waterfall is drawn again at another width. +const drawing = { + tool: null, + layers: {}, + // {key, undo} for each change, so Undo also reverts moves and Clear. + history: [] +}; +// Where each text was drawn, to find it under the pointer. +const textBoxes = new WeakMap(); + +/** + * @param {string} key 'har1' or 'har2' + * @param {Object} renderer the waterfall-tools renderer + * @param {HTMLElement} container holds the waterfall canvas + * @param {Array} shapes the shapes of this run, changed in place + */ +function addDrawingLayer(key, renderer, container, shapes) { + const base = container.querySelector('canvas'); + if (!base) return; + const overlay = document.createElement('canvas'); + overlay.className = 'wf-draw'; + container.appendChild(overlay); + const layer = { key, renderer, container, base, overlay, shapes, draft: null }; + drawing.layers[key] = layer; + drawing.history = drawing.history.filter(h => h.key !== key); + + overlay.addEventListener('pointerdown', e => startShape(layer, e)); + overlay.addEventListener('pointermove', e => { + if (layer.moving) moveShape(layer, pointAt(layer, e)); + else if (layer.draft) { + layer.draft.b = pointAt(layer, e); + redraw(layer); + } else if (drawing.tool && dimensions(layer)) { + overlay.style.cursor = shapeAt(layer, pointAt(layer, e)) ? 'move' : ''; + } + }); + overlay.addEventListener('pointerup', () => { + if (!layer.moving) { + endShape(layer); + return; + } + // A click without a drag moved nothing, so there is nothing to undo. + if (layer.moving.shape.a === layer.moving.a) drawing.history.pop(); + layer.moving = null; + }); + overlay.addEventListener('pointercancel', () => { + layer.draft = null; + layer.moving = null; + redraw(layer); + }); + // waterfall-tools sets the canvas size after the container changes. + const resize = new ResizeObserver(() => redraw(layer)); + resize.observe(container); + resize.observe(base); + redraw(layer); +} + +function dimensions(layer) { + const rows = layer.renderer && layer.renderer.drawnRows; + return rows && rows.dimensions && rows.dimensions.canvasWidth ? rows.dimensions : null; +} + +// Screen pixels per waterfall pixel. +function scaleOf(layer, dims) { + return layer.base.clientWidth / dims.canvasWidth; +} + +function pointAt(layer, e) { + const dims = dimensions(layer); + const r = layer.base.getBoundingClientRect(); + const s = scaleOf(layer, dims); + return { + ms: ((e.clientX - r.left) / s - dims.labelsWidth) / dims.widthPerMs, + y: (e.clientY - r.top) / s + }; +} + +function startShape(layer, e) { + if (!drawing.tool || !dimensions(layer)) return; + e.preventDefault(); + const p = pointAt(layer, e); + const shape = shapeAt(layer, p); + if (shape) { + layer.overlay.setPointerCapture(e.pointerId); + startMove(layer, shape, p); + return; + } + if (drawing.tool === 'text') { + askText(layer, p, e); + return; + } + layer.overlay.setPointerCapture(e.pointerId); + layer.draft = { type: drawing.tool, a: p, b: p }; +} + +function xOf(dims, p) { + return dims.labelsWidth + p.ms * dims.widthPerMs; +} + +// The top shape whose line, outline or text is under the point. +function shapeAt(layer, p) { + const dims = dimensions(layer); + const px = xOf(dims, p); + const near = 6; + for (let i = layer.shapes.length - 1; i >= 0; i--) { + const shape = layer.shapes[i]; + const ax = xOf(dims, shape.a); + const ay = shape.a.y; + const bx = xOf(dims, shape.b); + const by = shape.b.y; + if (shape.type === 'line') { + const length = Math.hypot(bx - ax, by - ay) || 1; + const t = Math.max(0, Math.min(1, ((px - ax) * (bx - ax) + (p.y - ay) * (by - ay)) / (length * length))); + if (Math.hypot(px - (ax + t * (bx - ax)), p.y - (ay + t * (by - ay))) <= near) return shape; + } else if (shape.type === 'circle') { + const rx = Math.max(Math.abs(bx - ax) / 2, 3); + const ry = Math.max(Math.abs(by - ay) / 2, 3); + const d = Math.hypot((px - (ax + bx) / 2) / rx, (p.y - (ay + by) / 2) / ry); + if (Math.abs(d - 1) * Math.min(rx, ry) <= near) return shape; + } else if (shape.type === 'text') { + const box = textBoxes.get(shape); + if (box && px >= box.x - 4 && px <= box.x + box.width + 4 && Math.abs(p.y - ay) <= 10) return shape; + } + } + return null; +} + +function startMove(layer, shape, p) { + const a = shape.a; + const b = shape.b; + layer.moving = { shape, from: p, a, b }; + drawing.history.push({ + key: layer.key, + undo: () => { + shape.a = a; + shape.b = b; + } + }); +} + +function moveShape(layer, p) { + const { shape, from, a, b } = layer.moving; + const dms = p.ms - from.ms; + const dy = p.y - from.y; + shape.a = { ms: a.ms + dms, y: a.y + dy }; + shape.b = { ms: b.ms + dms, y: b.y + dy }; + redraw(layer); +} + +function endShape(layer) { + const shape = layer.draft; + layer.draft = null; + if (!shape) return; + const dims = dimensions(layer); + const dx = (shape.b.ms - shape.a.ms) * dims.widthPerMs; + const dy = shape.b.y - shape.a.y; + // A click without a drag is not a shape. + if (Math.hypot(dx, dy) >= 6) addShape(layer, shape); + redraw(layer); +} + +function addShape(layer, shape) { + layer.shapes.push(shape); + drawing.history.push({ + key: layer.key, + undo: () => layer.shapes.splice(layer.shapes.indexOf(shape), 1) + }); + redraw(layer); +} + +// Show a text field where the user clicked. Enter adds the text, Esc +// or an empty field cancels it. +function askText(layer, p, e) { + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'wf-draw-input'; + input.setAttribute('aria-label', 'Text to add to the waterfall'); + const r = layer.container.getBoundingClientRect(); + input.style.left = (e.clientX - r.left) + 'px'; + input.style.top = (e.clientY - r.top) + 'px'; + layer.container.appendChild(input); + let done = false; + const finish = keep => { + if (done) return; + done = true; + const text = input.value.trim(); + input.remove(); + if (keep && text) addShape(layer, { type: 'text', a: p, b: p, text }); + }; + input.addEventListener('keydown', ev => { + if (ev.key === 'Enter') finish(true); + if (ev.key === 'Escape') { + ev.stopPropagation(); + finish(false); + } + }); + input.addEventListener('blur', () => finish(true)); + // Focus after this pointer event, else the canvas takes the focus back. + setTimeout(() => input.focus(), 0); +} + +function redraw(layer) { + const dims = dimensions(layer); + const { base, overlay } = layer; + if (!dims || !base.isConnected) return; + const ratio = window.devicePixelRatio || 1; + overlay.style.left = base.offsetLeft + 'px'; + overlay.style.top = base.offsetTop + 'px'; + overlay.style.width = base.clientWidth + 'px'; + overlay.style.height = base.clientHeight + 'px'; + overlay.width = Math.round(base.clientWidth * ratio); + overlay.height = Math.round(base.clientHeight * ratio); + const ctx = overlay.getContext('2d'); + const s = scaleOf(layer, dims) * ratio; + ctx.setTransform(s, 0, 0, s, 0, 0); + drawShapes(ctx, dims, layer.draft ? layer.shapes.concat(layer.draft) : layer.shapes); +} + +// Draw in waterfall pixels. The caller sets the scale. +function drawShapes(ctx, dims, shapes) { + const color = getComputedStyle(document.documentElement).getPropertyValue('--color-draw').trim() || '#dc2626'; + const x = p => xOf(dims, p); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + for (const shape of shapes) { + const ax = x(shape.a); + const ay = shape.a.y; + const bx = x(shape.b); + const by = shape.b.y; + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = 2.5; + ctx.beginPath(); + if (shape.type === 'line') { + ctx.moveTo(ax, ay); + ctx.lineTo(bx, by); + // Arrow head at the end of the line. + const angle = Math.atan2(by - ay, bx - ax); + for (const side of [-1, 1]) { + ctx.moveTo(bx, by); + ctx.lineTo(bx - 11 * Math.cos(angle + side * 0.45), by - 11 * Math.sin(angle + side * 0.45)); + } + ctx.stroke(); + } else if (shape.type === 'circle') { + ctx.ellipse((ax + bx) / 2, (ay + by) / 2, Math.max(Math.abs(bx - ax) / 2, 3), Math.max(Math.abs(by - ay) / 2, 3), 0, 0, 2 * Math.PI); + ctx.stroke(); + } else if (shape.type === 'text') { + ctx.font = '600 14px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'; + ctx.textBaseline = 'middle'; + // A white outline keeps the text readable on the bars. + ctx.lineWidth = 4; + ctx.strokeStyle = '#ffffff'; + // Keep the text inside the waterfall when the time axis gets narrower. + const width = ctx.measureText(shape.text).width; + const tx = Math.max(0, Math.min(ax, dims.canvasWidth - width - 4)); + textBoxes.set(shape, { x: tx, width }); + ctx.strokeText(shape.text, tx, ay); + ctx.fillText(shape.text, tx, ay); + } + } +} + +function setDrawTool(tool) { + drawing.tool = drawing.tool === tool || !tool ? null : tool; + for (const button of document.querySelectorAll('[data-action="draw-tool"]')) { + button.setAttribute('aria-pressed', String(button.dataset.tool === drawing.tool)); + } + const wrapper = document.querySelector('.harwrapper'); + if (wrapper) { + wrapper.classList.toggle('is-drawing', !!drawing.tool); + wrapper.classList.toggle('is-drawing-text', drawing.tool === 'text'); + } + const hint = document.getElementById('drawHint'); + if (hint) hint.hidden = !drawing.tool; +} + +function undoDrawing() { + const last = drawing.history.pop(); + const layer = last && drawing.layers[last.key]; + if (!layer) return; + last.undo(); + redraw(layer); +} + +function clearDrawings() { + for (const layer of Object.values(drawing.layers)) { + const removed = layer.shapes.splice(0); + if (removed.length) drawing.history.push({ key: layer.key, undo: () => layer.shapes.push(...removed) }); + redraw(layer); + } +} + +// The waterfalls with their drawings as a PNG, as they show on the +// page: blended with the slider, or side by side. +function saveDrawingPng() { + const wrapper = document.querySelector('.harwrapper'); + const parts = ['har1', 'har2'] + .map(key => drawing.layers[key]) + .filter(layer => layer && layer.base.isConnected && dimensions(layer)) + .map(layer => ({ + layer, + dims: dimensions(layer), + box: layer.base.getBoundingClientRect(), + opacity: Number(getComputedStyle(layer.container).opacity), + label: (document.getElementById(layer.key + 'Label') || {}).textContent || layer.key + })) + .filter(p => p.opacity > 0.01); + if (!parts.length || !wrapper) return; + const origin = wrapper.getBoundingClientRect(); + const ratio = parts[0].layer.base.width / parts[0].box.width; + const labelHeight = 32; + // One label above each place. Blended waterfalls share a place. + const labels = new Map(); + for (const p of parts) { + const at = Math.round(p.box.left - origin.left) + ',' + Math.round(p.box.top - origin.top); + labels.set(at, (labels.has(at) ? labels.get(at) + ' / ' : '') + p.label); + } + const out = document.createElement('canvas'); + out.width = Math.round(Math.max(...parts.map(p => p.box.right - origin.left)) * ratio); + out.height = Math.round(Math.max(...parts.map(p => p.box.bottom - origin.top + labelHeight)) * ratio); + const ctx = out.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, out.width, out.height); + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + ctx.fillStyle = '#111827'; + ctx.font = '600 15px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'; + ctx.textBaseline = 'middle'; + for (const [at, label] of labels) { + const [x, y] = at.split(',').map(Number); + ctx.fillText(label, x + 8, y + labelHeight / 2); + } + for (const p of parts) { + const x = p.box.left - origin.left; + const y = p.box.top - origin.top + labelHeight; + ctx.globalAlpha = p.opacity; + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + ctx.drawImage(p.layer.base, x, y, p.box.width, p.box.height); + const s = scaleOf(p.layer, p.dims) * ratio; + ctx.setTransform(s, 0, 0, s, x * ratio, y * ratio); + drawShapes(ctx, p.dims, p.layer.shapes); + } + out.toBlob(blob => { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'compare-waterfall.png'; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 0); + }, 'image/png'); +} + +document.addEventListener('keydown', e => { + if (!drawing.tool || (e.target instanceof Element && e.target.closest('input, textarea'))) return; + if (e.key === 'Escape') setDrawTool(null); + if ((e.metaKey || e.ctrlKey) && e.key === 'z') { + e.preventDefault(); + undoDrawing(); + } +}); diff --git a/public/js/compare/generate.js b/public/js/compare/generate.js index 7b5a201..fc1b24c 100644 --- a/public/js/compare/generate.js +++ b/public/js/compare/generate.js @@ -1,5 +1,33 @@ -/* global getLastTiming, removeAndHide, createUpload, getAllDomains, hideUpload, objectPropertiesToArray, parseTemplate, getTotalDiff, generateVisualProgress, formatDate, getUniqueRequests, getFilmstrip, compareWaterfall, renderShareControls, blendWaterfalls, applyWaterfallLayoutPreference */ -/* exported showUpload, formatDate, generate, toggleRow, regenerate, formatTime, showLoading*/ +/* global formatTime, getLastTiming, removeAndHide, createUpload, hideUpload, parseTemplate, formatDate, formatBytes, getFilmstrip, compareWaterfall, renderShareControls, blendWaterfalls, applyWaterfallLayoutPreference, buildRuns, canCompareAllRuns, medianRun, isOutlierRun, compareMetrics, MIN_EFFECT, ADJUST_MIN_RUNS, requestSizes, compareRequests, initTimeline, addTimelineWaterfall, stopTimeline, prefersReducedMotion, addDrawingLayer */ +/* exported generate, regenerate, buildRequestView, linkMetric, showMetric, setCompareMode, pickRun, setMetricsQuery, toggleAllMetrics, toggleMetricSection, setRequestFilter, setRequestSort, setRequestMin */ + +// What the result page shows right now. +const view = { + config: null, + runs1: [], + runs2: [], + allowAll: false, + mode: 'runs', + run1: 1, + run2: 1, + rows: [], + query: '', + showAll: false, + expanded: new Set(), + requestFilter: 'all', + requestSort: 'change', + requestMin: 1000 +}; + +function storedFlag(key) { + try { return localStorage.getItem(key) === '1'; } + catch (e) { return false; } +} + +function storeFlag(key, on) { + try { localStorage.setItem(key, on ? '1' : '0'); } + catch (e) { /* localStorage blocked — preference is in-memory only */ } +} /** * You have pushed the switch button or have changed the run drop down @@ -7,104 +35,73 @@ * @param {*} switchHar */ function regenerate(switchHar) { - const e = document.getElementById('run1Option'); - const e2 = document.getElementById('run2Option'); - const runIndex = e ? e.options[e.selectedIndex].value : 0; - const runIndex2 = e2 ? e2.options[e2.selectedIndex].value : 0; + if (!switchHar) { + const e = document.getElementById('run1Option'); + const e2 = document.getElementById('run2Option'); + if (e) view.run1 = Number(e.value); + if (e2) view.run2 = Number(e2.value); + render(); + return; + } const prev = window.har || {}; // Carry per-HAR metadata (url) and top-level config (title, // firstParty, stripVersion, comments) across a Switch / run change. // Without this, the share UI would flip from "Copy share link" to // "Download bundle" the moment the user toggled anything. generate({ - har1: { - har: switchHar ? prev.har2.har : prev.har1.har, - run: switchHar ? runIndex2 : runIndex, - label: switchHar ? prev.har2.label : prev.har1.label, - url: switchHar ? prev.har2.url : prev.har1.url - }, - har2: { - har: switchHar ? prev.har1.har : prev.har2.har, - run: switchHar ? runIndex : runIndex2, - label: switchHar ? prev.har1.label : prev.har2.label, - url: switchHar ? prev.har1.url : prev.har2.url - }, + har1: { har: prev.har2.har, run: view.run2 - 1, label: prev.har2.label, url: prev.har2.url, drawings: prev.har2.drawings }, + har2: { har: prev.har1.har, run: view.run1 - 1, label: prev.har1.label, url: prev.har1.url, drawings: prev.har1.drawings }, title: prev.title, firstParty: prev.firstParty, stripVersion: prev.stripVersion, - comments: prev.comments + comments: prev.comments, + mode: view.mode }); } +function setCompareMode(mode) { + if (mode === 'runs' && view.mode === 'all') { + view.run1 = medianRun(view.runs1); + view.run2 = medianRun(view.runs2); + } + view.mode = view.allowAll ? mode : 'runs'; + render(); +} + +// A run dot was clicked: compare that run in single runs mode. +function pickRun(side, run) { + if (view.mode === 'all') { + view.run1 = medianRun(view.runs1); + view.run2 = medianRun(view.runs2); + } + view.mode = 'runs'; + if (side === 1) view.run1 = run; + else view.run2 = run; + render(); +} + /** * Render one waterfall into # using waterfall-tools. * Pass the same `maxTime` (ms) for both HARs in the pair so the * stacked panels share a time axis. */ -function addWaterfall(har, selectedPage, waterfallDivId, maxTime) { +function addWaterfall(har, selectedPage, waterfallDivId, maxTime, shapes) { const outputHolder = document.getElementById(waterfallDivId); if (!outputHolder) return Promise.resolve(); return compareWaterfall.render(har, outputHolder, { endTimeMs: maxTime, runIndex: Number(selectedPage) + }).then(renderer => { + addTimelineWaterfall(waterfallDivId, renderer, outputHolder); + addDrawingLayer(waterfallDivId, renderer, outputHolder, shapes); }); } -// Down-sample a filmstrip down to a small set of evenly-spaced frames -// for the under-chart thumbnail strip; the dense version still lives -// in the dedicated Filmstrip section below. -function sampleFrames(frames, count) { - if (!frames || frames.length === 0) return []; - if (frames.length <= count) return frames.slice(); - const step = (frames.length - 1) / (count - 1); - const out = []; - for (let i = 0; i < count; i++) out.push(frames[Math.round(i * step)]); - return out; -} - -function addVisualProgress(pageXray1, pageXray2, config, filmstrip) { - if ( - !pageXray1.visualMetrics || - !pageXray1.visualMetrics.VisualProgress || - !pageXray2.visualMetrics || - !pageXray2.visualMetrics.VisualProgress - ) return; - - parseTemplate( - 'visualProgressTemplate', - { p1: pageXray1, p2: pageXray2, config }, - '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, - 'visualProgress', - { - thumbnails1: filmstrip ? sampleFrames(filmstrip.frames1, 6) : [], - thumbnails2: filmstrip ? sampleFrames(filmstrip.frames2, 6) : [], - label1: config.har1.label, - label2: config.har2.label, - markers1: markerSet(pageXray1), - markers2: markerSet(pageXray2) - } - ); +// Drawings belong to one run: on another run they point at other requests. +function shapesFor(side, run) { + side.drawings = side.drawings || {}; + side.drawings[run] = side.drawings[run] || []; + return side.drawings[run]; } /** @@ -116,12 +113,7 @@ function generate(config) { // We remove the potential old HARs removeAndHide(); hideUpload(); - - // We wanna know which HAR that's the slowest (need the most) - // output - const lastTime = getLastTiming(config.har1.har, config.har1.run); - const lastTime2 = getLastTiming(config.har2.har, config.har2.run); - const slowestHarTiming = Math.max(lastTime, lastTime2); + stopTimeline(); // we store the HAR to easy get it when we switch runs window.har = config; @@ -138,69 +130,226 @@ function generate(config) { formatDate(config.har2.har.log.pages[config.har2.run].startedDateTime); } - const pageXray1 = window.PageXray.convertIndex( - config.har1.har, - config.har1.run, - { firstParty: config.firstParty } - ); - const pageXray2 = window.PageXray.convertIndex( - config.har2.har, - config.har2.run, - { firstParty: config.firstParty } - ); + view.config = config; + view.runs1 = buildRuns(config.har1.har, config.firstParty); + view.runs2 = buildRuns(config.har2.har, config.firstParty); + view.allowAll = canCompareAllRuns(config.har1.har, config.har2.har, view.runs1, view.runs2); + view.mode = view.allowAll ? config.mode || 'all' : 'runs'; + view.run1 = Number(config.har1.run) + 1; + view.run2 = Number(config.har2.run) + 1; + view.showAll = storedFlag('compare.showAllMetrics'); + view.expanded = new Set(); - // special handling for Template7 limits - // so we massage the data - const runs1 = []; - const runs2 = []; - for (let i = 0; i < config.har1.har.log.pages.length; i++) { - runs1.push({ - id: i, - selected: config.har1.run == i ? 'selected' : '', - show: i + 1 - }); + render(); + + // Render/refresh the share affordance now that window.har is set. + // Guarded so a missing share.js (e.g. embedded usage) is a no-op + // rather than a hard error. + if (typeof renderShareControls === 'function') { + renderShareControls(); } - for (let i = 0; i < config.har2.har.log.pages.length; i++) { - runs2.push({ - id: i, - selected: config.har2.run == i ? 'selected' : '', - show: i + 1 - }); +} + +function render() { + const config = view.config; + const all = view.mode === 'all'; + const run1 = all ? medianRun(view.runs1) : view.run1; + const run2 = all ? medianRun(view.runs2) : view.run2; + view.selected = { run1, run2 }; + // Share links and bundles open the runs on screen. + config.har1.run = run1 - 1; + config.har2.run = run2 - 1; + config.mode = view.mode; + + const r1 = view.runs1[run1 - 1]; + const r2 = view.runs2[run2 - 1]; + view.rows = compareMetrics(view.runs1, view.runs2, { allRuns: all, runA: run1, runB: run2 }); + + renderSummary(r1, r2); + const hasTimeline = renderTimeline(r1, r2); + renderMetricsTable(); + const hasRequests = renderRequests(r1, r2); + const hasCaptures = renderCaptures(r1, r2); + + const sections = [{ id: 'summary', label: 'Summary' }]; + if (hasTimeline) sections.push({ id: 'timelineHeader', label: 'Timeline' }); + sections.push({ id: 'waterfallHeader', label: 'Waterfall' }); + sections.push({ id: 'metricsHeader', label: 'All metrics' }); + if (hasRequests) sections.push({ id: 'requestDiffHeader', label: 'Request changes' }); + if (hasCaptures) sections.push({ id: 'capturesHeader', label: 'Captures' }); + parseTemplate('resultHeaderTemplate', { sections }, 'resultHeaderContent'); + + // Take care of comments + for (const key of Object.keys(config.comments || {})) { + const el = document.getElementById('comment-' + key); + if (el) el.textContent = config.comments[key]; } - const filmstrip = getFilmstrip( - config.har1.har, - config.har1.run, - config.har2.har, - config.har2.run - ); + createUpload('har1upload'); + createUpload('har2upload'); +} - // It's time to start to parse content - parseTemplate( - 'resultHeaderTemplate', - { - p1: pageXray1, - p2: pageXray2, - filmstrip: filmstrip - }, - 'resultHeaderContent' - ); +const metricRowsOnly = rows => rows.filter(r => r.section !== true); + +function renderSummary(r1, r2) { + const config = view.config; + const all = view.mode === 'all'; + const { run1, run2 } = view.selected; + const label1 = config.har1.label; + const label2 = config.har2.label; + const metrics = metricRowsOnly(view.rows); + const browser = r1.px.meta.browser; + + const runText = (runs, run) => (all ? runs.length + ' runs' : 'run ' + run + ' of ' + runs.length); + const pickerOptions = (runs, run) => runs.map(r => ({ + value: r.run, + selected: r.run === run, + text: 'Run ' + r.run + + (runs.length > 2 && r.run === medianRun(runs) ? ' (median)' : '') + + (runs.length > 2 && isOutlierRun(runs, r.run) ? ' (outlier)' : '') + })); + const runPickers = []; + if (view.runs1.length > 1) runPickers.push({ key: 1, label: label1, options: pickerOptions(view.runs1, run1) }); + if (view.runs2.length > 1) runPickers.push({ key: 2, label: label2, options: pickerOptions(view.runs2, run2) }); + const outliers = []; + if (view.runs1.length > 2 && isOutlierRun(view.runs1, run1)) outliers.push(label1 + ' run ' + run1); + if (view.runs2.length > 2 && isOutlierRun(view.runs2, run2)) outliers.push(label2 + ' run ' + run2); + + // The verdict counts changes that are significant and big enough. + const notable = metrics.filter(r => r.notable && !r.zero) + .sort((x, y) => (y.delta > 0) - (x.delta > 0) || Math.abs(y.pct ?? 100) - Math.abs(x.pct ?? 100)); + const smallSignificant = all ? metrics.filter(r => r.significant && !r.notable && !r.zero).length : 0; + const worse = notable.filter(r => r.delta > 0).length; + const better = notable.length - worse; + const smallText = smallSignificant + ? ' ' + smallSignificant + (notable.length ? ' more' : ' metrics') + ' changed significantly by less than ' + MIN_EFFECT + '%; see All metrics.' + : ''; + let verdict; + if (all && !notable.length) { + verdict = { + cls: 'same', tag: 'No notable change', + title: 'No metric changed significantly by ' + MIN_EFFECT + '% or more across ' + view.runs1.length + ' + ' + view.runs2.length + ' runs.', + text: 'Differences between single runs are within run-to-run noise.' + smallText + }; + } else if (all) { + verdict = { + cls: worse >= better ? 'worse' : 'better', tag: worse + ' worse · ' + better + ' better', + title: notable.length + ' of ' + metrics.length + ' metrics changed significantly by ' + MIN_EFFECT + '% or more.', + text: 'Regressions first. With this many metrics a few can pass the test by chance, so more runs give a firmer answer.' + smallText + }; + } else { + const multi = view.runs1.length > 1 || view.runs2.length > 1; + verdict = { + cls: notable.length ? 'warn' : 'same', tag: 'Single runs', + title: 'Comparing ' + label1 + ' run ' + run1 + ' with ' + label2 + ' run ' + run2 + ': ' + notable.length + ' metrics differ by 5% or more.', + text: view.allowAll + ? 'One run per side cannot tell a real change from noise. Switch to All runs to see which changes hold up.' + : multi ? 'Compare the same URL in two HAR files with several runs each to test if a change is real.' + : 'Test with several runs to tell a real change from noise.' + }; + } + verdict.items = notable.slice(0, 8).map(r => ({ cls: r.delta > 0 ? 'worse' : 'better', text: r.label + ' ' + r.dText, metric: r.label })); + + const preferred = ['First Contentful Paint', 'Largest Contentful Paint', 'Speed Index', 'Total Blocking Time', + 'Cumulative Layout Shift', 'First Visual Change', 'Last Visual Change', 'Load event', 'Transfer size', 'Requests']; + const cards = preferred + .map(label => metrics.find(r => r.label === label)) + .filter(r => r && typeof r.a === 'number' && typeof r.b === 'number') + .slice(0, 6) + .map(r => { + const values = r.valuesA.concat(r.valuesB).map(v => v.value); + const lo = Math.min(...values); + const hi = Math.max(...values); + const dots = list => list.map(v => ({ run: v.run, value: v.value, text: r.fmt(v.value) })); + return { + label: r.label, aText: r.aText, bText: r.bText, + delta: r.dText.replace(/ \(.*/, ''), + cls: r.notable ? r.cls : 'same', + note: all ? (r.significant && !r.notable ? 'significant, under ' + MIN_EFFECT + '%' : r.pText) : 'single runs', + valuesA: dots(r.valuesA), valuesB: dots(r.valuesB), + runA: all ? null : run1, runB: all ? null : run2, + scale: v => (hi === lo ? 50 : 4 + ((v - lo) / (hi - lo)) * 92) + }; + }); + + parseTemplate('summaryTemplate', { + url: r1.px.url.replace(/^https?:\/\//, ''), + details: [browser && browser.name + ' ' + browser.version, r1.px.meta.connectivity].filter(Boolean).join(' · '), + sides: [ + { key: 1, label: label1, when: formatDate(r1.page.startedDateTime), runs: runText(view.runs1, run1) }, + { key: 2, label: label2, when: formatDate(r2.page.startedDateTime), runs: runText(view.runs2, run2) } + ], + modeBar: view.allowAll || runPickers.length > 0, + allowAll: view.allowAll, + mode: view.mode, + modeNote: 'Each metric compares the ' + view.runs1.length + ' runs of ' + label1 + ' with the ' + view.runs2.length + + ' runs of ' + label2 + '. A change is significant when a Mann-Whitney U test gives p < 0.05. ' + + (view.runs1.length >= ADJUST_MIN_RUNS && view.runs2.length >= ADJUST_MIN_RUNS + ? 'The p-values are adjusted for the number of metrics (Benjamini-Hochberg), so fewer changes are significant by chance. ' : '') + + 'The timeline, video and waterfall show the median run of each side: ' + label1 + ' run ' + run1 + ' and ' + label2 + ' run ' + run2 + '.', + runPickers, + outlierNote: outliers.length ? outliers.join(' and ') + (outliers.length > 1 ? ' are outliers' : ' is an outlier') + ' (25% or more from the median).' : '', + verdict, + cards + }, 'summaryContent'); +} + +function renderTimeline(r1, r2) { + const config = view.config; + const { run1, run2 } = view.selected; + const maxTime = Math.max(getLastTiming(config.har1.har, run1 - 1), getLastTiming(config.har2.har, run2 - 1)); + const vp = r => (r.px.visualMetrics && r.px.visualMetrics.VisualProgress) || null; + const filmstrip = getFilmstrip(config.har1.har, run1 - 1, config.har2.har, run2 - 1); + const video1 = r1.px.meta.video; + const video2 = r2.px.meta.video; + const hasVideo = !!(video1 && video2); + const hasChart = !!(vp(r1) && vp(r2)); + + const section = document.getElementById('timelineSection'); + const show = hasChart || !!filmstrip || hasVideo; + if (section) section.hidden = !show; if (filmstrip) { - parseTemplate( - 'filmstripTemplate', - { config: config, filmstrip: filmstrip }, - 'filmstripContent' - ); + parseTemplate('filmstripTemplate', { config, filmstrip }, 'filmstripContent'); + } else { + document.getElementById('filmstripContent').innerHTML = ''; } + const firstFrame = frames => (frames && frames.length ? frames[0].img : ''); + parseTemplate('timelineMediaTemplate', { + hasVideo: hasVideo, + sides: [ + { key: 1, label: config.har1.label + ' run ' + run1, video: video1, poster: r1.px.meta.screenshot, frame: firstFrame(filmstrip && filmstrip.frames1) }, + { key: 2, label: config.har2.label + ' run ' + run2, video: video2, poster: r2.px.meta.screenshot, frame: firstFrame(filmstrip && filmstrip.frames2) } + ].filter(() => hasVideo || filmstrip) + }, 'timelineMedia'); + + // 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; }); + } + // Every run of a side makes the shaded range when the runs are of one URL. + const chartRuns = (runs, run) => runs + .filter(r => vp(r) && (view.allowAll || r.run === run)) + .map(r => ({ vp: vp(r), selected: r.run === run })); // Slider labels for the blend control. Both waterfalls are rendered // into the same area on the same time axis; the slider blends har2 // over har1. const label1 = document.getElementById('har1Label'); const label2 = document.getElementById('har2Label'); - if (label1) label1.textContent = config.har1.label; - if (label2) label2.textContent = config.har2.label; + if (label1) label1.textContent = config.har1.label + ' run ' + run1; + if (label2) label2.textContent = config.har2.label + ' run ' + run2; // Reset the slider to 0 (show HAR1 fully) when (re)generating. const slider = document.getElementById('harBlendSlider'); @@ -209,105 +358,308 @@ function generate(config) { // 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(); + applyWaterfallLayoutPreference(); + + // The milestones a HAR reached, for the HUD chips and timeline flags. + // `metric` links each one to its card and table row. + function milestones(r) { + const vm = r.px.visualMetrics || {}; + const gw = r.px.googleWebVitals || {}; + return [ + ['First Visual Change', 'FVC', vm.FirstVisualChange], + ['First Contentful Paint', 'FCP', gw.firstContentfulPaint], + ['Largest Contentful Paint', 'LCP', gw.largestContentfulPaint], + ['Speed Index', 'SI', vm.SpeedIndex], + ['Visual Complete 85%', 'VC85', vm.VisualComplete85], + ['Last Visual Change', 'LVC', vm.LastVisualChange], + ['Load event', 'Load', r.page.pageTimings && r.page.pageTimings.onLoad] + ] + .filter(([, , t]) => typeof t === 'number' && t > 0 && t <= maxTime) + .map(([name, short, t]) => ({ name, short, t, text: formatTime(t), metric: name })) + .sort((a, b) => a.t - b.t); + } + // sitespeed.io lists the long tasks in its filmstrip timings. + function longTasks(r) { + return ((r.page._meta && r.page._meta.filmstrip) || []) + .flatMap(frame => frame.timings || []) + .filter(timing => timing.metric === 'longTask') + .map(timing => ({ start: Number(timing.value), duration: Number(timing.duration) })) + .filter(task => task.start < maxTime && task.duration > 0); } + const firstVisual = Math.min(...[r1, r2] + .map(r => (r.px.visualMetrics && r.px.visualMetrics.FirstVisualChange) || Infinity)); - parseTemplate( - 'pageXrayTemplate', - { - p1: pageXray1, - p2: pageXray2, - config, - runs1: runs1, - runs2: runs2, - cpuCategories1: - pageXray1.cpu && pageXray1.cpu.categories - ? objectPropertiesToArray(pageXray1.cpu.categories) - : undefined, - cpuCategories2: - pageXray2.cpu && pageXray2.cpu.categories - ? objectPropertiesToArray(pageXray2.cpu.categories) - : undefined, - cpuEvents1: - pageXray1.cpu && pageXray1.cpu.events - ? objectPropertiesToArray(pageXray1.cpu.events) - : undefined, - cpuEvents2: - pageXray2.cpu && pageXray2.cpu.events - ? objectPropertiesToArray(pageXray2.cpu.events) - : undefined - }, - 'pageXrayContent' - ); + const meta = document.getElementById('timelineMeta'); + if (meta) meta.textContent = config.har1.label + ' run ' + run1 + ' · ' + config.har2.label + ' run ' + run2; - addWaterfall(config.har1.har, config.har1.run, 'har1', slowestHarTiming); - addWaterfall(config.har2.har, config.har2.run, 'har2', slowestHarTiming); - - addVisualProgress(pageXray1, pageXray2, config, filmstrip); - - if (Object.keys(pageXray1.firstParty).length > 0) { - parseTemplate( - 'thirdPartyTemplate', - { - p1: pageXray1, - p2: pageXray2, - config - }, - 'thirdPartyContent' - ); + initTimeline({ + maxTime, + start: hasChart ? Math.max(r1.px.visualMetrics.FirstVisualChange || 0, r2.px.visualMetrics.FirstVisualChange || 0) : 0, + firstVisual: isFinite(firstVisual) ? firstVisual : 0, + track: [ + { key: 1, label: config.har1.label, milestones: milestones(r1), longTasks: longTasks(r1) }, + { key: 2, label: config.har2.label, milestones: milestones(r2), longTasks: longTasks(r2) } + ], + chart: hasChart ? { + runs1: chartRuns(view.runs1, run1), + runs2: chartRuns(view.runs2, run2), + markers1: markerSet(r1.px), + markers2: markerSet(r2.px) + } : { runs1: [], runs2: [] }, + sides: { + 1: { frames: filmstrip ? filmstrip.frames1 : [], vp: vp(r1) }, + 2: { frames: filmstrip ? filmstrip.frames2 : [], vp: vp(r2) } + } + }); + document.getElementById('visualProgress').hidden = !hasChart; + document.querySelector('.vp-title').hidden = !hasChart; + document.getElementById('vpRangeNote').hidden = !hasChart || !view.allowAll; + + addWaterfall(config.har1.har, run1 - 1, 'har1', maxTime, shapesFor(config.har1, run1)); + addWaterfall(config.har2.har, run2 - 1, 'har2', maxTime, shapesFor(config.har2, run2)); + return show; +} + +function renderMetricsTable() { + const all = view.mode === 'all'; + const config = view.config; + const { run1, run2 } = view.selected; + const query = view.query.trim().toLowerCase(); + const metrics = metricRowsOnly(view.rows); + // What counts as changed is what the summary counts: significant and + // at least MIN_EFFECT across all runs, or 5% or more between two single + // runs (they almost always differ a little). + const changed = r => r.notable; + const dots = (r, list) => list.map(v => ({ run: v.run, value: v.value, text: r.fmt(v.value) })); + + // Each section shows its changed metrics; the rest are folded. + const rows = []; + let section = null; + let sectionRows = []; + const flush = () => { + if (!section) return; + const open = view.showAll || view.expanded.has(section.label); + const shown = sectionRows.filter(r => (query + ? (r.section + ' ' + r.label).toLowerCase().includes(query) + : open || changed(r))); + if (query && !shown.length) return; + const changedCount = sectionRows.filter(changed).length; + rows.push({ + sectionRow: true, + label: section.label, + changedCount, + unchanged: sectionRows.length - changedCount, + expanded: open, + canFold: !query && !view.showAll && sectionRows.length > changedCount + }); + for (const r of shown) { + rows.push({ ...r, showDots: all ? r.significant : r.notable, dotsA: dots(r, r.valuesA), dotsB: dots(r, r.valuesB) }); + } + }; + for (const r of view.rows) { + if (r.section === true) { + flush(); + section = r; + sectionRows = []; + } else { + sectionRows.push(r); + } } + flush(); - const allDomains = getAllDomains(pageXray1, pageXray2); - parseTemplate( - 'domainsTemplate', - { - domains: allDomains, - config - }, - 'domainsContent' - ); + parseTemplate('metricsTableTemplate', { + rows, + labelA: config.har1.label + (all ? ' median' : ' run ' + run1), + labelB: config.har2.label + (all ? ' median' : ' run ' + run2), + referenceLabel: config.har1.label + (all ? ' median' : ' run ' + run1), + runA: all ? null : run1, + runB: all ? null : run2 + }, 'metricsTableContent'); + + const note = document.getElementById('metricsNote'); + if (note) { + note.textContent = metrics.filter(changed).length + ' of ' + metrics.length + ' metrics changed' + + (all ? ' significantly by ' + MIN_EFFECT + '% or more' : ' by 5% or more') + '. The others are folded in each section. ' + + (all ? 'Values are medians with the min – max range below. ' : '') + + 'Run dots show each run\'s difference from ' + config.har1.label + ' on one shared scale; click a dot to compare that run.'; + } + const toggle = document.getElementById('allMetricsToggle'); + if (toggle) toggle.setAttribute('aria-pressed', String(view.showAll)); +} + +// The chart marker of a metric, when it has one. +const markerKinds = { + 'First Visual Change': 'fvc', + 'First Contentful Paint': 'fcp', + 'Largest Contentful Paint': 'lcp', + 'Speed Index': 'si' +}; +// Pointing at a metric highlights it everywhere: card, verdict, table +// row and its marker on the chart. +function linkMetric(label, on) { + for (const el of document.querySelectorAll('[data-metric]')) { + if (el.dataset.metric === label) el.classList.toggle('is-linked', on); + } + const kind = markerKinds[label]; + if (kind) { + for (const el of document.querySelectorAll('#visualProgress .vp-marker--' + kind)) el.classList.toggle('is-linked', on); + } +} + +// Scroll to the row of a metric and flash it. Clears the filters that +// would hide the row. +function showMetric(label) { + const findRow = () => [...document.querySelectorAll('#metricsTableContent tr[data-metric]')] + .find(tr => tr.dataset.metric === label); + if (!findRow()) { + view.query = ''; + const row = metricRowsOnly(view.rows).find(r => r.label === label); + if (row) view.expanded.add(row.section); + const search = document.getElementById('metricSearch'); + if (search) search.value = ''; + renderMetricsTable(); + } + const row = findRow(); + if (!row) return; + row.scrollIntoView({ block: 'center', behavior: prefersReducedMotion() ? 'auto' : 'smooth' }); + row.classList.remove('metric-flash'); + void row.offsetWidth; + row.classList.add('metric-flash'); +} + +function setMetricsQuery(query) { + view.query = query; + renderMetricsTable(); +} + +// "Show all metrics" toggle for the metrics table. By default each +// section shows only the metrics that changed, so a long metric list +// collapses to just the things that actually moved. Persists the +// choice so the page comes back the way the user left it. +function toggleAllMetrics() { + view.showAll = !view.showAll; + storeFlag('compare.showAllMetrics', view.showAll); + renderMetricsTable(); +} + +// Show or fold the unchanged metrics of one section. +function toggleMetricSection(label) { + if (view.expanded.has(label)) view.expanded.delete(label); + else view.expanded.add(label); + renderMetricsTable(); +} + +function requestType(url) { + const path = url.split(/[?#]/)[0].toLowerCase(); + if (/\.css$/.test(path) || /only=styles/.test(url)) return 'css'; + if (/\.m?js$/.test(path) || /only=scripts|load\.php/.test(url)) return 'js'; + if (/\.(png|jpe?g|gif|svg|webp|avif|ico)$/.test(path)) return 'image'; + if (/\.(woff2?|ttf|otf)$/.test(path)) return 'font'; + return 'other'; +} + +function renderRequests(r1, r2) { + const config = view.config; + const section = document.getElementById('requestsSection'); // At the moment we only test this if you test the same URL // But we should find a better way to do it in the future - if (pageXray1.url === pageXray2.url) { - const requestDiff = getUniqueRequests( - config.har1.har, - config.har1.run, - config.har2.har, - config.har2.run, - config - ); - const total = getTotalDiff(requestDiff); - parseTemplate( - 'requestDiffTemplate', - { - requestDiff, - total, - config - }, - 'requestDiffContent' - ); - - // Take care of comments - if (config.comments) { - const commentKeys = Object.keys(config.comments); - for (let key of commentKeys) { - const el = document.getElementById('comment-' + key); - if (el) { - el.textContent = config.comments[key]; - } - } - } + const show = r1.px.url === r2.px.url; + if (section) section.hidden = !show; + if (!show) return false; + + const all = view.mode === 'all'; + const { run1, run2 } = view.selected; + const pages = (runs, run) => (all ? runs.map(r => r.run - 1) : [run - 1]); + const changes = compareRequests( + requestSizes(config.har1.har, pages(view.runs1, run1), config.stripVersion), + requestSizes(config.har2.har, pages(view.runs2, run2), config.stripVersion) + ); + parseTemplate('requestsTemplate', { + ...buildRequestView(changes, { all, filter: view.requestFilter, min: view.requestMin, sort: view.requestSort }), + sort: view.requestSort, + labelA: config.har1.label + (all ? '' : ' run ' + run1), + labelB: config.har2.label + (all ? '' : ' run ' + run2) + }, 'requestsContent'); + + const note = document.getElementById('requestsNote'); + if (note) { + note.textContent = all + ? 'Median transfer size of each URL across the runs that requested it.' + : 'Transfer size in ' + config.har1.label + ' run ' + run1 + ' and ' + config.har2.label + ' run ' + run2 + '.'; } + const min = document.getElementById('requestMinLabel'); + if (min) min.textContent = 'Hide size changes smaller than ' + (view.requestMin ? formatBytes(view.requestMin) : '0 B'); + return true; +} - createUpload('har1upload'); - createUpload('har2upload'); +// Summary, filter chips and rows for the request changes. +function buildRequestView(changes, { all, filter, min, sort }) { + const visible = changes.filter(c => c.kind === 'new' || c.kind === 'removed' || Math.abs(c.diff) >= min); + const filters = [['all', 'All'], ['increased', 'Bigger'], ['decreased', 'Smaller'], ['new', 'New'], ['removed', 'Removed']] + .map(([id, label]) => ({ + id, label, active: filter === id, + count: id === 'all' ? visible.length : visible.filter(c => c.kind === id).length + })); + const maxDiff = Math.max(...changes.map(c => Math.abs(c.diff)), 1); + const sign = n => (n > 0 ? '+' : '−'); + const seen = s => (all && s.seen < s.runs ? ' · ' + s.seen + '/' + s.runs + ' runs' : ''); + const rows = visible + .filter(c => filter === 'all' || c.kind === filter) + .sort((x, y) => (sort === 'url' ? x.url.localeCompare(y.url) : Math.abs(y.diff) - Math.abs(x.diff))) + .map(c => ({ + url: c.url, + type: requestType(c.url), + aText: c.a ? formatBytes(c.a.size) + seen(c.a) : '—', + bText: c.b ? formatBytes(c.b.size) + seen(c.b) : '—', + cls: c.diff > 0 ? 'worse' : 'better', + dText: c.kind === 'new' ? 'New' : c.kind === 'removed' ? 'Removed' : sign(c.diff) + formatBytes(Math.abs(c.diff)), + w: Math.max(Math.round((Math.abs(c.diff) / maxDiff) * 100), 2) + })); + const total = (kind, f) => changes.filter(c => c.kind === kind).reduce((sum, c) => sum + f(c), 0); + const count = kind => changes.filter(c => c.kind === kind).length; + const summary = [ + { label: 'New (' + count('new') + ')', value: '+' + formatBytes(total('new', c => c.b.size)), cls: 'worse' }, + { label: 'Removed (' + count('removed') + ')', value: '−' + formatBytes(total('removed', c => c.a.size)), cls: 'better' }, + { label: 'Increased (' + count('increased') + ')', value: '+' + formatBytes(total('increased', c => c.diff)), cls: 'worse' }, + { label: 'Decreased (' + count('decreased') + ')', value: '−' + formatBytes(-total('decreased', c => c.diff)), cls: 'better' } + ]; + return { summary, filters, rows }; +} - // Render/refresh the share affordance now that window.har is set. - // Guarded so a missing share.js (e.g. embedded usage) is a no-op - // rather than a hard error. - if (typeof renderShareControls === 'function') { - renderShareControls(); +function setRequestFilter(filter) { + view.requestFilter = filter; + renderRequests(view.runs1[view.selected.run1 - 1], view.runs2[view.selected.run2 - 1]); +} + +function setRequestSort(sort) { + view.requestSort = sort; + renderRequests(view.runs1[view.selected.run1 - 1], view.runs2[view.selected.run2 - 1]); +} + +function setRequestMin(bytes) { + view.requestMin = bytes; + renderRequests(view.runs1[view.selected.run1 - 1], view.runs2[view.selected.run2 - 1]); +} + +// Captures — final screenshot, video, link back to the per-run +// result page. +function renderCaptures(r1, r2) { + const config = view.config; + const { run1, run2 } = view.selected; + const side = (key, label, r) => ({ + key, label, + screenshot: r.px.meta.screenshot, + video: r.px.meta.video, + result: r.px.meta.result + }); + const sides = [side(1, config.har1.label + ' run ' + run1, r1), side(2, config.har2.label + ' run ' + run2, r2)]; + const has = sides.some(s => s.screenshot || s.video || s.result); + const target = document.getElementById('capturesContent'); + if (!has) { + if (target) target.innerHTML = ''; + return false; } + parseTemplate('capturesTemplate', { sides }, 'capturesContent'); + return true; } diff --git a/public/js/compare/generateVisualProgress.js b/public/js/compare/generateVisualProgress.js index 37b9e60..689f1ca 100644 --- a/public/js/compare/generateVisualProgress.js +++ b/public/js/compare/generateVisualProgress.js @@ -1,55 +1,44 @@ /* exported generateVisualProgress */ /** - * Draw two visual-progress curves as inline SVG, with an optional - * pair of thumbnail strips below aligned to the same time axis. + * Draw the visual progress as inline SVG: a shaded range of all runs + * and a strong line for the selected run of each side, and the + * timeline cursor on top. * - * @param {Object} visualProgress1 milliseconds -> percent - * @param {Object} visualProgress2 milliseconds -> percent * @param {string} id container element id - * @param {Object} [opts] - * @param {Array<{time:string,img:string}>} [opts.thumbnails1] - * @param {Array<{time:string,img:string}>} [opts.thumbnails2] - * @param {string} [opts.label1] - * @param {string} [opts.label2] + * @param {Object} opts + * @param {number} opts.maxTime milliseconds on the x axis + * @param {Array<{vp: Object, selected: boolean}>} opts.runs1 + * @param {Array<{vp: Object, selected: boolean}>} opts.runs2 + * @param {Array} [opts.markers1] + * @param {Array} [opts.markers2] + * @param {Array<{from: number, to: number}>} [opts.bands] time ranges (ms) to shade + * @param {function(number)} [opts.onTime] called with the time (ms) under the pointer + * @returns {{setCursor: function(number)}|null} */ -function generateVisualProgress(visualProgress1, visualProgress2, id, opts) { - opts = opts || {}; +function generateVisualProgress(id, opts) { const container = document.getElementById(id); - if (!container) return; + if (!container) return null; container.innerHTML = ''; + const maxTime = Math.max(opts.maxTime, 1) / 1000; + // Build de-duplicated stepped point lists; series end on (maxTime, 100) // so the visual progress line is anchored even when the last sample // was below 100%. - let maxTime = 0; - const series = [visualProgress1, visualProgress2].map(function(list) { + function points(list) { let prev = -1; - const points = []; - Object.keys(list || {}).forEach(function(ms) { + const out = []; + Object.keys(list || {}).forEach(function (ms) { const v = list[ms]; if (v !== prev) { prev = v; - const t = Number(ms) / 1000; - if (t > maxTime) maxTime = t; - points.push({ x: t, y: v }); + out.push({ x: Math.min(Number(ms) / 1000, maxTime), y: v }); } }); - return points; - }); - - // Extend maxTime so the last thumbnail still fits on the axis. - function tMax(arr, key) { - if (!arr || !arr.length) return 0; - return Math.max.apply(null, arr.map(function (a) { return Number(a[key]) || 0; })); + out.push({ x: maxTime, y: 100 }); + return out; } - maxTime = Math.max( - maxTime, - tMax(opts.thumbnails1, 'time'), - tMax(opts.thumbnails2, 'time') - ); - if (maxTime <= 0) maxTime = 1; - series.forEach(function(s) { s.push({ x: maxTime, y: 100 }); }); // Viewbox in "data units" — we draw in time-on-X (0..maxTime) and // percent-on-Y (0..100) and rely on the SVG viewBox + width:100% in @@ -71,7 +60,7 @@ function generateVisualProgress(visualProgress1, visualProgress2, id, opts) { svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H); svg.setAttribute('preserveAspectRatio', 'none'); svg.setAttribute('role', 'img'); - svg.setAttribute('aria-label', 'Visual progress comparison'); + svg.setAttribute('aria-label', 'Visual progress of every run'); function add(tag, attrs, text) { const el = document.createElementNS(ns, tag); @@ -99,53 +88,73 @@ function generateVisualProgress(visualProgress1, visualProgress2, id, opts) { 'Time (seconds)'); // Stepped path between consecutive samples. - function stepPath(points) { - if (!points.length) return ''; - let d = 'M ' + x(points[0].x) + ' ' + y(points[0].y); - for (let i = 1; i < points.length; i++) { - d += ' H ' + x(points[i].x) + ' V ' + y(points[i].y); + function stepPath(pts) { + if (!pts.length) return ''; + let d = 'M ' + x(pts[0].x) + ' ' + y(pts[0].y); + for (let i = 1; i < pts.length; i++) { + d += ' H ' + x(pts[i].x) + ' V ' + y(pts[i].y); } return d; } - add('path', { class: 'vp-series--1', d: stepPath(series[0]) }); - add('path', { class: 'vp-series--2', d: stepPath(series[1]) }); + // Shade where the two selected runs differ a lot, under the lines. + const bands = (opts.bands || []).map(function (b) { + const x1 = x(Math.min(b.from / 1000, maxTime)); + const x2 = x(Math.min(b.to / 1000, maxTime)); + return { + from: b.from, + to: b.to, + el: add('rect', { class: 'vp-band', x: x1, y: y(100), width: Math.max(x2 - x1, 1), height: y(0) - y(100) }) + }; + }); + + // One shaded range per side, from the slowest to the fastest run. + // Separate lines per run cross and cover each other. + function rangePath(runs) { + const steps = runs.map(function (r) { + return Object.keys(r.vp).map(function (ms) { return [Number(ms) / 1000, r.vp[ms]]; }) + .sort(function (a, b) { return a[0] - b[0]; }); + }); + const at = function (s, t) { + let v = 0; + s.forEach(function (p) { if (p[0] <= t) v = p[1]; }); + return v; + }; + const times = [...new Set([].concat(...steps.map(function (s) { return s.map(function (p) { return p[0]; }); })))] + .filter(function (t) { return t < maxTime; }) + .sort(function (a, b) { return a - b; }); + if (!times.length) return ''; + const hi = times.map(function (t) { return Math.max(...steps.map(function (s) { return at(s, t); })); }); + const lo = times.map(function (t) { return Math.min(...steps.map(function (s) { return at(s, t); })); }); + let d = 'M ' + x(times[0]) + ' ' + y(hi[0]); + for (let i = 1; i < times.length; i++) d += ' H ' + x(times[i]) + ' V ' + y(hi[i]); + d += ' H ' + x(maxTime) + ' V ' + y(lo[times.length - 1]); + for (let i = times.length - 1; i > 0; i--) d += ' H ' + x(times[i]) + ' V ' + y(lo[i - 1]); + return d + ' H ' + x(times[0]) + ' Z'; + } + [[opts.runs1, 1], [opts.runs2, 2]].forEach(function (pair) { + const runs = (pair[0] || []).filter(function (r) { return r.vp; }); + if (runs.length > 1) add('path', { class: 'vp-range vp-range--' + pair[1], d: rangePath(runs) }); + }); + [[opts.runs1, 1], [opts.runs2, 2]].forEach(function (pair) { + (pair[0] || []).filter(function (r) { return r.selected; }).forEach(function (r) { + add('path', { class: 'vp-series--' + pair[1], d: stepPath(points(r.vp)) }); + }); + }); // 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. + // dominant. Their names are on the timeline flags above the chart. 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) { + (markers || []).forEach(function (m) { const t = m.time / 1000; + if (t > maxTime) return; const xv = x(t); - const cls = 'vp-marker vp-marker--' + hark + ' vp-marker--' + m.kind; add('line', { - class: cls, + class: 'vp-marker vp-marker--' + hark + ' vp-marker--' + m.kind, 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); @@ -153,50 +162,27 @@ function generateVisualProgress(visualProgress1, visualProgress2, id, opts) { container.appendChild(svg); - // Thumbnail strips below the chart, aligned to the same time axis - // as the SVG. Each strip is a relative-positioned row; each thumb - // is absolutely positioned at its time's X percentage so they line - // up visually with the curve and markers above. - function thumbStrip(label, frames) { - if (!frames || !frames.length) return null; - const row = document.createElement('div'); - row.className = 'vp-thumb-row'; - if (label) { - const lab = document.createElement('span'); - lab.className = 'vp-thumb-label'; - lab.textContent = label; - row.appendChild(lab); - } - const rail = document.createElement('div'); - rail.className = 'vp-thumb-rail'; - frames.forEach(function (f) { - const t = Number(f.time); - // Express the X position as a percentage of container width that - // matches the SVG's viewBox X coordinate at this time. - const xv = padL + (t / maxTime) * plotW; - const leftPct = (xv / W) * 100; - const wrap = document.createElement('div'); - wrap.className = 'vp-thumb'; - wrap.style.left = leftPct.toFixed(2) + '%'; - wrap.innerHTML = - '' + - '' + - '' + - '' + (typeof f.time === 'number' ? f.time.toFixed(1) : f.time) + 's'; - rail.appendChild(wrap); - }); - row.appendChild(rail); - return row; - } - - function escapeAttr(s) { - return String(s == null ? '' : s) - .replace(/&/g, '&').replace(/"/g, '"') - .replace(//g, '>'); + const cursor = document.createElement('div'); + cursor.className = 'vp-cursor'; + container.appendChild(cursor); + + if (opts.onTime) { + const fromPointer = function (e) { + const rect = svg.getBoundingClientRect(); + const xv = ((e.clientX - rect.left) / rect.width) * W; + const t = Math.max(0, Math.min(maxTime, ((xv - padL) / plotW) * maxTime)); + opts.onTime(Math.round(t * 1000)); + }; + svg.addEventListener('pointermove', fromPointer); + svg.addEventListener('pointerdown', fromPointer); } - const row1 = thumbStrip(opts.label1, opts.thumbnails1); - const row2 = thumbStrip(opts.label2, opts.thumbnails2); - if (row1) container.appendChild(row1); - if (row2) container.appendChild(row2); + return { + setCursor: function (ms) { + cursor.style.left = ((x(Math.min(ms / 1000, maxTime)) / W) * 100).toFixed(3) + '%'; + bands.forEach(function (b) { + b.el.classList.toggle('vp-band--active', ms >= b.from && ms < b.to); + }); + } + }; } diff --git a/public/js/compare/har.js b/public/js/compare/har.js index 9acddae..8d56dcc 100644 --- a/public/js/compare/har.js +++ b/public/js/compare/har.js @@ -1,4 +1,5 @@ -/* exported getLastTiming, getAllDomains, getTotalDiff, getUniqueRequests */ +/* global median */ +/* exported getLastTiming, requestSizes, compareRequests */ /** * Helper functions to get things out of the HAR @@ -37,110 +38,32 @@ function getLastTiming(har, run) { return doneTime; } -function getAllDomains(firstPage, secondPage) { - const domainMap = {}; - - for (let name of Object.keys(firstPage.domains)) { - let domain = domainMap[name] || {}; - domain.firstPage = firstPage.domains[name]; - domainMap[name] = domain; - } - for (let name of Object.keys(secondPage.domains)) { - let domain = domainMap[name] || {}; - domain.secondPage = secondPage.domains[name]; - domainMap[name] = domain; - } - - const allDomains = []; - - for (let name of Object.keys(domainMap)) { - const domain = domainMap[name]; - domain.name = name; - allDomains.push(domain); +// Median transfer size of each URL across the given runs (0-based page +// indexes), and how many of the runs requested it. +function requestSizes(har, runs, stripVersion) { + const perRun = runs.map(run => getURLs(har, run, stripVersion)); + const sizes = {}; + for (const url of new Set(perRun.flatMap(Object.keys))) { + const values = perRun.filter(urls => url in urls).map(urls => urls[url]); + sizes[url] = { size: median(values), seen: values.length, runs: runs.length }; } - - return allDomains; + return sizes; } -function getTotalDiff(requestDiff) { - const total = { - har1: 0, - har2: 0, - diff: 0, - new: 0, - newBytes: 0, - removed: 0, - removedBytes: 0, - increased: 0, - increasedBytes: 0, - decreased: 0, - decreasedBytes: 0 - }; - for (let diff of requestDiff) { - if (diff.har1) total.har1 += diff.har1; - if (diff.har2) total.har2 += diff.har2; - if (diff.diff) total.diff += diff.diff; - - // is new! - if (diff.har1 === undefined) { - total.new += 1; - total.newBytes += diff.har2; - } else if (diff.har2 === undefined) { - // is removed - total.removed += 1; - total.removedBytes += diff.har1; - } else if (diff.diff < 0) { - // decrease! - total.decreased += 1; - total.decreasedBytes += diff.diff; - } else { - // must be increase - total.increased += 1; - total.increasedBytes += diff.diff; - } - } - return total; -} - -function getUniqueRequests(har1, run1, har2, run2, options) { - const urls1 = getURLs(har1, run1, options.stripVersion); - const urls2 = getURLs(har2, run2, options.stripVersion); - const all = []; - const minDiffInBytes = 1000; - // Use `in` for presence checks because a URL whose transfer size is - // unknown is stored as 0 — truthy checks would mis-classify those as - // removed/added. - for (let url of Object.keys(urls1)) { - if (url in urls2) { - const delta = urls2[url] - urls1[url]; - if (delta < minDiffInBytes && delta > -minDiffInBytes) { - // no meaningful diff, skip - continue; - } - all.push({ - url: url, - har1: urls1[url], - har2: urls2[url], - diff: delta - }); - } else { - all.push({ - url: url, - diff: -urls1[url], - har1: urls1[url] - }); - } - } - for (let url of Object.keys(urls2)) { - if (!(url in urls1)) { - all.push({ - url: url, - diff: urls2[url], - har2: urls2[url] - }); - } +// The URLs whose size differs between two requestSizes() results. +// Use `in` for presence checks because a URL whose transfer size is +// unknown is stored as 0 — truthy checks would mis-classify those as +// removed/added. +function compareRequests(sizes1, sizes2) { + const changes = []; + for (const url of new Set(Object.keys(sizes1).concat(Object.keys(sizes2)))) { + const a = url in sizes1 ? sizes1[url] : undefined; + const b = url in sizes2 ? sizes2[url] : undefined; + const diff = (b ? b.size : 0) - (a ? a.size : 0); + const kind = !a ? 'new' : !b ? 'removed' : diff > 0 ? 'increased' : diff < 0 ? 'decreased' : 'same'; + if (kind !== 'same') changes.push({ url, a, b, diff, kind }); } - return all; + return changes; } function getURLs(har, run, stripVersion) { diff --git a/public/js/compare/load.js b/public/js/compare/load.js index de3c1c9..17ce345 100644 --- a/public/js/compare/load.js +++ b/public/js/compare/load.js @@ -182,12 +182,14 @@ function loadFromBundle(bundle) { har1: { har: bundle.har1.har, run: bundle.har1.run || 0, - label: bundle.har1.label || 'HAR1' + label: bundle.har1.label || 'HAR1', + drawings: bundle.har1.drawings }, har2: { har: bundle.har2.har, run: bundle.har2.run || 0, - label: bundle.har2.label || 'HAR2' + label: bundle.har2.label || 'HAR2', + drawings: bundle.har2.drawings }, title: bundle.title || 'Compare HAR files', firstParty: bundle.firstParty || undefined, diff --git a/public/js/compare/metrics.js b/public/js/compare/metrics.js new file mode 100644 index 0000000..82f32f9 --- /dev/null +++ b/public/js/compare/metrics.js @@ -0,0 +1,238 @@ +/* global PageXray, median, mannWhitneyP, benjaminiHochberg, formatBytes, formatTime */ +/* exported buildRuns, canCompareAllRuns, medianRun, isOutlierRun, compareMetrics, MIN_EFFECT, SINGLE_RUN_MIN_EFFECT, ADJUST_MIN_RUNS */ + +// A change must be significant and at least this big (percent) to +// count in the summary. The table still shows every significant change. +const MIN_EFFECT = 2; +// Single runs have no test, so the summary needs a bigger change. +const SINGLE_RUN_MIN_EFFECT = 5; +// With fewer runs the smallest possible p-value (0.0079 for 5 + 5) is too +// big for the Benjamini-Hochberg adjustment, and real changes get lost. +const ADJUST_MIN_RUNS = 10; + +// One entry per page (run) in the HAR. +function buildRuns(har, firstParty) { + return har.log.pages.map((page, i) => ({ + run: i + 1, + page, + px: PageXray.convertIndex(har, i, { firstParty }) + })); +} + +// Medians only make sense for runs of the same URL from two different HARs. +function canCompareAllRuns(har1, har2, runs1, runs2) { + const oneUrl = runs => new Set(runs.map(r => r.px.url)).size === 1; + return har1 !== har2 && runs1.length > 1 && runs2.length > 1 && oneUrl(runs1) && oneUrl(runs2); +} + +const pick = (...path) => run => path.reduce((o, key) => (o == null ? undefined : o[key]), run); + +// The run metric that picks the median run and finds outliers. +function rankMetric(runs) { + const candidates = [ + pick('px', 'visualMetrics', 'SpeedIndex'), + pick('px', 'googleWebVitals', 'firstContentfulPaint'), + pick('page', 'pageTimings', 'onLoad') + ]; + return candidates.find(get => runs.every(r => typeof get(r) === 'number')) || (() => 0); +} + +function medianRun(runs) { + const get = rankMetric(runs); + return runs.slice().sort((x, y) => get(x) - get(y))[(runs.length - 1) >> 1].run; +} + +function isOutlierRun(runs, run) { + const get = rankMetric(runs); + const mid = median(runs.map(get)); + return mid > 0 && Math.abs(get(runs[run - 1]) - mid) > 0.25 * mid; +} + +const fmtMs = v => formatTime(v); +const fmtBytes = v => formatBytes(v); +const fmtCount = v => String(Math.round(v * 100) / 100); +const fmtCLS = v => v.toFixed(3); +const typeNames = { + html: 'HTML', css: 'CSS', javascript: 'JavaScript', image: 'Image', + font: 'Font', favicon: 'Favicon', svg: 'SVG', plain: 'Plain text', + json: 'JSON', other: 'Other' +}; + +// Every metric the result page shows, grouped in sections. +function metricDefs(runs) { + const keysOf = get => [...new Set(runs.flatMap(r => Object.keys(get(r) || {})))]; + const defs = []; + let section = ''; + const sec = label => { + section = label; + defs.push({ section: true, label }); + }; + const add = (label, get, fmt) => defs.push({ label, get, fmt, section }); + const type = t => typeNames[t] || t; + + sec('Content'); + add('Requests', pick('px', 'requests'), fmtCount); + add('Transfer size', pick('px', 'transferSize'), fmtBytes); + add('Content size', pick('px', 'contentSize'), fmtBytes); + for (const t of keysOf(pick('px', 'contentTypes'))) { + add(type(t) + ' requests', pick('px', 'contentTypes', t, 'requests'), fmtCount); + add(type(t) + ' transfer size', pick('px', 'contentTypes', t, 'transferSize'), fmtBytes); + add(type(t) + ' content size', pick('px', 'contentTypes', t, 'contentSize'), fmtBytes); + } + + sec('Timings'); + add('Time to first byte', pick('px', 'googleWebVitals', 'ttfb'), fmtMs); + add('DOMContentLoaded', pick('page', 'pageTimings', 'onContentLoad'), fmtMs); + add('Load event', pick('page', 'pageTimings', 'onLoad'), fmtMs); + + sec('Render blocking'); + const recalc = (milestone, key) => pick('px', 'renderBlocking', 'recalculateStyle', milestone, key); + add('Style recalc elements (FCP)', recalc('beforeFCP', 'elements'), fmtCount); + add('Style recalc time (FCP)', recalc('beforeFCP', 'durationInMillis'), fmtMs); + add('Style recalc elements (LCP)', recalc('beforeLCP', 'elements'), fmtCount); + add('Style recalc time (LCP)', recalc('beforeLCP', 'durationInMillis'), fmtMs); + add('Render blocking requests', pick('px', 'renderBlocking', 'blocking'), fmtCount); + add('Potentially blocking requests', pick('px', 'renderBlocking', 'potentiallyBlocking'), fmtCount); + add('In body parser blocking requests', pick('px', 'renderBlocking', 'in_body_parser_blocking'), fmtCount); + + sec('Visual metrics'); + [ + ['First Visual Change', 'FirstVisualChange'], ['Largest Image', 'LargestImage'], + ['Logo', 'Logo'], ['Heading', 'Heading'], ['Speed Index', 'SpeedIndex'], + ['Last Visual Change', 'LastVisualChange'], ['Visual Readiness', 'VisualReadiness'], + ['Visual Complete 85%', 'VisualComplete85'], ['Visual Complete 95%', 'VisualComplete95'], + ['Visual Complete 99%', 'VisualComplete99'] + ].forEach(([label, key]) => add(label, pick('px', 'visualMetrics', key), fmtMs)); + + sec('Core Web Vitals'); + add('First Contentful Paint', pick('px', 'googleWebVitals', 'firstContentfulPaint'), fmtMs); + add('Largest Contentful Paint', pick('px', 'googleWebVitals', 'largestContentfulPaint'), fmtMs); + add('Total Blocking Time', pick('px', 'googleWebVitals', 'totalBlockingTime'), fmtMs); + add('Cumulative Layout Shift', pick('px', 'googleWebVitals', 'cumulativeLayoutShift'), fmtCLS); + + sec('CPU'); + add('Total Blocking Time (long tasks)', pick('px', 'cpu', 'longTasks', 'totalBlockingTime'), fmtMs); + add('Max Potential FID', pick('px', 'cpu', 'longTasks', 'maxPotentialFid'), fmtMs); + add('Total CPU Long Tasks', pick('px', 'cpu', 'longTasks', 'tasks'), fmtCount); + add('Long task duration', pick('px', 'cpu', 'longTasks', 'totalDuration'), fmtMs); + + sec('CPU time spent by category'); + for (const key of keysOf(pick('px', 'cpu', 'categories')).sort()) { + add(key, pick('px', 'cpu', 'categories', key), fmtMs); + } + sec('CPU events'); + for (const key of keysOf(pick('px', 'cpu', 'events')).sort()) { + add(key, pick('px', 'cpu', 'events', key), fmtMs); + } + + // PageXray leaves out the totals when a party has no requests, so + // add up the content types instead. + const partyTotal = (party, key) => run => { + const p = run.px[party]; + if (!p) return undefined; + if (typeof p[key] === 'number') return p[key]; + return Object.values(p.contentTypes || {}).reduce((sum, t) => sum + (t[key] || 0), 0); + }; + for (const [party, title] of [['firstParty', 'First party'], ['thirdParty', 'Third party']]) { + sec(title); + add(title + ' requests', partyTotal(party, 'requests'), fmtCount); + add(title + ' transfer size', partyTotal(party, 'transferSize'), fmtBytes); + add(title + ' content size', partyTotal(party, 'contentSize'), fmtBytes); + add(title + ' cookies', pick('px', party, 'cookies'), fmtCount); + for (const t of keysOf(pick('px', party, 'contentTypes'))) { + add(title + ' ' + type(t) + ' requests', pick('px', party, 'contentTypes', t, 'requests'), fmtCount); + add(title + ' ' + type(t) + ' transfer size', pick('px', party, 'contentTypes', t, 'transferSize'), fmtBytes); + add(title + ' ' + type(t) + ' content size', pick('px', party, 'contentTypes', t, 'contentSize'), fmtBytes); + } + } + + sec('Domains'); + for (const domain of keysOf(pick('px', 'domains'))) { + const name = domain || '(no host name)'; + add(name + ' requests', pick('px', 'domains', domain, 'requests'), fmtCount); + add(name + ' transfer size', pick('px', 'domains', domain, 'transferSize'), fmtBytes); + for (const timing of keysOf(pick('px', 'domains', domain, 'timings'))) { + add(name + ' ' + timing, pick('px', 'domains', domain, 'timings', timing), fmtMs); + } + } + return defs; +} + +const signOf = n => (n > 0 ? '+' : '−'); + +/** + * Compare every metric. With allRuns, values are medians and a change + * is significant when a Mann-Whitney U test gives p < 0.05. With + * ADJUST_MIN_RUNS or more runs on each side, the p-values are adjusted + * for the number of metrics (Benjamini-Hochberg). Without allRuns, + * runA and runB (1-based) are compared directly. Every metric is + * "lower is better". + */ +function compareMetrics(runsA, runsB, { allRuns, runA, runB }) { + const defs = metricDefs(runsA.concat(runsB)); + const values = (def, runs) => runs + .map(r => ({ run: r.run, value: def.get(r) })) + .filter(v => typeof v.value === 'number' && isFinite(v.value)); + // Metrics with the same value in every run are not tested. + const tested = defs.map(def => { + if (!allRuns || def.section === true) return null; + const numbers = runs => values(def, runs).map(v => v.value); + const a = numbers(runsA); + const b = numbers(runsB); + return new Set(a.concat(b)).size > 1 ? mannWhitneyP(a, b) : null; + }); + const adjust = allRuns && runsA.length >= ADJUST_MIN_RUNS && runsB.length >= ADJUST_MIN_RUNS; + const adjusted = adjust ? benjaminiHochberg(tested) : tested; + const rows = []; + for (const [i, def] of defs.entries()) { + if (def.section === true) { + rows.push(def); + continue; + } + const valuesA = values(def, runsA); + const valuesB = values(def, runsB); + if (!valuesA.length && !valuesB.length) continue; + + const numbersA = valuesA.map(v => v.value); + const numbersB = valuesB.map(v => v.value); + let a, b, p = null; + if (allRuns) { + a = median(numbersA); + b = median(numbersB); + p = adjusted[i] === null ? mannWhitneyP(numbersA, numbersB) : adjusted[i]; + } else { + a = def.get(runsA[runA - 1]); + b = def.get(runsB[runB - 1]); + } + const has = typeof a === 'number' && typeof b === 'number'; + const delta = has ? b - a : 0; + const changed = has && Math.abs(delta) > 1e-9; + const pct = has && a ? Math.round((delta / a) * 100) : null; + const significant = allRuns ? p !== null && p < 0.05 && changed : changed; + const minEffect = allRuns ? MIN_EFFECT : SINGLE_RUN_MIN_EFFECT; + // A change from 0 has no percentage: count it only when the test + // backs it up. + const notable = significant && (pct === null ? allRuns : Math.abs(pct) >= minEffect); + const range = numbers => (allRuns && Math.min(...numbers) !== Math.max(...numbers) + ? def.fmt(Math.min(...numbers)) + ' – ' + def.fmt(Math.max(...numbers)) : ''); + + rows.push({ + label: def.label, + section: def.section, + a, b, delta, pct, p, changed, significant, notable, + cls: !significant ? 'same' : delta > 0 ? 'worse' : 'better', + aText: typeof a === 'number' ? def.fmt(a) : '—', + bText: typeof b === 'number' ? def.fmt(b) : '—', + aRange: range(numbersA), + bRange: range(numbersB), + dText: !has ? '—' : !changed ? 'no change' + : signOf(delta) + def.fmt(Math.abs(delta)) + (pct ? ' (' + signOf(pct) + Math.abs(pct) + '%)' : ''), + pText: p === null ? '' : (p < 0.05 ? 'significant' : 'not significant') + ' · p ' + (p < 0.001 ? '< 0.001' : p.toFixed(3)) + (adjust && adjusted[i] !== null ? ' (adjusted)' : ''), + zero: numbersA.concat(numbersB).every(v => v === 0), + reference: allRuns ? median(numbersA) : a, + valuesA, valuesB, + fmt: def.fmt + }); + } + return rows; +} diff --git a/public/js/compare/share.js b/public/js/compare/share.js index 743c810..49e503b 100644 --- a/public/js/compare/share.js +++ b/public/js/compare/share.js @@ -102,12 +102,14 @@ function downloadBundle(btn, cfg) { har1: { har: cfg.har1.har, run: cfg.har1.run, - label: cfg.har1.label + label: cfg.har1.label, + drawings: cfg.har1.drawings }, har2: { har: cfg.har2.har, run: cfg.har2.run, - label: cfg.har2.label + label: cfg.har2.label, + drawings: cfg.har2.drawings } }; const blob = new Blob([JSON.stringify(bundle)], { type: 'application/json' }); diff --git a/public/js/compare/start.js b/public/js/compare/start.js index 8c34215..e4ff4c4 100644 --- a/public/js/compare/start.js +++ b/public/js/compare/start.js @@ -1,4 +1,4 @@ -/* global createMainDropZone, showLoading, loadHARsFromConfig, loadFilesFromGist, loadFilesFromConfig, loadFromPaste, compareFromForm, errorMessage, showUpload, toggleWaterfallLayout, blendWaterfalls, regenerate, toggleDiffOnly, toggleRow */ +/* global createMainDropZone, showLoading, loadHARsFromConfig, loadFilesFromGist, loadFilesFromConfig, loadFromPaste, compareFromForm, errorMessage, showUpload, toggleWaterfallLayout, blendWaterfalls, regenerate, setCompareMode, pickRun, setMetricsQuery, toggleAllMetrics, toggleMetricSection, setRequestFilter, setRequestSort, setRequestMin, toggleTimelinePlay, scrubTimeline, showMetric, linkMetric, setTimelineSpeed, stepTimelineFrame, jumpTimeline, setDrawTool, undoDrawing, clearDrawings, saveDrawingPng */ // Start-up code for the page. It lives in a file, not in index.html, // because the Content-Security-Policy blocks inline scripts. @@ -91,8 +91,25 @@ const actions = { blend: el => blendWaterfalls(el.value), switch: () => regenerate(true), 'change-run': () => regenerate(), - 'toggle-diff-only': el => toggleDiffOnly(el), - 'toggle-row': el => toggleRow(el, el.dataset.rows, el.firstElementChild) + 'compare-mode': el => setCompareMode(el.dataset.mode), + 'pick-run': el => pickRun(Number(el.dataset.side), Number(el.dataset.run)), + 'toggle-all-metrics': () => toggleAllMetrics(), + 'toggle-section': el => toggleMetricSection(el.dataset.section), + 'metric-search': el => setMetricsQuery(el.value), + 'request-filter': el => setRequestFilter(el.dataset.filter), + 'request-sort': el => setRequestSort(el.dataset.sort), + 'request-min': el => setRequestMin(Number(el.value)), + 'timeline-play': () => toggleTimelinePlay(), + 'timeline-seek': el => scrubTimeline(Number(el.dataset.t)), + 'timeline-speed': el => setTimelineSpeed(el.dataset.speed), + 'timeline-step': el => stepTimelineFrame(Number(el.dataset.step)), + 'timeline-jump': () => jumpTimeline(), + 'filmstrip-zoom': el => el.closest('.card').style.setProperty('--filmstrip-frame-width', el.value + 'px'), + 'show-metric': el => showMetric(el.dataset.metric), + 'draw-tool': el => setDrawTool(el.dataset.tool), + 'draw-undo': () => undoDrawing(), + 'draw-clear': () => clearDrawings(), + 'draw-save': () => saveDrawingPng() }; const actionElements = { @@ -110,3 +127,11 @@ for (const [type, selector] of Object.entries(actionElements)) { actions[el.dataset.action](el); }); } + +// Pointing at (or tabbing to) a metric highlights it everywhere. +for (const [type, on] of [['mouseover', true], ['mouseout', false], ['focusin', true], ['focusout', false]]) { + document.addEventListener(type, e => { + const el = e.target instanceof Element && e.target.closest('[data-metric]'); + if (el) linkMetric(el.dataset.metric, on); + }); +} diff --git a/public/js/compare/stats.js b/public/js/compare/stats.js new file mode 100644 index 0000000..59e4f16 --- /dev/null +++ b/public/js/compare/stats.js @@ -0,0 +1,77 @@ +/* exported median, mannWhitneyP, benjaminiHochberg */ + +function median(values) { + if (!values.length) return undefined; + const sorted = values.slice().sort((a, b) => a - b); + const mid = sorted.length >> 1; + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +function mannWhitneyU(a, b) { + let u = 0; + for (const x of a) { + for (const y of b) u += x < y ? 1 : x === y ? 0.5 : 0; + } + return u; +} + +// Two-sided Mann-Whitney U test. Exact (every split of the pooled +// values) for up to 16 values, normal approximation above that. +// Returns null when a side has fewer than two values. +function mannWhitneyP(a, b) { + const n1 = a.length; + const n2 = b.length; + if (n1 < 2 || n2 < 2) return null; + const mean = (n1 * n2) / 2; + const observed = Math.abs(mannWhitneyU(a, b) - mean); + if (observed === 0) return 1; + + const all = a.concat(b); + if (all.length > 16) { + const sd = Math.sqrt((n1 * n2 * (all.length + 1)) / 12); + const z = (observed - 0.5) / sd; + return Math.min(1, 2 * (1 - normalCdf(z))); + } + + let extreme = 0; + let total = 0; + const picked = []; + const walk = start => { + if (picked.length === n1) { + const xs = picked.map(i => all[i]); + const ys = all.filter((_, i) => !picked.includes(i)); + total++; + if (Math.abs(mannWhitneyU(xs, ys) - mean) >= observed - 1e-9) extreme++; + return; + } + for (let i = start; i <= all.length - (n1 - picked.length); i++) { + picked.push(i); + walk(i + 1); + picked.pop(); + } + }; + walk(0); + return extreme / total; +} + +// Abramowitz and Stegun 7.1.26 +function normalCdf(z) { + const x = Math.abs(z) / Math.SQRT2; + const t = 1 / (1 + 0.3275911 * x); + const erf = 1 - ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x); + return z >= 0 ? (1 + erf) / 2 : (1 - erf) / 2; +} + +// Benjamini-Hochberg adjusted p-values. Many metrics are tested at the +// same time, so some get p < 0.05 by chance. The adjusted values keep +// the expected part of false alarms under 5%. null stays null. +function benjaminiHochberg(ps) { + const order = ps.map((p, i) => i).filter(i => ps[i] !== null).sort((i, j) => ps[i] - ps[j]); + const adjusted = ps.slice(); + let min = 1; + for (let k = order.length - 1; k >= 0; k--) { + min = Math.min(min, (ps[order[k]] * order.length) / (k + 1)); + adjusted[order[k]] = min; + } + return adjusted; +} diff --git a/public/js/compare/templates.js b/public/js/compare/templates.js index f6c1842..b4a6dec 100644 --- a/public/js/compare/templates.js +++ b/public/js/compare/templates.js @@ -1,5 +1,5 @@ -/* global formatBytes, formatTime, formatDate, formatURL */ -/* exported parseTemplate */ +/* global formatURL */ +/* exported parseTemplate, applyPositions */ // // Templates. Replaces the Template7 setup that used to live here — @@ -32,381 +32,273 @@ function each(collection, fn) { .join(''); } +// Inline style attributes are blocked by the Content-Security-Policy, so +// positions go in data-x / data-w and applyPositions() sets them. +function applyPositions(container) { + for (const el of container.querySelectorAll('[data-x]')) el.style.left = el.dataset.x + '%'; + for (const el of container.querySelectorAll('[data-w]')) el.style.width = el.dataset.w + '%'; +} + +function pill(cls, text) { + return '' + h(text) + ''; +} + +// One lane of run dots for a side. Positions are the difference from +// the reference value, on a shared -50% to +50% scale. A card passes its +// own scale and gets plain marks, because the card itself is a button. +function runDots(values, side, reference, selected, scale) { + return '' + each(values, function (v) { + const pct = reference ? ((v.value - reference) / reference) * 100 : 0; + const x = scale ? scale(v.value) : 50 + Math.max(-50, Math.min(50, pct)); + const cls = 'run-dot run-dot--' + side + + (v.run === selected ? ' run-dot--selected' : '') + + (!scale && Math.abs(pct) > 50 ? ' run-dot--clipped' : ''); + const label = 'Run ' + v.run + ': ' + v.text; + if (scale) return ''; + return ''; + }) + ''; +} + // -// visualProgressTemplate +// resultHeaderTemplate — links to the sections that have content. // -function visualProgressTemplate(d) { - return ( - '

Visual Progress

' + - '
' + - '
' + - '
' + - '
    ' + - '
  • ' + h(d.config.har1.label) + '
  • ' + - '
  • ' + h(d.config.har2.label) + '
  • ' + - '
' + - '
' - ); +function resultHeaderTemplate(d) { + return ''; } // -// pageXrayTemplate +// summaryTemplate — the answer first: the two HAR files, what to +// compare (all runs or single runs), a verdict and the key metrics. // -function pageXrayTemplate(d) { - const p1 = d.p1, p2 = d.p2, config = d.config; - const showRuns = d.runs1.length > 1 || d.runs2.length > 1; - - function section(title, kind) { - const cls = 'pageXraySection' + (kind ? ' pageXraySection--' + kind : ''); - 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 'no change'; - } - const cls = delta > 0 ? 'pageXrayDiff--worse' : 'pageXrayDiff--better'; - const sign = delta > 0 ? '+' : '−'; - const absDelta = Math.abs(delta); - const value = formatter ? formatter(absDelta) : absDelta.toString(); - let pctStr = ''; - if (a !== 0) { - const pct = Math.round((delta / a) * 100); - pctStr = ' (' + - (pct > 0 ? '+' : (pct < 0 ? '−' : '')) + - Math.abs(pct) + '%)'; - } - return '' + sign + value + pctStr + ''; - } - - // 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 = ''; - - function metricRow(label, a, b, formatter) { - return '' + h(label) + '' + - '' + (formatter ? formatter(a) : a) + '' + - '' + (formatter ? formatter(b) : b) + '' + - diffCell(a, b, formatter) + ''; - } - - // "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 += '
'; - // Visually-hidden caption — useful for screen readers and gives the - // table a programmatic name without taking up real estate. - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - - // Setup (top of the table — first rows after the header, no section - // title because there's no group above it to separate from). - if (showRuns) { - html += '' + emptyDiff + ''; + html += ''; } - html += '' + - '' + - '' + - emptyDiff + ''; - - html += '' + - '' + - '' + - emptyDiff + ''; - - html += '' + - '' + - '' + - emptyDiff + ''; - - if (p1.meta.connectivity && p2.meta.connectivity) { - html += '' + - '' + - '' + - emptyDiff + ''; - } - - html += section('Content', 'content'); - html += '' + - '' + - '' + - diffCell(p1.transferSize, p2.transferSize, formatBytes) + ''; - - ['html', 'css', 'javascript'].forEach(function (kind) { - const label = { html: 'HTML', css: 'CSS', javascript: 'JavaScript' }[kind]; - const a = p1.contentTypes[kind] || { requests: 0, transferSize: 0, contentSize: 0 }; - const b = p2.contentTypes[kind] || { requests: 0, transferSize: 0, contentSize: 0 }; - html += '' + - '' + - '' + - diffCell(a.transferSize, b.transferSize, formatBytes) + ''; - }); - - const img1 = p1.contentTypes.image || { requests: 0, transferSize: 0 }; - const img2 = p2.contentTypes.image || { requests: 0, transferSize: 0 }; - html += '' + - '' + - '' + - diffCell(img1.transferSize, img2.transferSize, formatBytes) + ''; - - if (p1.renderBlocking && p2.renderBlocking) { - html += section('Render blocking', 'blocking'); + const v = d.verdict; + html += '
' + pill(v.cls, v.tag) + + '

' + h(v.title) + '

' + h(v.text) + '

' + + (v.items.length ? '

' + each(v.items, function (i) { + return ''; + }) + '

' : '') + + '
'; + + html += '
' + each(d.cards, function (c) { + return ''; + }) + '
'; + return html; +} - // Style recalculation before FCP / LCP. Sitespeed.io / browsertime - // captures how many elements the browser re-styled and how long it - // took on its way to each paint milestone — both are "lower is - // better", so the existing diff colouring applies. Rendered before - // the blocking-request counts so the eye sees the work first and - // the per-request counts as context. Guarded so a plain Chrome HAR - // (no recalculateStyle) doesn't render four empty rows. - const rs1 = p1.renderBlocking.recalculateStyle; - const rs2 = p2.renderBlocking.recalculateStyle; - if (rs1 && rs2) { - if (rs1.beforeFCP && rs2.beforeFCP) { - html += metricRow('Style recalc elements (FCP)', - rs1.beforeFCP.elements, rs2.beforeFCP.elements); - html += metricRow('Style recalc time (FCP)', - rs1.beforeFCP.durationInMillis, rs2.beforeFCP.durationInMillis, formatTime); - } - if (rs1.beforeLCP && rs2.beforeLCP) { - html += metricRow('Style recalc elements (LCP)', - rs1.beforeLCP.elements, rs2.beforeLCP.elements); - html += metricRow('Style recalc time (LCP)', - rs1.beforeLCP.durationInMillis, rs2.beforeLCP.durationInMillis, formatTime); - } +// +// metricsTableTemplate — every metric, grouped in the same sections as +// before, with medians and ranges (all runs) or the two picked runs. +// +function metricsTableTemplate(d) { + if (!d.rows.length) return '

No metrics match.

'; + const head = '' + + '' + + '' + + '' + + '' + + '' + + ''; + const body = each(d.rows, function (r) { + if (r.sectionRow) { + const count = r.changedCount === undefined ? '' + : '' + (r.changedCount ? r.changedCount + ' changed' : 'No changes') + ''; + const fold = r.canFold + ? '' + : ''; + return ''; } + return '' + + '' + + '' + + '' + + ''; + }); + return '
Page X-ray comparison: ' + - h(config.har1.label) + ' versus ' + h(config.har2.label) + '
' + - '
' + - 'Metric' + - '' + - '' + - '' + - '' + - '
' + h(config.har1.label) + - '' + - ' ' + h(config.har2.label) + - '' + - 'Δ
Run'; - if (d.runs1.length > 1) { - html += ''; +function summaryTemplate(d) { + let html = '
' + + '

' + h(d.url) + '

' + + '

' + h(d.details) + '

' + + '
' + each(d.sides, function (s) { + return '' + + '' + h(s.label) + ' ' + h(s.when) + ' · ' + h(s.runs) + + '' + + ''; + }) + '
'; + + if (d.modeBar) { + html += '
'; + if (d.allowAll) { + html += '
' + + '' + + '' + + '
'; } - html += '
'; - if (d.runs2.length > 1) { - html += '' + each(p.options, function (o) { + return ''; + }) + ''; }); - html += ''; + if (d.outlierNote) html += pill('warn', d.outlierNote); } - html += '
URL' + h(p1.url) + '' + h(p2.url) + '
Date' + h(formatDate(p1.meta.startedDateTime)) + '' + h(formatDate(p2.meta.startedDateTime)) + '
Browser' + (p1.meta.browser ? h(p1.meta.browser.name) + ' ' + h(p1.meta.browser.version) : '') + '' + (p2.meta.browser ? h(p2.meta.browser.name) + ' ' + h(p2.meta.browser.version) : '') + '
Connectivity' + h(p1.meta.connectivity) + '' + h(p2.meta.connectivity) + '
Total' + p1.requests + ' (' + formatBytes(p1.transferSize) + ' / ' + formatBytes(p1.contentSize) + ')' + p2.requests + ' (' + formatBytes(p2.transferSize) + ' / ' + formatBytes(p2.contentSize) + ')
' + label + '' + a.requests + ' (' + formatBytes(a.transferSize) + ' / ' + formatBytes(a.contentSize) + ')' + b.requests + ' (' + formatBytes(b.transferSize) + ' / ' + formatBytes(b.contentSize) + ')
Image' + img1.requests + ' (' + formatBytes(img1.transferSize) + ')' + img2.requests + ' (' + formatBytes(img2.transferSize) + ')
Metric' + h(d.labelA) + '' + h(d.labelB) + 'ChangeRuns vs ' + h(d.referenceLabel) + + '
' + h(r.label) + count + fold + '
' + h(r.label) + '' + h(r.aText) + '' + h(r.aRange) + '' + h(r.bText) + '' + h(r.bRange) + '' + pill(r.cls, r.dText) + '' + h(r.pText) + '' + (r.showDots + ? runDots(r.dotsA, 1, r.reference, d.runA) + runDots(r.dotsB, 2, r.reference, d.runB) + : '') + '
' + + '' + + head + '' + body + '
All metrics: ' + h(d.labelA) + ' versus ' + h(d.labelB) + '
'; +} - // The three counters below are *request* counts — how many - // resources fall into each render-blocking class. The "requests" - // suffix makes that explicit so the numbers aren't mistaken for - // milliseconds or sizes. - const rb1 = p1.renderBlocking, rb2 = p2.renderBlocking; - html += metricRow('Render blocking requests', rb1.blocking, rb2.blocking); - html += metricRow('Potentially blocking requests', - rb1.potentiallyBlocking, rb2.potentiallyBlocking); - html += metricRow('In body parser blocking requests', - rb1.in_body_parser_blocking, rb2.in_body_parser_blocking); +// +// requestsTemplate — requests that are new, removed or changed size. +// +function requestsTemplate(d) { + const summary = '
' + each(d.summary, function (s) { + return '
' + h(s.label) + '' + h(s.value) + '
'; + }) + '
'; + const filters = '
' + each(d.filters, function (f) { + return ''; + }) + '
'; + if (!d.rows.length) { + return summary + filters + '

No requests match. Lower the size limit or pick another filter.

'; } + const sortButton = (id, label) => ''; + return summary + filters + + '
' + + '' + + '' + + '' + + '' + + '' + + each(d.rows, function (r) { + return '' + + '' + + '' + + ''; + }) + '
Request changes
' + sortButton('url', 'URL') + 'Type' + h(d.labelA) + '' + h(d.labelB) + '' + sortButton('change', 'Change') + '
' + h(formatURL(r.url)) + '' + h(r.type) + '' + h(r.aText) + '' + h(r.bText) + '' + + '' + h(r.dText) + '
'; +} + +// +// timelineMediaTemplate — the stage: the two runs at the timeline +// cursor, their videos when both have one, else their filmstrip frames. +// +function timelineMediaTemplate(d) { + return '
' + each(d.sides, function (s) { + const media = d.hasVideo + ? '' + : '' + h(s.label) + ' at the timeline cursor'; + return '
' + media + + '
' + h(s.label) + + '
'; + }) + '
'; +} - if (p1.visualMetrics) { - // Only render rows where both HARs have the value. - const vmRows = [ - ['First Visual Change', 'FirstVisualChange'], - ['Largest Image', 'LargestImage'], - ['Logo', 'Logo'], - ['Heading', 'Heading'], - ['Speed Index', 'SpeedIndex'], - ['Last Visual Change', 'LastVisualChange'], - ['Visual Readiness', 'VisualReadiness'] - ]; - let vmHtml = ''; - vmRows.forEach(function (row) { - const label = row[0], key = row[1]; - if (p1.visualMetrics[key] && p2.visualMetrics && p2.visualMetrics[key]) { - vmHtml += metricRow(label, p1.visualMetrics[key], p2.visualMetrics[key], formatTime); +// +// timelineTrackTemplate — milestone chips per HAR that light up as the +// cursor passes them, and the milestone timeline: HAR1 flags above the +// axis, HAR2 flags below, long tasks and divergence bands on the axis. +// +function timelineTrackTemplate(d) { + const pct = t => ((t / d.end) * 100).toFixed(2); + const hud = '
' + each(d.sides, function (s) { + return '
' + + '' + h(s.label) + '' + + each(s.milestones, function (m) { + return ''; + }) + '
'; + }) + '
'; + + let flags = ''; + d.sides.forEach(function (s) { + // Milestones at almost the same time share one flag. + const groups = []; + s.milestones.forEach(function (m) { + const group = groups[groups.length - 1]; + if (group && ((m.t - group.t) / d.end) * 100 < 3) { + group.short += ' · ' + m.short; + group.name += ', ' + m.name; + group.metric = null; + } else { + groups.push(Object.assign({}, m)); } }); - if (vmHtml) html += section('Visual metrics', 'visual') + vmHtml; - } - - if (p1.googleWebVitals && p2.googleWebVitals) { - html += section('Core Web Vitals', 'cwv'); - const gw1 = p1.googleWebVitals, gw2 = p2.googleWebVitals; - html += metricRow('First Contentful Paint', - gw1.firstContentfulPaint, gw2.firstContentfulPaint, formatTime); - html += metricRow('Largest Contentful Paint', - gw1.largestContentfulPaint, gw2.largestContentfulPaint, formatTime); - html += metricRow('Total Blocking Time', - gw1.totalBlockingTime, gw2.totalBlockingTime, formatTime); - html += metricRow('Cumulative Layout Shift', - gw1.cumulativeLayoutShift, gw2.cumulativeLayoutShift, fmtCLS); - } - - // Long-task block — sitespeed.io HARs that ran Lighthouse get - // these; older / WPT-style HARs don't, so the block stays silent. - let cpuHtml = ''; - let cpuSectionEmitted = false; - function ensureCpuSection() { - if (!cpuSectionEmitted) { - html += section('CPU', 'cpu'); - cpuSectionEmitted = true; - } - } - if (p1.cpu && p2.cpu && p1.cpu.longTasks && p2.cpu.longTasks) { - const lt1 = p1.cpu.longTasks, lt2 = p2.cpu.longTasks; - if (lt1.totalBlockingTime && lt2.totalBlockingTime) { - cpuHtml += metricRow('Total Blocking Time', - lt1.totalBlockingTime, lt2.totalBlockingTime, formatTime); - } - if (lt1.maxPotentialFid && lt2.maxPotentialFid) { - cpuHtml += metricRow('Max Potential FID', - lt1.maxPotentialFid, lt2.maxPotentialFid, formatTime); - } - if (lt1.tasks && lt2.tasks) { - cpuHtml += metricRow('Total CPU Long Tasks', lt1.tasks, lt2.tasks); - } - if (cpuHtml) { - ensureCpuSection(); - html += 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) + - ''; + let last = -100; + let low = false; + groups.forEach(function (m) { + const at = (m.t / d.end) * 100; + // Alternate lanes when two flags would collide. + low = at - last < 7 ? !low : false; + last = at; + flags += ''; }); - return '' + - '' + - '' + - '' + - '' + - '' + body + '
' + h(eventLabel) + '' + h(config.har1.label) + '' + h(config.har2.label) + 'Δ
'; - } - - 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) { - ensureCpuSection(); - html += ' CPU time spent by category ' + - ''; - html += '' + - cpuBreakdownTable(d.cpuCategories1, d.cpuCategories2, fmtCategoryValue, 'Category') + - ''; - html += 'CPU Events ' + - ''; - html += '' + - cpuBreakdownTable(d.cpuEvents1, d.cpuEvents2, fmtEventValue, 'Event') + - ''; - } + const busy = each(d.sides, function (s) { + return each(s.longTasks, function (task) { + return ''; + }); + }); + const bands = each(d.bands, function (b) { + return ''; + }); + const ticks = each(d.ticks, function (t) { + return '' + (t / 1000) + 's'; + }); - // Captures — final screenshot, video, link back to the per-run - // result page. Last group because the values are large media - // elements, not numbers; pushing them to the bottom keeps the - // scannable metric rows above contiguous. Screenshots and videos - // get a CSS-sized treatment via .pageXrayCapture so the regression - // spotter has room to actually see the difference. - let capturesHtml = ''; - if (p1.meta.screenshot && p2.meta.screenshot) { - capturesHtml += 'Screenshot' + - '' + - '' + - emptyDiff + ''; - } - if (p1.meta.video && p2.meta.video) { - capturesHtml += 'Video' + - '' + - '' + - emptyDiff + ''; - } - if (p1.meta.result && p2.meta.result) { - capturesHtml += 'Extra' + - 'Result page' + - 'Result page' + - emptyDiff + ''; - } - if (capturesHtml) html += section('Captures', 'captures') + capturesHtml; + return hud + + '
' + + '
' + bands + '
' + + '
' + + (busy ? '
' + busy + '
' : '') + + '
' + + flags + ticks + + '
' + + (busy ? '

Red marks on the timeline are long tasks: the main thread was busy and the page could not paint or respond.

' : ''); +} - html += '
'; - return html; +// +// capturesTemplate — final screenshots, videos and result pages. +// +function capturesTemplate(d) { + return '

Captures

' + each(d.sides, function (s) { + const links = []; + if (s.video) links.push('Video'); + if (s.result) links.push('Result page'); + return '
' + h(s.label) + '
' + + (s.screenshot + ? '' + : '') + + (links.length ? '

' + links.join(' · ') + '

' : '') + '
'; + }) + '
'; } // @@ -448,6 +340,7 @@ function filmstripTemplate(d) { return ''; } @@ -467,14 +360,19 @@ function filmstripTemplate(d) { const f2 = fs2[i]; const time = (f1 && f1.time) || (f2 && f2.time) || ''; const diffCls = divergenceClass(f1 && f1.progress, f2 && f2.progress); - columns += '
' + + const ms = (f1 && f1.ms) || (f2 && f2.ms) || 0; + // A dot on the time marks a column where either page changed. + const changed = i > 0 && ((f1 && fs1[i - 1] && f1.sourceMs !== fs1[i - 1].sourceMs) || + (f2 && fs2[i - 1] && f2.sourceMs !== fs2[i - 1].sourceMs)); + columns += '
' + cellHtml(f1, config.har1.label, time, 1) + cellHtml(f2, config.har2.label, time, 2) + '
' + h(time) + ' s
' + '
'; } - return '

Filmstrip

' + + return '

Filmstrip

' + + '
' + '
' + ''; } -// -// thirdPartyTemplate -// -function thirdPartyTemplate(d) { - const p1 = d.p1, p2 = d.p2, config = d.config; - let html = '

First Party vs Third Party content

'; - if (p1.firstPartyRegEx === p2.firstPartyRegEx) { - html += '

Using ' + h(p1.firstPartyRegEx) + ' as --firstParty parameter.

'; - } else { - html += '

Using ' + h(p1.firstPartyRegEx) + ' as --firstParty parameter for ' + - h(config.har1.label) + ' and ' + h(p2.firstPartyRegEx) + ' for ' + h(config.har2.label) + '.

'; - } - html += '
'; - html += '' + - '' + - '' + - '' + - ''; - - function pct(n, total) { - if (!n || !total || n <= 0) return 0; - return Math.round((n / total) * 100); - } - - function expandableRow(label, toggleClass, p1Cell, p2Cell, contentTypes1, contentTypes2, cellFn) { - let row = '' + - '' + - ''; - row += each(contentTypes1, function (entry, key) { - return '' + - '' + - '' + - '' + - ''; - }); - return row; - } - - // First-party block - html += expandableRow('First party requests', 'firstPartyRequests', - p1.firstParty.requests + ' (' + pct(p1.firstParty.requests, p1.requests) + '%)', - p2.firstParty.requests + ' (' + pct(p2.firstParty.requests, p2.requests) + '%)', - p1.firstParty.contentTypes, p2.firstParty.contentTypes, - function (entry) { return entry ? entry.requests : 0; } - ); - html += expandableRow('First party transfer size', 'firstPartyTransferSize', - formatBytes(p1.firstParty.transferSize) + ' (' + pct(p1.firstParty.transferSize, p1.transferSize) + '%)', - formatBytes(p2.firstParty.transferSize) + ' (' + pct(p2.firstParty.transferSize, p2.transferSize) + '%)', - p1.firstParty.contentTypes, p2.firstParty.contentTypes, - function (entry) { return formatBytes(entry ? entry.transferSize : 0); } - ); - html += expandableRow('First party content size', 'firstPartyContentSize', - formatBytes(p1.firstParty.contentSize) + ' (' + pct(p1.firstParty.contentSize, p1.contentSize) + '%)', - formatBytes(p2.firstParty.contentSize) + ' (' + pct(p2.firstParty.contentSize, p2.contentSize) + '%)', - p1.firstParty.contentTypes, p2.firstParty.contentTypes, - function (entry) { return formatBytes(entry ? entry.contentSize : 0); } - ); - html += '' + - '' + - ''; - - // Third-party block - html += expandableRow('Third party requests', 'thirdPartyRequests', - p1.thirdParty.requests + ' (' + pct(p1.thirdParty.requests, p1.requests) + '%)', - p2.thirdParty.requests + ' (' + pct(p2.thirdParty.requests, p2.requests) + '%)', - p1.thirdParty.contentTypes, p2.thirdParty.contentTypes, - function (entry) { return entry ? entry.requests : 0; } - ); - html += expandableRow('Third party transfer size', 'thirdPartyTransferSize', - formatBytes(p1.thirdParty.transferSize) + ' (' + pct(p1.thirdParty.transferSize, p1.transferSize) + '%)', - formatBytes(p2.thirdParty.transferSize) + ' (' + pct(p2.thirdParty.transferSize, p2.transferSize) + '%)', - p1.thirdParty.contentTypes, p2.thirdParty.contentTypes, - function (entry) { return formatBytes(entry ? entry.transferSize : 0); } - ); - html += expandableRow('Third party content size', 'thirdPartyContentSize', - formatBytes(p1.thirdParty.contentSize) + ' (' + pct(p1.thirdParty.contentSize, p1.contentSize) + '%)', - formatBytes(p2.thirdParty.contentSize) + ' (' + pct(p2.thirdParty.contentSize, p2.contentSize) + '%)', - p1.thirdParty.contentTypes, p2.thirdParty.contentTypes, - function (entry) { return formatBytes(entry ? entry.contentSize : 0); } - ); - html += '' + - '' + - ''; - - html += '
Metric ' + h(config.har1.label) + ' ' + h(config.har2.label) + '
' + label + ' ' + - '' + p1Cell + '' + p2Cell + '
  ' + h(key) + '' + cellFn(entry) + '' + cellFn(contentTypes2 ? contentTypes2[key] : null) + '
First party cookies' + (p1.firstParty.cookies != null ? p1.firstParty.cookies : '') + '
Third party cookies' + (p1.thirdParty.cookies != null ? p1.thirdParty.cookies : '') + '
'; - return html; -} - -// -// resultHeaderTemplate -// -function resultHeaderTemplate(d) { - const p1 = d.p1, p2 = d.p2; - let html = ''; - return html; -} - -// -// domainsTemplate -// -function domainsTemplate(d) { - const config = d.config; - let html = '

Domains

'; - html += '
'; - html += '
' + - '' + - '' + - '' + - '' + - '' + - ''; - - html += each(d.domains, function (domain) { - const safeId = 'domainDetails' + domain.name; - let row = '' + - '' + - '' + - '' + - '' + - '' + - ''; - row += '' + - '' + - ''; - return row; - }); - - html += '
Domain' + h(config.har1.label) + ' Request' + h(config.har2.label) + ' Request' + h(config.har1.label) + ' Size' + h(config.har2.label) + ' Size
' + h(domain.name) + ' ' + - '' + (domain.firstPage ? domain.firstPage.requests : '') + '' + (domain.secondPage ? domain.secondPage.requests : '') + '' + (domain.firstPage ? formatBytes(domain.firstPage.transferSize) : '') + '' + (domain.secondPage ? formatBytes(domain.secondPage.transferSize) : '') + '
 Total time spent HAR 1
' + - (domain.firstPage ? each(domain.firstPage.timings, function (val, key) { - return '  ' + h(key) + ' : ' + val.toFixed(0) + '
'; - }) : '') + - '
 Total time spent HAR 2
' + - (domain.secondPage ? each(domain.secondPage.timings, function (val, key) { - return '  ' + h(key) + ' : ' + val.toFixed(0) + '
'; - }) : '') + - '
'; - return html; -} - -// -// requestDiffTemplate -// -function requestDiffTemplate(d) { - if (!d.requestDiff) return ''; - let html = '

Request/response transfer size difference (larger than 1 KB)

'; - html += '
'; - - if (!d.requestDiff.length) { - html += '

There are no difference in size of the requests/responses.

'; - return html; - } - - const t = d.total; - - html += '
'; - html += '' + - '' + - '' + - '' + - '' + - ''; - - html += each(d.requestDiff, function (r) { - let diffCell; - if (r.har1 !== undefined && r.har2 !== undefined) { - if (r.diff < 0) { - diffCell = ''; - } else { - diffCell = ''; - } - } else if (r.har1 !== undefined) { - diffCell = ''; - } else { - diffCell = ''; - } - return '' + - '' + - '' + - '' + - diffCell + - ''; - }); - - html += '' + - '' + - '' + - (t.diff < 0 - ? '' - : '') + - ''; - - html += '
URL' + h(d.config.har1.label) + '' + h(d.config.har2.label) + 'Diff
-' + formatBytes(-r.diff) + '+' + formatBytes(r.diff) + 'RemovedNew
' + h(formatURL(r.url)) + '' + formatBytes(r.har1) + '' + formatBytes(r.har2) + '
Total' + formatBytes(t.har1) + '' + formatBytes(t.har2) + '-' + formatBytes(-t.diff) + '+' + formatBytes(t.diff) + '
'; - - // Summary table - html += '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - (t.diff < 0 - ? '' - : '') + - '
Summary of request/response differenceSize
New (' + t.new + ')' + formatBytes(t.newBytes) + '
Removed (' + t.removed + ')-' + formatBytes(t.removedBytes) + '
Increased (' + t.increased + ')' + formatBytes(t.increasedBytes) + '
Decreased (' + t.decreased + ')-' + formatBytes(-t.decreasedBytes) + '
Total-' + formatBytes(-t.diff) + '+' + formatBytes(t.diff) + '
'; - - html += '
'; - return html; -} - const TEMPLATES = { - visualProgressTemplate: visualProgressTemplate, - pageXrayTemplate: pageXrayTemplate, - filmstripTemplate: filmstripTemplate, - thirdPartyTemplate: thirdPartyTemplate, resultHeaderTemplate: resultHeaderTemplate, - domainsTemplate: domainsTemplate, - requestDiffTemplate: requestDiffTemplate + summaryTemplate: summaryTemplate, + metricsTableTemplate: metricsTableTemplate, + requestsTemplate: requestsTemplate, + timelineMediaTemplate: timelineMediaTemplate, + timelineTrackTemplate: timelineTrackTemplate, + capturesTemplate: capturesTemplate, + filmstripTemplate: filmstripTemplate }; function parseTemplate(templateId, data, divId) { const renderer = TEMPLATES[templateId]; if (!renderer) return; const target = document.getElementById(divId); - if (target) target.innerHTML = renderer(data); + if (target) { + target.innerHTML = renderer(data); + applyPositions(target); + } } diff --git a/public/js/compare/timeline.js b/public/js/compare/timeline.js new file mode 100644 index 0000000..1091693 --- /dev/null +++ b/public/js/compare/timeline.js @@ -0,0 +1,300 @@ +/* global generateVisualProgress, prefersReducedMotion, parseTemplate */ +/* exported initTimeline, setTimelineTime, scrubTimeline, toggleTimelinePlay, stopTimeline, addTimelineWaterfall, divergenceBands, setTimelineSpeed, stepTimelineFrame, jumpTimeline */ + +// The shared time cursor. The visual progress chart, the two videos (or +// filmstrip frames), the milestone timeline, the filmstrip and the +// waterfalls all follow it. +const timeline = { + t: 0, + maxTime: 1, + firstVisual: 0, + speed: 1, + playing: false, + raf: 0, + chart: null, + sides: {}, + columns: [], + current: null, + waterfalls: {} +}; + +// Time ranges where the visual progress of the two runs differs by +// threshold percentage points or more. +function divergenceBands(vp1, vp2, maxTime, threshold = 20) { + const steps = vp => Object.keys(vp || {}).map(t => [Number(t), vp[t]]).sort((a, b) => a[0] - b[0]); + const s1 = steps(vp1); + const s2 = steps(vp2); + if (!s1.length || !s2.length) return []; + const at = (s, t) => lastAtOrBefore(s, t, step => step[0])[1]; + const times = [...new Set([0, ...s1.map(s => s[0]), ...s2.map(s => s[0])])] + .filter(t => t < maxTime) + .sort((a, b) => a - b); + const bands = []; + times.forEach((t, i) => { + const end = i + 1 < times.length ? times[i + 1] : maxTime; + if (Math.abs(at(s1, t) - at(s2, t)) < threshold) return; + const last = bands[bands.length - 1]; + if (last && last.to === t) last.to = end; + else bands.push({ from: t, to: end }); + }); + return bands; +} + +function lastAtOrBefore(list, t, getTime) { + let found = list[0]; + for (const item of list) { + if (getTime(item) <= t) found = item; + } + return found; +} + +/** + * @param {Object} opts + * @param {number} opts.maxTime + * @param {number} opts.start + * @param {number} opts.firstVisual where "Jump to first visual change" goes + * @param {Object} opts.chart runs and markers for generateVisualProgress + * @param {Array} opts.track milestones and long tasks per HAR for the timeline + * @param {Object} opts.sides keyed by '1' and '2' + */ +function initTimeline(opts) { + stopTimeline(); + timeline.maxTime = Math.max(opts.maxTime, 1); + timeline.firstVisual = opts.firstVisual || 0; + timeline.sides = opts.sides; + timeline.waterfalls = {}; + timeline.current = null; + const bands = divergenceBands(opts.sides[1].vp, opts.sides[2].vp, timeline.maxTime); + timeline.chart = generateVisualProgress('visualProgress', { + ...opts.chart, + bands, + maxTime: timeline.maxTime, + onTime: scrubTimeline + }); + const note = document.getElementById('vpBandNote'); + if (note) note.hidden = !bands.length; + + const end = timeline.maxTime; + const tickStep = end <= 2000 ? 500 : end <= 6000 ? 1000 : end <= 15000 ? 2000 : 5000; + const ticks = []; + for (let t = 0; t <= end; t += tickStep) ticks.push(t); + parseTemplate('timelineTrackTemplate', { sides: opts.track, end, ticks, bands }, 'timelineTrackContent'); + addAxisControls(); + + timeline.columns = [...document.querySelectorAll('#filmstripContent .filmstrip-column')]; + setTimelineSpeed(timeline.speed); + setTimelineTime(opts.start || 0, true); +} + +// Drag on the timeline or use the arrow keys to move the cursor. +function addAxisControls() { + const track = document.getElementById('timelineTrack'); + const axis = document.getElementById('timelineAxis'); + if (!track || !axis) return; + const timeAt = e => { + const r = track.getBoundingClientRect(); + return ((e.clientX - r.left) / r.width) * timeline.maxTime; + }; + axis.addEventListener('pointerdown', e => { + axis.setPointerCapture(e.pointerId); + scrubTimeline(timeAt(e)); + }); + axis.addEventListener('pointermove', e => { + if (axis.hasPointerCapture(e.pointerId)) scrubTimeline(timeAt(e)); + }); + axis.addEventListener('keydown', e => { + const step = e.shiftKey ? 500 : 100; + const keys = { + ArrowLeft: timeline.t - step, + ArrowRight: timeline.t + step, + Home: 0, + End: timeline.maxTime + }; + if (!(e.key in keys)) return; + e.preventDefault(); + scrubTimeline(keys[e.key]); + }); +} + +// Move the cursor because the user pointed somewhere. +function scrubTimeline(t) { + stopTimeline(); + setTimelineTime(t, true); +} + +function setTimelineTime(t, seekVideos) { + timeline.t = Math.max(0, Math.min(timeline.maxTime, Number(t) || 0)); + const ms = timeline.t; + const pct = ((ms / timeline.maxTime) * 100).toFixed(3) + '%'; + + if (timeline.chart) timeline.chart.setCursor(ms); + const label = document.getElementById('timelineTime'); + if (label) label.textContent = (ms / 1000).toFixed(3) + ' s'; + const playhead = document.getElementById('timelinePlayhead'); + if (playhead) playhead.style.left = pct; + const fill = document.getElementById('timelineFill'); + if (fill) fill.style.width = pct; + const axis = document.getElementById('timelineAxis'); + if (axis) { + axis.setAttribute('aria-valuenow', String(Math.round(ms))); + axis.setAttribute('aria-valuetext', (ms / 1000).toFixed(2) + ' seconds'); + } + // Milestone chips and flags light up once the cursor has passed them. + for (const el of document.querySelectorAll('#timelineTrackContent [data-t]')) { + el.classList.toggle('is-passed', ms >= Number(el.dataset.t)); + } + for (const band of document.querySelectorAll('#timelineTrackContent .rendering-band')) { + band.classList.toggle('rendering-band--active', ms >= Number(band.dataset.from) && ms < Number(band.dataset.to)); + } + + for (const [key, side] of Object.entries(timeline.sides)) { + const progress = document.querySelector('.timeline-progress[data-side="' + key + '"]'); + const vp = Object.entries(side.vp || {}).map(([time, value]) => ({ time: Number(time), value })); + if (progress && vp.length) { + progress.textContent = lastAtOrBefore(vp, ms, v => v.time).value + '% rendered'; + } + const frame = document.querySelector('.timeline-frame[data-side="' + key + '"]'); + if (frame && side.frames && side.frames.length) { + const img = lastAtOrBefore(side.frames, ms, f => f.ms).img; + if (frame.getAttribute('src') !== img) frame.setAttribute('src', img); + } + const video = document.querySelector('.timeline-video[data-side="' + key + '"]'); + if (video && seekVideos && Math.abs(video.currentTime - ms / 1000) > 0.04) { + video.currentTime = ms / 1000; + } + } + + let current = null; + for (const column of timeline.columns) { + if (Number(column.dataset.ms) <= ms) current = column; + column.classList.remove('filmstrip-column--current'); + } + if (current) current.classList.add('filmstrip-column--current'); + if (current && current !== timeline.current) { + timeline.current = current; + keepInView(current); + } + + for (const wf of Object.values(timeline.waterfalls)) placeWaterfallCursor(wf); +} + +// Scroll the filmstrip so the current frame stays visible. Only scrolls +// when the frame is about to leave the view, so playing does not jitter. +function keepInView(column) { + const rail = column.closest('.filmstrip-rail'); + if (!rail) return; + const c = column.getBoundingClientRect(); + const r = rail.getBoundingClientRect(); + if (c.left >= r.left && c.right <= r.right) return; + rail.scrollBy({ + left: c.left - r.left - (r.width - c.width) / 2, + behavior: prefersReducedMotion() ? 'auto' : 'smooth' + }); +} + +function setTimelineSpeed(speed) { + timeline.speed = Number(speed) || 1; + for (const button of document.querySelectorAll('[data-action="timeline-speed"]')) { + button.setAttribute('aria-pressed', String(Number(button.dataset.speed) === timeline.speed)); + } + for (const video of document.querySelectorAll('.timeline-video')) video.playbackRate = timeline.speed; +} + +// Go to the previous or next filmstrip frame. +function stepTimelineFrame(direction) { + const times = timeline.columns.map(c => Number(c.dataset.ms)); + const next = direction > 0 + ? times.find(t => t > timeline.t + 0.5) + : times.filter(t => t < timeline.t - 0.5).pop(); + scrubTimeline(next === undefined ? (direction > 0 ? timeline.maxTime : 0) : next); +} + +function jumpTimeline() { + scrubTimeline(timeline.firstVisual); +} + +/** + * Show the cursor on a waterfall-tools waterfall. The renderer knows + * where its time axis starts (labelsWidth) and its scale (widthPerMs). + */ +function addTimelineWaterfall(key, renderer, container) { + const cursor = document.createElement('div'); + cursor.className = 'wf-cursor'; + container.appendChild(cursor); + const wf = { renderer, container, cursor }; + timeline.waterfalls[key] = wf; + // waterfall-tools draws in the next frames and again on resize. + let tries = 0; + const place = () => { + placeWaterfallCursor(wf); + if (!cursor.style.left && ++tries < 30) requestAnimationFrame(place); + }; + place(); + // waterfall-tools sets the canvas size after the container changes. + const resize = new ResizeObserver(() => placeWaterfallCursor(wf)); + resize.observe(container); + const canvas = container.querySelector('canvas'); + if (canvas) resize.observe(canvas); +} + +function placeWaterfallCursor(wf) { + const dims = wf.renderer && wf.renderer.drawnRows && wf.renderer.drawnRows.dimensions; + const canvas = wf.container.querySelector('canvas'); + if (!dims || !canvas || !dims.canvasWidth) return; + const x = (dims.labelsWidth + timeline.t * dims.widthPerMs) * (canvas.clientWidth / dims.canvasWidth); + wf.cursor.style.left = x.toFixed(1) + 'px'; +} + +function toggleTimelinePlay() { + if (timeline.playing) stopTimeline(); + else playTimeline(); +} + +function playTimeline() { + if (timeline.t >= timeline.maxTime) setTimelineTime(0, true); + timeline.playing = true; + updatePlayButton(); + document.getElementById('visualProgress').classList.add('vp-chart--playing'); + const track = document.getElementById('timelineTrack'); + if (track) track.classList.add('rendering-timeline--playing'); + const videos = [...document.querySelectorAll('.timeline-video')]; + for (const video of videos) { + video.currentTime = timeline.t / 1000; + video.playbackRate = timeline.speed; + video.play().catch(() => {}); + } + let last = performance.now(); + const step = now => { + if (!timeline.playing) return; + // A playing video drives the clock so frames and cursor stay in step. + const lead = videos.find(v => !v.paused && !v.ended); + const t = lead ? lead.currentTime * 1000 : timeline.t + (now - last) * timeline.speed; + last = now; + if (t >= timeline.maxTime) { + setTimelineTime(timeline.maxTime, false); + stopTimeline(); + return; + } + setTimelineTime(t, false); + timeline.raf = requestAnimationFrame(step); + }; + timeline.raf = requestAnimationFrame(step); +} + +function stopTimeline() { + timeline.playing = false; + cancelAnimationFrame(timeline.raf); + const chart = document.getElementById('visualProgress'); + if (chart) chart.classList.remove('vp-chart--playing'); + const track = document.getElementById('timelineTrack'); + if (track) track.classList.remove('rendering-timeline--playing'); + for (const video of document.querySelectorAll('.timeline-video')) video.pause(); + updatePlayButton(); +} + +function updatePlayButton() { + const button = document.getElementById('timelinePlay'); + if (!button) return; + button.textContent = timeline.playing ? '❚❚ Pause' : '▶ Play'; + button.setAttribute('aria-pressed', timeline.playing ? 'true' : 'false'); +} diff --git a/public/js/compare/upload.js b/public/js/compare/upload.js index cd4db86..0091096 100644 --- a/public/js/compare/upload.js +++ b/public/js/compare/upload.js @@ -152,9 +152,13 @@ function createUpload(id) { const url1 = changeHar1 ? undefined : prev.har1 && prev.har1.url; const url2 = changeHar1 ? (prev.har2 && prev.har2.url) : undefined; + // The drawings on the HAR that stays are kept. + const drawings1 = changeHar1 ? undefined : prev.har1 && prev.har1.drawings; + const drawings2 = changeHar1 ? prev.har2 && prev.har2.drawings : undefined; + generate({ - har1: { har: har1, run: run1, label: label1, url: url1 }, - har2: { har: har2, run: run2, label: label2, url: url2 }, + har1: { har: har1, run: run1, label: label1, url: url1, drawings: drawings1 }, + har2: { har: har2, run: run2, label: label2, url: url2, drawings: drawings2 }, stripVersion: stripVersion, title: prev.title, firstParty: prev.firstParty, diff --git a/public/js/compare/ux.js b/public/js/compare/ux.js index 0c76ff4..90cfc45 100644 --- a/public/js/compare/ux.js +++ b/public/js/compare/ux.js @@ -1,4 +1,4 @@ -/* exported showUpload, formatDate, formatURL, toggleRow, hideUpload, objectPropertiesToArray, formatTime, showLoading, errorMessage, formatBytes, blendWaterfalls, toggleDiffOnly, toggleWaterfallLayout, applyWaterfallLayoutPreference */ +/* exported showUpload, formatDate, formatURL, hideUpload, formatTime, showLoading, errorMessage, formatBytes, blendWaterfalls, toggleWaterfallLayout, applyWaterfallLayoutPreference, prefersReducedMotion */ // Hide the upload functionality function hideUpload() { @@ -16,12 +16,13 @@ function removeAndHide() { } removeChildren('har1'); removeChildren('har2'); - removeChildren('pageXrayContent'); + removeChildren('summaryContent'); + removeChildren('visualProgress'); + removeChildren('timelineMedia'); removeChildren('filmstripContent'); - removeChildren('thirdPartyContent'); - removeChildren('visualProgressContent'); - removeChildren('requestDiffContent'); - removeChildren('domainsContent'); + removeChildren('metricsTableContent'); + removeChildren('requestsContent'); + removeChildren('capturesContent'); hide('result'); hide('loading'); } @@ -39,20 +40,6 @@ function showLoading() { el2.style.display = 'block'; } -function objectPropertiesToArray(object) { - const array = []; - for (let key in object) { - if (Object.hasOwn(object, key)) { - array.push({ name: key, value: object[key] }); - } - } - return array.sort(function(a, b) { - if (a.name < b.name) return -1; - if (a.name > b.name) return 1; - return 0; - }); -} - // Put a error message on the screen function errorMessage(myMessage) { const message = document.getElementById('message'); @@ -73,6 +60,10 @@ function hide(id) { } } +function prefersReducedMotion() { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + function formatDate(dateString) { return new Date(dateString).toLocaleString(); } @@ -181,31 +172,3 @@ function toggleWaterfallLayout() { try { localStorage.setItem('compare.waterfallSideBySide', on ? '1' : '0'); } catch (e) { /* localStorage blocked */ } } - -// "Only show differences" toggle for the page-x-ray table — hides -// rows whose Δ cell renders "no change", so a long metric list -// collapses to just the things that actually moved. Persists the -// choice so the page comes back the way the user left it. -function toggleDiffOnly(btn) { - const table = document.querySelector('.pageXrayTable'); - if (!table) return; - const on = !table.classList.contains('pageXrayTable--diff-only'); - table.classList.toggle('pageXrayTable--diff-only', on); - btn.setAttribute('aria-pressed', on ? 'true' : 'false'); - try { localStorage.setItem('compare.diffOnly', on ? '1' : '0'); } - catch (e) { /* localStorage blocked — preference is in-memory only */ } -} - -function toggleRow(element, className, toggler) { - const rows = element.closest('table').getElementsByClassName(className); - - for (let i = 0; i < rows.length; i++) { - if (getComputedStyle(rows[i]).display === 'none') { - rows[i].style.display = 'table-row'; - toggler.style.transform = 'rotate(45deg)'; - } else { - rows[i].style.display = 'none'; - toggler.style.transform = 'rotate(-45deg)'; - } - } -} diff --git a/src/css/buttons.css b/src/css/buttons.css index 9c0acd4..e0cb684 100644 --- a/src/css/buttons.css +++ b/src/css/buttons.css @@ -8,7 +8,7 @@ .submit { display: block; margin: 16px auto; - padding: 12px 22px; + padding: 10px 28px; background-color: var(--color-blue); color: var(--color-surface-card); font-family: var(--font-sans); @@ -16,7 +16,7 @@ font-weight: var(--font-weight-semibold); letter-spacing: 0.01em; border: 1px solid var(--color-blue); - border-radius: var(--radius-sm); + border-radius: var(--radius-pill); text-decoration: none; white-space: nowrap; cursor: pointer; diff --git a/src/css/domains.css b/src/css/domains.css deleted file mode 100644 index b0d2bcc..0000000 --- a/src/css/domains.css +++ /dev/null @@ -1,25 +0,0 @@ -/* - * DOMAINS / THIRD-PARTY - * ===================== - * Helper classes used by the per-domain breakdown table and the - * expandable third-party rows. - */ - -.no-break { - word-break: keep-all; -} - -/* Disclosure arrows on collapsible domain rows. */ -.right, -.down { - display: inline-block; - width: 0.65em; - height: 0.65em; - border-right: 2px solid var(--color-text-secondary); - border-bottom: 2px solid var(--color-text-secondary); - margin-right: 6px; - transition: transform var(--motion-fast); -} - -.right { transform: rotate(-45deg); } -.down { transform: rotate(45deg); } diff --git a/src/css/filmstrip.css b/src/css/filmstrip.css index 0dac05a..7054cc9 100644 --- a/src/css/filmstrip.css +++ b/src/css/filmstrip.css @@ -42,7 +42,7 @@ } } .filmstrip-legend-label--har1::before { background: var(--color-blue); } -.filmstrip-legend-label--har2::before { background: #f97316; /* orange — deliberately +.filmstrip-legend-label--har2::before { background: var(--color-har2); /* orange — deliberately distinct from HAR1 blue so the eye can tell which row is which without reading the label. */ } @@ -146,7 +146,7 @@ border-left: 4px solid var(--color-blue); } .filmstrip-cell--har2 { - border-left: 4px solid #f97316; + border-left: 4px solid var(--color-har2); } .filmstrip-cell--har1:hover, .filmstrip-cell--har1:focus-visible { @@ -155,7 +155,7 @@ } .filmstrip-cell--har2:hover, .filmstrip-cell--har2:focus-visible { - border-color: #f97316; + border-color: var(--color-har2); } .filmstrip-cell-badge { @@ -176,7 +176,7 @@ pointer-events: none; } .filmstrip-cell--har1 .filmstrip-cell-badge { background: var(--color-blue); } -.filmstrip-cell--har2 .filmstrip-cell-badge { background: #f97316; } +.filmstrip-cell--har2 .filmstrip-cell-badge { background: var(--color-har2); } .filmstrip-cell img { display: block; diff --git a/src/css/home.css b/src/css/home.css index 78a5a90..2035f32 100644 --- a/src/css/home.css +++ b/src/css/home.css @@ -3,19 +3,74 @@ * ============================ * Single column with a generous drop-target card and URL inputs * underneath. Centred at desktop widths, full-width on phones. + * The soft blue and orange background matches the two HAR colors. */ +.home { + min-height: 100vh; + /* Soft blue and orange light, the colors of HAR 1 and HAR 2. */ + background: + radial-gradient(40rem 26rem at 12% -6%, rgba(0, 149, 210, 0.16), transparent 70%), + radial-gradient(36rem 24rem at 92% 4%, rgba(249, 115, 22, 0.13), transparent 70%), + var(--color-surface); + + .header { + display: flex; + align-items: center; + background: transparent; + border-bottom: 0; + } +} + +.home-brand { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 1.15rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text); + text-decoration: none; + + .img-logo { width: 40px; } +} + +.home-main { + max-width: 720px; + margin: 0 auto; + padding: 24px 16px 64px; + text-align: center; +} + +.home-title { + margin: 24px 0 12px; + font-size: clamp(1.9rem, 4.5vw, 3rem); + font-weight: 700; + line-height: 1.12; + letter-spacing: -0.03em; + text-wrap: balance; +} +.home-har1 { color: var(--color-har1-ink); } +.home-har2 { color: var(--color-har2-ink); } + +.home-lead { + max-width: 560px; + margin: 0 auto 32px; + font-size: 1.05rem; + color: var(--color-text-secondary); + text-wrap: balance; +} + .dropzone { - max-width: 640px; - margin: 32px auto 0; - padding: 36px 28px; + margin: 0; + padding: 40px 28px 32px; text-align: center; - background: var(--color-surface-card); + cursor: pointer; + background: rgba(255, 255, 255, 0.85); border: 2px dashed var(--color-border-strong); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); - transition: border-color var(--motion-fast), - background-color var(--motion-fast); + border-radius: 20px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 12px 32px -12px rgba(15, 23, 42, 0.12); + transition: border-color var(--motion-med), background-color var(--motion-med), + box-shadow var(--motion-med), transform var(--motion-med); h2 { margin: 0 0 8px 0; } @@ -23,15 +78,184 @@ color: var(--color-text-secondary); margin: 0 0 16px 0; } + + &:hover { + border-color: var(--color-blue); + transform: translateY(-2px); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 18px 40px -14px rgba(0, 149, 210, 0.35); + } +} + +.dropzone-icon { + width: 72px; + height: 72px; + margin-bottom: 8px; + transition: transform var(--motion-med); +} +.dropzone-file1 { fill: var(--color-blue-tint); stroke: var(--color-har1); stroke-width: 2.5; } +.dropzone-file2 { fill: #fff7ed; stroke: var(--color-har2); stroke-width: 2.5; } +.dropzone-arrow { + fill: none; + stroke: var(--color-har2-ink); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; +} + +.dropzone .dropzone-title { + margin-bottom: 4px; + font-size: 1.25rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text); +} + +.dropzone-button { + display: inline-block; + padding: 10px 20px; + font-weight: var(--font-weight-semibold); + color: var(--color-surface-card); + background: var(--color-blue); + border-radius: var(--radius-pill); + transition: background-color var(--motion-fast); +} +.dropzone:hover .dropzone-button { background: var(--color-blue-dark); } + +.home-private { + margin: 12px 0 0; + font-size: 0.85rem; + color: var(--color-text-muted); +} + +.home-tip { + display: flex; + gap: 10px; + align-items: baseline; + margin: 16px 0 0; + padding: 12px 16px; + text-align: left; + font-size: 0.9rem; + color: var(--color-text-secondary); + background: rgba(255, 255, 255, 0.7); + border: 1px solid var(--color-info-border); + border-radius: var(--radius-md); + + code { font-size: 0.85em; white-space: nowrap; } +} + +.home-tip-badge { + flex: none; + padding: 1px 8px; + font-size: 0.75rem; + font-weight: var(--font-weight-semibold); + color: var(--color-info); + background: var(--color-info-bg); + border-radius: var(--radius-pill); +} + +.home-or { + display: flex; + align-items: center; + gap: 12px; + margin: 28px 0; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-text-muted); + + &::before, + &::after { + content: ""; + flex: 1; + border-top: 1px solid var(--color-border); + } +} + +.home-urls { + padding: 20px; + text-align: left; + background: rgba(255, 255, 255, 0.85); + border: 1px solid var(--color-border); + border-radius: 20px; + box-shadow: var(--shadow-md); + + .smallish { + margin: 0 0 12px; + text-align: left; + } +} + +.home-url { + position: relative; + display: block; + margin-bottom: 10px; + + .input-field { + max-width: none; + margin: 0; + padding-left: 46px; + } +} + +.home-url-badge { + position: absolute; + top: 50%; + left: 10px; + display: grid; + place-items: center; + width: 26px; + height: 26px; + transform: translateY(-50%); + font-size: 0.8rem; + font-weight: var(--font-weight-semibold); + color: var(--color-surface-card); + border-radius: 50%; +} +.home-url--1 .home-url-badge { background: var(--color-har1); } +.home-url--2 .home-url-badge { background: var(--color-har2-ink); } + +.home-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 14px; + + .submit { margin: 0; } +} + +.home-check { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + color: var(--color-text-secondary); + cursor: pointer; + + input { width: 16px; height: 16px; accent-color: var(--color-blue); } +} + +.home-links { + margin-top: 24px; + + a { white-space: nowrap; } } -.fd-zone { +@media (prefers-reduced-motion: reduce) { + .dropzone:hover { transform: none; } +} + +.dropzone { position: relative; overflow: hidden; &.over { border-color: var(--color-blue); + border-style: solid; background: var(--color-blue-tint); + box-shadow: 0 0 0 6px rgba(0, 149, 210, 0.15), 0 18px 40px -14px rgba(0, 149, 210, 0.35); + + .dropzone-icon { transform: translateY(-4px) scale(1.06); } } } diff --git a/src/css/main.css b/src/css/main.css index ef80cb9..d261d33 100644 --- a/src/css/main.css +++ b/src/css/main.css @@ -14,7 +14,5 @@ @import './waterfall.css'; @import './filmstrip.css'; @import './visual-progress.css'; -@import './domains.css'; -@import './page-xray.css'; -@import './request-diff.css'; +@import './result.css'; @import './lightbox.css'; diff --git a/src/css/page-xray.css b/src/css/page-xray.css deleted file mode 100644 index bfc52d1..0000000 --- a/src/css/page-xray.css +++ /dev/null @@ -1,293 +0,0 @@ -/* - * PAGE X-RAY TABLE - * ================ - * Side-by-side metric breakdown plus a Δ column — the dominant table - * on the result view. Table itself inherits the base table styling - * from elements.css; this file tunes its layout, the section - * dividers, the delta colouring and the captures sizing. - */ - -.userShowable { - display: none; - background: var(--color-surface); - border-radius: var(--radius-sm); - padding: 8px 12px; -} - -/* - * Section divider rows — full-width header cells inserted between - * groups of related metrics ("Content", "Render blocking", "Visual - * metrics", "Core Web Vitals", "CPU", "Captures"). Each kind gets - * its own quiet pastel/text pair so the eye can tell sections apart - * at a glance instead of seeing identical bars stacked down the - * table. Hover is suppressed so they stay visually distinct from - * data rows. - */ -.pageXrayTable .pageXraySection th { - text-align: left; - font-size: 0.7rem; - font-weight: var(--font-weight-semibold); - letter-spacing: 0.08em; - text-transform: uppercase; - padding: 14px 12px 6px; - border-bottom: 1px solid var(--color-border); -} - -.pageXrayTable .pageXraySection--content th { background: var(--color-info-bg); color: var(--color-info); } -.pageXrayTable .pageXraySection--blocking th { background: var(--color-warning-bg); color: var(--color-warning); } -.pageXrayTable .pageXraySection--visual th { background: #f3e8ff; color: #6b21a8; } -.pageXrayTable .pageXraySection--cwv th { background: var(--color-ok-bg); color: var(--color-ok); } -.pageXrayTable .pageXraySection--cpu th { background: #ffedd5; color: #9a3412; } -.pageXrayTable .pageXraySection--captures th { background: var(--color-surface); color: var(--color-text-muted); } - -/* Hover repeats the section's own bg so the row doesn't snap to the - default row-hover blue tint when the user mouses over a header. */ -.pageXrayTable .pageXraySection--content:hover th { background: var(--color-info-bg); } -.pageXrayTable .pageXraySection--blocking:hover th { background: var(--color-warning-bg); } -.pageXrayTable .pageXraySection--visual:hover th { background: #f3e8ff; } -.pageXrayTable .pageXraySection--cwv:hover th { background: var(--color-ok-bg); } -.pageXrayTable .pageXraySection--cpu:hover th { background: #ffedd5; } -.pageXrayTable .pageXraySection--captures:hover th { background: var(--color-surface); } - -.pageXrayTable { - table-layout: fixed; - - tr { - transition: background-color var(--motion-fast); - - &:hover td { - background: var(--color-blue-tint); - color: var(--color-text); - - a { color: var(--color-blue-dark); } - } - } -} - -.tableXrayMetric { width: 22%; text-align: left; } - -/* - * Metric column header — "Metric" label sits on top of a row of - * action chips (Switch + Only differences). Stacking with flex - * keeps the chips aligned consistently with each other and with the - * column's left edge (which is where the row labels below sit), - * instead of relying on inline-wrap and ending up indented. - */ -.tableXrayMetric-stack { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 6px; -} -.tableXrayMetric-actions { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - /* Negative margin so the button's visible text edge lines up with - the metric labels below ("URL", "Date", …) — the button's own - border + padding would otherwise indent the text by ~9px. */ - margin-left: -9px; -} -.tableXrayMetric-actions .chip-toggle, -.tableXrayMetric-actions .submit-smaller { - /* `gap` handles spacing — strip the default left margins. */ - margin-left: 0; -} -.tableXrayHarMetric, -.tableXrayHar2Metric { width: 31%; } -.tableXrayDiff { width: 16%; text-align: right; padding-right: 12px; color: var(--color-text-muted); font-weight: var(--font-weight-medium); } - -/* - * Δ column — at-a-glance regression / improvement marker. Every - * metric in this table is "lower is better"; HAR2 minus HAR1 > 0 is a - * regression (red), < 0 is an improvement (green). The colour comes - * from the modifier class, the layout is shared. - */ -.pageXrayDiff { - text-align: right; - padding-right: 12px; - font-variant-numeric: tabular-nums; - font-weight: var(--font-weight-semibold); - white-space: nowrap; - font-size: 0.9rem; -} -.pageXrayDiff--better { color: var(--color-ok); } -.pageXrayDiff--worse { color: var(--color-error); } -.pageXrayDiff--same { color: var(--color-text-muted); font-weight: var(--font-weight-regular); } -.pageXrayDiff-pct { - font-weight: var(--font-weight-regular); - opacity: 0.85; - margin-left: 2px; -} - -/* - * "Only show differences" toggle chip — lives in the metric header - * next to Switch. When pressed, the user only sees rows where the Δ - * column actually reports a change; "no change" rows fold away. - */ -.chip-toggle { - display: inline-flex; - align-items: center; - padding: 4px 10px; - /* Visually separate from any preceding button or text. Reset to 0 - inside a flex parent (e.g. .card-title) where `gap` already - handles the spacing. */ - margin-left: 12px; - vertical-align: middle; - background: var(--color-surface); - color: var(--color-text-secondary); - border: 1px solid var(--color-border); - border-radius: var(--radius-pill, 999px); - font-size: 0.78rem; - font-weight: var(--font-weight-medium); - cursor: pointer; - transition: background var(--motion-fast), color var(--motion-fast), - border-color var(--motion-fast); -} -.card-title > .chip-toggle { - margin-left: 0; -} -.chip-toggle:hover, -.chip-toggle:focus-visible { - background: var(--color-blue-tint); - color: var(--color-blue-dark); - border-color: var(--color-info-border); -} -.chip-toggle[aria-pressed="true"] { - background: var(--color-blue); - color: white; - border-color: var(--color-blue); -} - -/* - * Hide rows whose Δ cell explicitly reports "no change". Section - * dividers and rows without a Δ cell stay visible — the toggle is - * meant to reduce noise, not strip the table of its structure. - */ -.pageXrayTable--diff-only tr:has(td.pageXrayDiff--same) { - display: none; -} - -/* Captures — final screenshot and recorded video. The whole point of - the comparison view is to spot regressions, so the captures need - real room: width caps at column width (responsive) with a sensible - minimum that still beats the previous 200px hard-cap. */ -.pageXrayCapture { - display: block; - width: 100%; - max-width: 460px; - height: auto; - border-radius: var(--radius-sm); - border: 1px solid var(--color-border); - background: var(--color-surface-card); -} - -/* - * CPU disclosure sub-tables. The cell hosting the sub-table loses the - * parent table's hover styling so the inner header/rows read on their - * own; the sub-table itself uses smaller text and the same Δ column - * styling so a regression in "scripting" reads the same way as a - * regression in the main table. - */ -.cpuBreakdownCell { - padding: 0; - background: var(--color-surface); -} -.pageXrayTable tr.userShowable:hover td.cpuBreakdownCell { - background: var(--color-surface); -} - -.cpuBreakdownTable { - width: 100%; - margin: 0; - font-size: 0.875rem; - border-collapse: collapse; - table-layout: fixed; - - th, td { - padding: 6px 12px; - border-bottom: 1px solid var(--color-border); - } - th { - background: var(--color-surface-card); - text-align: left; - font-weight: var(--font-weight-semibold); - color: var(--color-text-secondary); - font-size: 0.8rem; - } - th:nth-child(1) { width: 22%; } - th:nth-child(2), - th:nth-child(3) { width: 31%; text-align: right; } - th:nth-child(4) { width: 16%; text-align: right; } - td:nth-child(1) { text-align: left; } - td:nth-child(2), - td:nth-child(3), - td:nth-child(4) { text-align: right; font-variant-numeric: tabular-nums; } - tbody tr:last-child td { border-bottom: 0; } - tbody tr:hover td { background: var(--color-blue-tint); } -} - -/* - * Pretty file-upload chip — styled to look like a small secondary - * button. The native stays visually hidden but - * remains keyboard-accessible because the label is a real - *