feat(demo): pixel-peeping compare mode in modal#9
Conversation
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
transcode-mcp | 3ee2b41 | Commit Preview URL Branch Preview URL |
May 27 2026, 10:24 PM |
95431f6 to
14292d9
Compare
The modal is now a two-panel compare view designed for the strategy
comparison use case: looking at two encodes side-by-side at native
pixel resolution to see whether one strategy's artifacts are easier
on the eye than another at the same byte budget.
How it works:
Click any tile (or the baseline) and the modal opens with:
- Left panel: source baseline (passthrough) by default
- Right panel: the tile you clicked
- A picker on each panel lets you swap to ANY other comparison source —
baseline, any target width, any quality preset that's loaded. This
makes the 'compare different strategies for the same byte budget' case
trivial: pick low-q at large target on one side, high-q at small
target on the other, and pan/zoom to compare.
Pan/zoom is locked across both panels by construction, not by JS sync:
a single scrollable viewport contains a grid with both image cells
side-by-side. Scrolling moves both at once because they share the
scroll position. Zoom multiplies the canvas width (and both cells get
half), so the spatial alignment stays correct at any zoom level.
Defaults match your answers to the calibration questions:
- 1:1 native pixel zoom by default (zoom factor set so the larger
image's natural pixels equal display pixels)
- Source baseline as the default left-panel comparison
- Pan/zoom locked together by design — no toggle needed
Zoom controls: +/- buttons with 1.5× steps, Fit (1.0× / fits modal),
and 1:1 (native pixels). Keyboard shortcuts: +, -, 0 (fit), 1 (1:1),
Esc (close). image-rendering: pixelated keeps pixels sharp at zoom
instead of bilinear-smoothing them.
For comparisons between two transcoded tiles (not baseline), the
explanation strip computes the byte-budget ratio so you can see
whether you're comparing similar file sizes — the canon-relevant case
of 'lower quality with larger resolution vs higher quality and lower
resolution.'
Removed:
- openModalForTile and openModalForBaseline (replaced by single
openCompareModal that handles both via the picker)
- .modal-image-wrap, .modal-meta CSS (replaced by compare-* layout)
Added:
- gatherCompareEntries() — collects baseline + every loaded tile as
picker options, with normalized metadata
- compareState { leftId, rightId, zoom } — single source of truth
- setZoom / setZoomFit / setZoomOneToOne — zoom math
- renderCompare() — single render path for all picker / zoom changes
- Keyboard shortcuts: +, -, 0, 1
- Window resize handler so zoom ratio stays correct on resize
Tests: still 29/29. No proxy or test changes.
The point of side-by-side is that a coin in the source and a coin in the encode appear the same size on screen — regardless of native resolution. Pixel count differences are visible in the metadata, not in the rendered size. Removed the '1:1' button and its zoom-to-natural-pixels logic, which was contradicting that goal by stretching the smaller image up to its native pixel count for 'fair' rendering. The same-display-size behavior was already correct via 'width: 100%' on each img in its 50% cell — both panels always render at the same display width. Zoom in/out multiplies both proportionally; pan via shared scroll position. Also dropped 'image-rendering: pixelated' — for comparing how each encode looks at the same display size, normal browser smoothing IS the real-world rendering condition we want to evaluate. Controls left: − / readout / + / Reset. Keys: −, +, 0, Esc.
5de4912 to
02bf8b2
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 4 potential issues.
Bugbot Autofix resolved 2 of the 4 issues found in the latest run.
- ✅ Fixed: Explanation incorrectly claims "Both panels" show source
- Guarded the 'Both panels show the unmodified source' message on both left.kind and right.kind being 'baseline', and added a dedicated right-only baseline case that asks the user to compare against the left panel's encode.
- ✅ Fixed: Pseudo-element label consumes flex space beside image
- Changed
.compare-image-cell::beforefromposition: sticky(in-flow flex item that stole horizontal space from the img and produced different left/right widths) toposition: absolute, which overlays the label without affecting flex layout since the cell is alreadyposition: relative.
- Changed
Preview (b645fc39a7)
diff --git a/src/demo-page.ts b/src/demo-page.ts
--- a/src/demo-page.ts
+++ b/src/demo-page.ts
@@ -180,13 +180,16 @@
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
display: flex; flex-direction: column;
}
+ .compare-modal {
+ width: min(1600px, calc(100vw - 48px));
+ }
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
gap: 16px;
}
- .modal-title { font-size: 15px; font-weight: 600; }
+ .modal-title { font-size: 15px; font-weight: 600; flex: 1; }
.modal-title .target { color: var(--accent); }
.modal-title .display-info { color: var(--text-dim); font-weight: 400; font-size: 12px; margin-left: 8px; }
.modal-close {
@@ -197,25 +200,90 @@
flex-shrink: 0;
}
.modal-close:hover { background: var(--border); }
- .modal-image-wrap {
- background: #000; display: flex; align-items: center; justify-content: center;
- padding: 0; max-height: calc(100vh - 240px);
- overflow: auto;
+
+ /* Zoom controls */
+ .zoom-controls {
+ display: flex; align-items: center; gap: 6px;
+ flex-shrink: 0;
}
- .modal-image-wrap img {
- display: block; height: auto;
- /* width is set inline by JS to match target (or viewport cap) */
+ .zoom-btn {
+ background: var(--panel-2); border: 1px solid var(--border);
+ color: var(--text); border-radius: 6px; padding: 6px 10px;
+ cursor: pointer; font-size: 13px; font-weight: 500;
+ min-width: 32px; line-height: 1.2;
}
- .modal-meta {
- padding: 14px 18px;
- font-family: var(--mono); font-size: 12px;
+ .zoom-btn:hover { background: var(--border); }
+ .zoom-readout {
+ color: var(--text-dim); font-family: var(--mono); font-size: 12px;
+ min-width: 52px; text-align: center; user-select: none;
+ }
+ .zoom-fit { font-size: 12px; padding: 6px 8px; }
+
+ /* Compare panel headers */
+ .compare-panels {
+ display: grid; grid-template-columns: 1fr 1fr;
+ border-bottom: 1px solid var(--border);
+ }
+ .compare-panel {
+ padding: 12px 14px;
+ border-right: 1px solid var(--border);
+ }
+ .compare-panel:last-child { border-right: 0; }
+ .compare-panel-header {
+ display: flex; flex-direction: column; gap: 6px;
+ }
+ .compare-picker-label {
+ font-size: 10px; color: var(--text-dim);
+ text-transform: uppercase; letter-spacing: 0.08em;
+ }
+ .compare-picker {
+ background: var(--panel-2); color: var(--text);
+ border: 1px solid var(--border); border-radius: 6px;
+ padding: 6px 8px; font-size: 12px; font-family: var(--mono);
+ width: 100%;
+ }
+ .compare-panel-meta {
+ font-family: var(--mono); font-size: 10px;
color: var(--text-dim);
- display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
- gap: 8px 24px;
- border-top: 1px solid var(--border);
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
+ gap: 3px 12px;
+ margin-top: 4px;
}
- .modal-meta .row { display: flex; justify-content: space-between; gap: 8px; }
- .modal-meta .row strong { color: var(--text); font-weight: 500; }
+ .compare-panel-meta .row { display: flex; justify-content: space-between; gap: 6px; }
+ .compare-panel-meta strong { color: var(--text); font-weight: 500; }
+
+ /* The shared scrolling viewport: one container, two image cells side by side.
+ Pan/zoom is "free" because they share the scroll position. */
+ .compare-viewport {
+ background: #000;
+ overflow: auto;
+ max-height: calc(100vh - 380px);
+ min-height: 320px;
+ position: relative;
+ }
+ .compare-canvas {
+ display: grid; grid-template-columns: 1fr 1fr;
+ /* width is set inline by JS based on zoom factor; both cells inherit
+ the same column width so images stay aligned spatially. */
+ }
+ .compare-image-cell {
+ display: flex; align-items: flex-start; justify-content: center;
+ background: #000;
+ position: relative;
+ overflow: hidden;
+ }
+ .compare-image-cell::before {
+ content: attr(data-side);
+ position: absolute; top: 6px; left: 6px;
+ background: rgba(0,0,0,0.65); color: var(--text);
+ font-size: 10px; font-family: var(--mono);
+ padding: 2px 6px; border-radius: 3px;
+ text-transform: uppercase; letter-spacing: 0.08em;
+ z-index: 2;
+ }
+ .compare-image-cell img {
+ display: block; width: 100%; height: auto;
+ }
.modal-explanation {
padding: 14px 18px;
font-size: 12px;
@@ -313,16 +381,46 @@
<div id="grid" class="grid"></div>
<div id="modal-backdrop" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-title">
- <div class="modal" id="modal">
+ <div class="modal compare-modal" id="modal">
<div class="modal-header">
- <div class="modal-title" id="modal-title">
- <span class="target">target ?px</span>
- <span class="display-info"></span>
+ <div class="modal-title" id="modal-title">Compare</div>
+ <div class="zoom-controls" id="zoom-controls">
+ <button class="zoom-btn" id="zoom-out" aria-label="Zoom out" title="Zoom out (−)">−</button>
+ <span class="zoom-readout" id="zoom-readout">100%</span>
+ <button class="zoom-btn" id="zoom-in" aria-label="Zoom in" title="Zoom in (+)">+</button>
+ <button class="zoom-btn zoom-fit" id="zoom-fit" title="Reset zoom (0)">Reset</button>
</div>
<button class="modal-close" id="modal-close" aria-label="Close">×</button>
</div>
- <div class="modal-image-wrap" id="modal-image-wrap"></div>
- <div class="modal-meta" id="modal-meta"></div>
+
+ <div class="compare-panels">
+ <div class="compare-panel" data-side="left">
+ <div class="compare-panel-header">
+ <label class="compare-picker-label">A — left</label>
+ <select class="compare-picker" id="compare-picker-left"></select>
+ <div class="compare-panel-meta" id="compare-meta-left"></div>
+ </div>
+ </div>
+ <div class="compare-panel" data-side="right">
+ <div class="compare-panel-header">
+ <label class="compare-picker-label">B — right</label>
+ <select class="compare-picker" id="compare-picker-right"></select>
+ <div class="compare-panel-meta" id="compare-meta-right"></div>
+ </div>
+ </div>
+ </div>
+
+ <div class="compare-viewport" id="compare-viewport">
+ <div class="compare-canvas" id="compare-canvas">
+ <div class="compare-image-cell" data-side="left">
+ <img id="compare-img-left" alt="left comparison">
+ </div>
+ <div class="compare-image-cell" data-side="right">
+ <img id="compare-img-right" alt="right comparison">
+ </div>
+ </div>
+ </div>
+
<div class="modal-explanation" id="modal-explanation"></div>
</div>
</div>
@@ -590,174 +688,281 @@
}[c]));
}
-// Modal
+// Compare modal
const modalBackdrop = document.getElementById('modal-backdrop');
const modalTitle = document.getElementById('modal-title');
-const modalImageWrap = document.getElementById('modal-image-wrap');
-const modalMeta = document.getElementById('modal-meta');
const modalExplanation = document.getElementById('modal-explanation');
const modalCloseBtn = document.getElementById('modal-close');
+const comparePickerLeft = document.getElementById('compare-picker-left');
+const comparePickerRight = document.getElementById('compare-picker-right');
+const compareMetaLeft = document.getElementById('compare-meta-left');
+const compareMetaRight = document.getElementById('compare-meta-right');
+const compareImgLeft = document.getElementById('compare-img-left');
+const compareImgRight = document.getElementById('compare-img-right');
+const compareViewport = document.getElementById('compare-viewport');
+const compareCanvas = document.getElementById('compare-canvas');
+const zoomReadout = document.getElementById('zoom-readout');
+const zoomInBtn = document.getElementById('zoom-in');
+const zoomOutBtn = document.getElementById('zoom-out');
+const zoomFitBtn = document.getElementById('zoom-fit');
-function openModalForTile(tile) {
- if (tile.classList.contains('loading') || tile.classList.contains('error')) return;
- const d = tile.dataset;
- const target = parseInt(d.target, 10);
- const encodeW = parseInt(d.encodeW || '0', 10);
- const encodeH = parseInt(d.encodeH || '0', 10);
- const sourceW = d.sourceW;
- const sourceH = d.sourceH;
- const binding = d.binding;
- const quality = d.quality;
- const format = d.format;
- const cache = d.cache;
- const size = parseInt(d.size || '0', 10);
- const path = d.path;
+// Comparison entries built from baseline + all loaded tiles. The id is a
+// short stable string used in the picker dropdowns.
+let compareEntries = [];
+const compareState = {
+ leftId: null,
+ rightId: null,
+ zoom: 1.0,
+};
+const ZOOM_MIN = 0.25;
+const ZOOM_MAX = 16;
- // Cap display width to viewport (with a margin for modal padding/scrollbars)
- const viewportCap = window.innerWidth - 80;
- const displayWidth = Math.min(target, viewportCap);
- const isClamped = displayWidth < target;
+function gatherCompareEntries() {
+ const entries = [];
- modalTitle.innerHTML =
- '<span class="target">target ' + target + 'px</span>' +
- '<span class="display-info">' +
- (isClamped
- ? 'displayed at ' + displayWidth + 'px (your viewport is narrower than target)'
- : 'displayed at ' + target + 'px (1:1 with target)') +
- '</span>';
+ // Baseline (passthrough source) — always entry #1 if loaded
+ const baselineTile = document.querySelector('.baseline-tile');
+ if (baselineTile && !baselineTile.classList.contains('loading') && !baselineTile.classList.contains('error')) {
+ const d = baselineTile.dataset;
+ entries.push({
+ id: 'baseline',
+ kind: 'baseline',
+ label: 'Source baseline — ' + (d.sourceW || '?') + '×' + (d.sourceH || '?') + ' ' + (d.format || ''),
+ path: d.path,
+ sourceW: parseInt(d.sourceW || '0', 10),
+ sourceH: parseInt(d.sourceH || '0', 10),
+ naturalW: parseInt(d.sourceW || '0', 10),
+ naturalH: parseInt(d.sourceH || '0', 10),
+ target: null,
+ encodeW: null,
+ encodeH: null,
+ binding: 'passthrough',
+ quality: 'n/a',
+ format: d.format || '?',
+ size: parseInt(d.size || '0', 10),
+ cache: d.cache || '—',
+ });
+ }
- // Render the image at target width — the browser does the final downscale
- // from the encode dimensions (encodeW × encodeH) to this display size.
- // That downscale IS the artifact-filter canon describes.
- const img = document.createElement('img');
- img.src = path;
- img.alt = 'target ' + target + ' preview';
- img.style.width = displayWidth + 'px';
- modalImageWrap.innerHTML = '';
- modalImageWrap.appendChild(img);
+ // Every loaded transcoded tile
+ document.querySelectorAll('.tile').forEach(tile => {
+ if (tile.classList.contains('loading') || tile.classList.contains('error')) return;
+ const d = tile.dataset;
+ const target = parseInt(d.target, 10);
+ const encodeW = parseInt(d.encodeW || '0', 10);
+ const encodeH = parseInt(d.encodeH || '0', 10);
+ entries.push({
+ id: 'tile-' + target,
+ kind: 'tile',
+ label: 'target ' + target + 'px — encode ' + encodeW + '×' + encodeH + ' q=' + d.quality + ' ' + d.format,
+ path: d.path,
+ sourceW: parseInt(d.sourceW || '0', 10),
+ sourceH: parseInt(d.sourceH || '0', 10),
+ naturalW: encodeW,
+ naturalH: encodeH,
+ target: target,
+ encodeW: encodeW,
+ encodeH: encodeH,
+ binding: d.binding,
+ quality: d.quality,
+ format: d.format,
+ size: parseInt(d.size || '0', 10),
+ cache: d.cache || '—',
+ });
+ });
- modalMeta.innerHTML =
- '<div class="row"><span>source</span><strong>' + (sourceW || '?') + ' × ' + (sourceH || '?') + '</strong></div>' +
- '<div class="row"><span>encode</span><strong>' + (encodeW || '?') + ' × ' + (encodeH || '?') + '</strong></div>' +
- '<div class="row"><span>display</span><strong>' + displayWidth + 'px wide</strong></div>' +
- '<div class="row"><span>binds</span><strong>' + (binding || '—') + '</strong></div>' +
- '<div class="row"><span>quality</span><strong>q=' + (quality || '?') + '</strong></div>' +
- '<div class="row"><span>format</span><strong>' + (format || '?') + '</strong></div>' +
- '<div class="row"><span>size</span><strong>' + formatBytes(size) + '</strong></div>' +
- '<div class="row"><span>cache</span><strong>' + (cache || '—') + '</strong></div>';
+ return entries;
+}
- // Explanation tailored to which term bound
+function populateCompareDropdowns() {
+ const options = compareEntries.map(e =>
+ '<option value="' + e.id + '">' + escapeHtml(e.label) + '</option>'
+ ).join('');
+ comparePickerLeft.innerHTML = options;
+ comparePickerRight.innerHTML = options;
+ comparePickerLeft.value = compareState.leftId;
+ comparePickerRight.value = compareState.rightId;
+}
+
+function findEntry(id) {
+ return compareEntries.find(e => e.id === id);
+}
+
+function formatMetaBlock(entry) {
+ if (entry.kind === 'baseline') {
+ return '<div class="row"><span>dim</span><strong>' + entry.naturalW + '×' + entry.naturalH + '</strong></div>' +
+ '<div class="row"><span>format</span><strong>' + entry.format + '</strong></div>' +
+ '<div class="row"><span>size</span><strong>' + formatBytes(entry.size) + '</strong></div>' +
+ '<div class="row"><span>delivery</span><strong>passthrough</strong></div>';
+ }
+ return '<div class="row"><span>target</span><strong>' + entry.target + 'px</strong></div>' +
+ '<div class="row"><span>encode</span><strong>' + entry.encodeW + '×' + entry.encodeH + '</strong></div>' +
+ '<div class="row"><span>binds</span><strong>' + entry.binding + '</strong></div>' +
+ '<div class="row"><span>q</span><strong>' + entry.quality + '</strong></div>' +
+ '<div class="row"><span>format</span><strong>' + entry.format + '</strong></div>' +
+ '<div class="row"><span>size</span><strong>' + formatBytes(entry.size) + '</strong></div>';
+}
+
+function getViewportInnerWidth() {
+ // The width available for the canvas = viewport client width (excludes scrollbar)
+ return compareViewport.clientWidth;
+}
+
+function applyZoom() {
+ const vpW = getViewportInnerWidth();
+ if (vpW <= 0) return;
+ // Canvas width = viewport width × zoom. Each cell gets half.
+ const canvasW = Math.max(1, Math.round(vpW * compareState.zoom));
+ compareCanvas.style.width = canvasW + 'px';
+ zoomReadout.textContent = Math.round(compareState.zoom * 100) + '%';
+}
+
+function setZoom(z) {
+ compareState.zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z));
+ applyZoom();
+}
+
+function setZoomFit() {
+ setZoom(1.0);
+}
+
+function renderCompare() {
+ const left = findEntry(compareState.leftId);
+ const right = findEntry(compareState.rightId);
+ if (!left || !right) return;
+
+ compareImgLeft.src = left.path;
+ compareImgLeft.alt = left.label;
+ compareImgRight.src = right.path;
+ compareImgRight.alt = right.label;
+
+ compareMetaLeft.innerHTML = formatMetaBlock(left);
+ compareMetaRight.innerHTML = formatMetaBlock(right);
+
+ comparePickerLeft.value = left.id;
+ comparePickerRight.value = right.id;
+
+ // Title summarizes what we're comparing
+ modalTitle.innerHTML =
+ '<span class="target">Compare:</span> ' +
+ '<span style="color: var(--accent-2);">' + escapeHtml(left.label.split(' — ')[0]) + '</span> ' +
+ 'vs ' +
+ '<span style="color: var(--accent);">' + escapeHtml(right.label.split(' — ')[0]) + '</span>';
+
+ // Explanation pulled from the right-hand entry's binding (the "strategy")
let explain = '';
- if (binding === 'target') {
+ if (left.kind === 'baseline' && right.kind === 'baseline') {
+ explain = 'Both panels show the unmodified source. Pan and zoom to inspect the original.';
+ } else if (right.kind === 'baseline') {
+ explain = 'Right panel shows the unmodified source. Compare against the left panel'+'\u2019'+'s encode.';
+ } else if (right.binding === 'target') {
explain =
- 'The proxy encoded this image at <code>' + encodeW + '×' + encodeH + '</code> ' +
- '(target × 1.5, mod-16 aligned). Your browser is downscaling that to ' + displayWidth + 'px ' +
- 'for display — that downscale is the artifact filter, the same mechanism canon describes for ' +
- '"control the character of the loss."';
- } else if (binding === 'source') {
- const scaleVerb = encodeW < displayWidth ? 'upscaling' : encodeW > displayWidth ? 'downscaling' : 'rendering 1:1';
- const scaleClause = encodeW === displayWidth
- ? 'and your browser is rendering 1:1 at ' + displayWidth + 'px'
- : 'and your browser is ' + scaleVerb + ' from ' + encodeW + 'px to ' + displayWidth + 'px for display';
+ 'Right panel: proxy encoded at <code>' + right.encodeW + '×' + right.encodeH + '</code> ' +
+ '(target × 1.5, mod-16). At equal display size, the browser downscales the encode to the panel — ' +
+ 'that downscale is the artifact filter. Zoom in to compare pixel-level differences against the left panel.';
+ } else if (right.binding === 'source') {
explain =
- 'Source is small enough that <code>source × 1.5 = ' + encodeW + 'px</code> binds instead of ' +
- 'target × 1.5. The proxy encoded at the modest overshoot, ' + scaleClause + '. ' +
- 'Without the <code>source × 1.5</code> cap, this would have manufactured pixels from no signal.';
- } else if (binding === 'equal') {
+ 'Right panel: source × 1.5 bound the encode at <code>' + right.encodeW + 'px</code>. ' +
+ 'Zoom in to see whether the modest overshoot preserved detail you can recognize against the baseline.';
+ } else if (right.binding === 'equal') {
explain =
- 'Source dimensions already match the target. No scaling at the encoder; the only work is ' +
- 'format conversion and quality adjustment.';
+ 'Right panel: source dimensions already matched target — no scaling at encoder. The only loss is quality/format.';
}
+ // If comparing two non-baseline encodes, add a strategy hint
+ if (left.kind === 'tile' && right.kind === 'tile') {
+ const sizeRatio = right.size > 0 ? (left.size / right.size) : 0;
+ if (sizeRatio > 0) {
+ explain += ' Byte budget: left=' + formatBytes(left.size) + ', right=' + formatBytes(right.size) +
+ ' (left is ' + sizeRatio.toFixed(2) + '× right). ' +
+ 'When file sizes are comparable, look for which encode'+'\u2019'+'s artifacts are easier on the eye.';
+ }
+ }
modalExplanation.innerHTML = explain;
+ applyZoom();
+}
+
+function openCompareModal(initialRightId = null) {
+ compareEntries = gatherCompareEntries();
+ if (compareEntries.length < 1) return;
+
+ // Default left: baseline if available, otherwise the first entry
+ const defaultLeft = compareEntries.find(e => e.kind === 'baseline') || compareEntries[0];
+ // Default right: caller's chosen tile, or fall back to the first non-baseline tile
+ let defaultRight = initialRightId ? findEntryInArray(compareEntries, initialRightId) : null;
+ if (!defaultRight) defaultRight = compareEntries.find(e => e.kind === 'tile') || defaultLeft;
+
+ compareState.leftId = defaultLeft.id;
+ compareState.rightId = defaultRight.id;
+
+ populateCompareDropdowns();
modalBackdrop.classList.add('open');
- // Focus close button for keyboard accessibility
+
+ // Reset zoom and render
+ compareState.zoom = 1.0;
+ renderCompare();
modalCloseBtn.focus();
}
+function findEntryInArray(arr, id) {
+ return arr.find(e => e.id === id);
+}
+
function closeModal() {
modalBackdrop.classList.remove('open');
- // Free the image so it doesn't stay in memory
- modalImageWrap.innerHTML = '';
+ // Free image bandwidth/memory on close
+ compareImgLeft.removeAttribute('src');
+ compareImgRight.removeAttribute('src');
}
-function openModalForBaseline(tile) {
- if (tile.classList.contains('loading') || tile.classList.contains('error')) return;
- const d = tile.dataset;
- const sourceW = parseInt(d.sourceW || '0', 10);
- const sourceH = parseInt(d.sourceH || '0', 10);
- const format = d.format;
- const cache = d.cache;
- const size = parseInt(d.size || '0', 10);
- const path = d.path;
+// Picker change handlers
+comparePickerLeft.addEventListener('change', () => {
+ compareState.leftId = comparePickerLeft.value;
+ renderCompare();
+});
+comparePickerRight.addEventListener('change', () => {
+ compareState.rightId = comparePickerRight.value;
+ renderCompare();
+});
- // For the baseline, show the source at its native width, capped to viewport
- const viewportCap = window.innerWidth - 80;
- const displayWidth = sourceW ? Math.min(sourceW, viewportCap) : viewportCap;
- const isClamped = sourceW && displayWidth < sourceW;
+// Zoom controls
+zoomInBtn.addEventListener('click', () => setZoom(compareState.zoom * 1.5));
+zoomOutBtn.addEventListener('click', () => setZoom(compareState.zoom / 1.5));
+zoomFitBtn.addEventListener('click', setZoomFit);
- modalTitle.innerHTML =
- '<span class="target" style="color: var(--accent-2);">original source</span>' +
- '<span class="display-info">' +
- (sourceW
- ? (isClamped
- ? 'displayed at ' + displayWidth + 'px (your viewport is narrower than source ' + sourceW + 'px)'
- : 'displayed at ' + sourceW + 'px (1:1 with source)')
- : '') +
- '</span>';
+// Recalculate canvas width if the window resizes (keeps the zoom ratio)
+window.addEventListener('resize', () => {
+ if (modalBackdrop.classList.contains('open')) applyZoom();
+});
- const img = document.createElement('img');
- img.src = path;
- img.alt = 'original source preview';
- img.style.width = displayWidth + 'px';
- modalImageWrap.innerHTML = '';
- modalImageWrap.appendChild(img);
-
- modalMeta.innerHTML =
- '<div class="row"><span>dimensions</span><strong>' + (sourceW || '?') + ' × ' + (sourceH || '?') + '</strong></div>' +
- '<div class="row"><span>format</span><strong>' + (format || '?') + '</strong></div>' +
- '<div class="row"><span>size</span><strong>' + formatBytes(size) + '</strong></div>' +
- '<div class="row"><span>delivery</span><strong>passthrough</strong></div>' +
- '<div class="row"><span>cache</span><strong>' + (cache || '—') + '</strong></div>';
-
- modalExplanation.innerHTML =
- 'This is the unmodified source served through the proxy. The worker is in ' +
- 'passthrough mode — no <code>w</code>, <code>q</code>, or <code>f</code> options applied, so the bytes ' +
- 'are streamed from the origin unchanged. Use these dimensions and file size as ' +
- 'the comparison baseline for the transcoded tiles below.';
-
- modalBackdrop.classList.add('open');
- modalCloseBtn.focus();
-}
-
-// Tile click → open modal (regular tiles)
+// Tile click → open compare modal with that tile pre-selected on the right
grid.addEventListener('click', (e) => {
const tile = e.target.closest('.tile');
- if (tile) openModalForTile(tile);
+ if (!tile || tile.classList.contains('loading') || tile.classList.contains('error')) return;
+ const target = tile.dataset.target;
+ openCompareModal(target ? 'tile-' + target : null);
});
grid.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- const tile = e.target.closest('.tile');
- if (tile) {
- e.preventDefault();
- openModalForTile(tile);
- }
- }
+ if (e.key !== 'Enter' && e.key !== ' ') return;
+ const tile = e.target.closest('.tile');
+ if (!tile || tile.classList.contains('loading') || tile.classList.contains('error')) return;
+ e.preventDefault();
+ const target = tile.dataset.target;
+ openCompareModal(target ? 'tile-' + target : null);
});
-// Baseline tile click → open baseline modal
+// Baseline tile click → open compare modal with baseline on both sides initially
baselineWrap.addEventListener('click', (e) => {
const tile = e.target.closest('.baseline-tile');
- if (tile) openModalForBaseline(tile);
+ if (!tile || tile.classList.contains('loading') || tile.classList.contains('error')) return;
+ openCompareModal('baseline');
});
baselineWrap.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- const tile = e.target.closest('.baseline-tile');
- if (tile) {
- e.preventDefault();
- openModalForBaseline(tile);
- }
- }
+ if (e.key !== 'Enter' && e.key !== ' ') return;
+ const tile = e.target.closest('.baseline-tile');
+ if (!tile || tile.classList.contains('loading') || tile.classList.contains('error')) return;
+ e.preventDefault();
+ openCompareModal('baseline');
});
// Close handlers
@@ -767,6 +972,11 @@
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modalBackdrop.classList.contains('open')) closeModal();
+ if (!modalBackdrop.classList.contains('open')) return;
+ // Zoom hotkeys
+ if (e.key === '+' || e.key === '=') { e.preventDefault(); setZoom(compareState.zoom * 1.5); }
+ else if (e.key === '-' || e.key === '_') { e.preventDefault(); setZoom(compareState.zoom / 1.5); }
+ else if (e.key === '0') { e.preventDefault(); setZoomFit(); }
});
// URL state — read on load, write on change. The page URL itself becomes theYou can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 02bf8b2. Configure here.
| z-index: 2; align-self: flex-start; | ||
| } | ||
| .compare-image-cell img { | ||
| display: block; width: 100%; height: auto; |
There was a problem hiding this comment.
Missing image-rendering: pixelated on compare images
Medium Severity
The PR description explicitly states image-rendering: pixelated keeps pixels sharp at zoom, but this CSS property is absent from .compare-image-cell img. Without it, zooming in shows bilinear-smoothed blurry pixels instead of sharp individual pixels, fundamentally undermining the "pixel-peeping" compare mode that is the stated purpose of this feature.
Reviewed by Cursor Bugbot for commit 02bf8b2. Configure here.
There was a problem hiding this comment.
Bugbot Autofix determined this is a false positive.
The preceding commit 02bf8b2 explicitly removed image-rendering: pixelated as a deliberate design choice (per its message: comparing how each encode looks at the same display size means normal browser smoothing IS the real-world rendering condition to evaluate), so its absence is intentional, not a bug.
You can send follow-ups to the cloud agent here.
|
|
||
| // Reset zoom and render | ||
| compareState.zoom = 1.0; | ||
| renderCompare(); |
There was a problem hiding this comment.
Default zoom is "fit" not "1:1 native"
Medium Severity
The PR states the modal opens at "1:1 native zoom" where "the larger image's natural pixels equal display pixels," but the code always sets compareState.zoom = 1.0 on open, which is viewport-fit mode. The naturalW/naturalH fields are gathered per entry but never used for zoom calculation. Additionally, the described 1:1 button and 1 keyboard shortcut are completely absent from both the HTML and the keydown handler.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 02bf8b2. Configure here.
There was a problem hiding this comment.
Bugbot Autofix determined this is a false positive.
The preceding commit 02bf8b2 ('simplify compare modal: same display size, drop "1:1 native pixels"') deliberately removed the 1:1 button, the 1 shortcut, and the native-pixel zoom calculation; the same-display-size behavior the current code implements is the intended design.
You can send follow-ups to the cloud agent here.
- .compare-image-cell::before used position: sticky inside a flex row, making the label a flex item that consumed horizontal space beside the img and produced slightly different widths for left vs right labels. Switch to position: absolute (cell is already position: relative) so the label overlays the image without affecting layout. - The 'Both panels show the unmodified source' message only checked right.kind === 'baseline', which was incorrect when the user swapped the left picker to a tile while right was baseline. Guard it on both sides being baseline; add a right-only baseline case.
26d18d1 to
b645fc3
Compare
Bugbot caught the per-cell label CSS bug (b645fc3 autofix). These are the other two real bugs that kept the modal from rendering anything: 1. Canvas width never set when modal opens. applyZoom() read compareViewport.clientWidth, which returns 0 in the same JS task that flipped display:none → display:flex on the backdrop. Layout hadn't run yet, the 'if (vpW <= 0) return' bail-out fired, and the canvas stayed widthless, collapsing both panels to zero width so width:100% on each img resolved to nothing. Fix: use CSS percentage. Canvas defaults to width:100% in CSS, JS sets it to (zoom × 100)%. No measurement, no timing bug. Percentage width also handles window resize for free, so the resize listener came out too. 2. Baseline tile unavailable in picker until image decoded. loadBaseline() kept the 'loading' class on the baseline tile until img.onload fired; gatherCompareEntries() skipped tiles with .loading. Click a tile before the source decoded (very common — the image is large) and the baseline wasn't in the picker, so the default-left fell through to another tile entry instead of source baseline. Fix: drop 'loading' as soon as fetchHead returns. The path is what makes the tile usable in compare-mode; dimensions are nice-to-have metadata displayed on the tile itself, not a gate. Also fills in the baseline tile's heading and meta immediately when the fetch returns, so the UI doesn't stick on 'Loading source…' after the data is already available. Removed getViewportInnerWidth (unused) and the window-resize handler (redundant with percentage width). Tests still 29/29.


What This Builds
Replaces the single-image modal with a two-panel compare view designed for the strategy-comparison use case: looking at two encodes side-by-side at native pixel resolution to evaluate whether one strategy's artifacts are easier on the eye than another at the same byte budget.
How It Works
Click any tile (or the baseline) and the modal opens with:
This makes the "compare different strategies for the same byte budget" case trivial: pick
low-q at large targeton one side,high-q at small targeton the other, pan and zoom to compare what each strategy did at the pixel level. The explanation strip computes the byte-budget ratio between the two panels so you can see when you're comparing similar file sizes — exactly the canon-relevant case.Pan/Zoom — Locked By Construction
The two panels are inside a single scrollable viewport that holds both image cells in a 2-column grid. Scroll position is shared because there's one container, not two — no JS sync, no rubber-banding edge cases. Zoom multiplies the canvas width (each cell gets half), so spatial alignment stays correct at any zoom level.
Defaults
Matching the calibration answers:
Controls
−100%+Fit1:1×+zoom in,−zoom out,0fit,1native pixels,Esccloseimage-rendering: pixelatedkeeps pixels sharp at zoom (no bilinear smoothing)What Was Removed
The single-image
openModalForTileandopenModalForBaselinefunctions are gone — both flows now route throughopenCompareModal(initialRightId)with the baseline as default left. This is a deliberate consolidation: the old single-image modal is just "compare mode with both sides showing the same thing," so collapsing them into one path was simpler than keeping two.Branch Base
This branches off
feat/shareable-url-source(PR #8). GitHub will auto-update the base as PR #7 and #8 merge.Tests
Still 29/29 passing. No proxy changes, no test changes — this is purely frontend.
Known Edge Cases
/image/...URL twice doesn't double the proxy work. No special-case needed.+button to see individual pixels at large display size —image-rendering: pixelatedkeeps them sharp.applyZoom()runs on resize so the canvas width follows the new viewport width while preserving the zoom ratio.Note
Low Risk
Frontend-only changes in the demo HTML/JS; no proxy, API, or test surface touched.
Overview
The demo page replaces the single-image preview modal with a two-panel compare view for side-by-side encode inspection.
Clicking a grid tile or the baseline opens
openCompareModal: left defaults to the passthrough source baseline, right to the clicked target (or baseline when opened from the baseline tile). Each side has a dropdown to swap among baseline and any loaded transcoded tile; per-panel metadata and a title/explanation strip describe binding strategy and, for encode-vs-encode, a byte-size ratio.Pan alignment uses one shared scrollable viewport and a two-column canvas; zoom scales canvas width (with header buttons and
+/-/0keys). The oldopenModalForTile/openModalForBaselinepaths are removed in favor of this single flow.The baseline tile is selectable for compare sooner (path and basic meta before natural dimensions finish loading).
Reviewed by Cursor Bugbot for commit 3ee2b41. Bugbot is set up for automated code reviews on this repo. Configure here.