Skip to content

feat(demo): pixel-peeping compare mode in modal#9

Merged
klappy merged 4 commits into
feat/shareable-url-sourcefrom
feat/compare-mode-modal
May 27, 2026
Merged

feat(demo): pixel-peeping compare mode in modal#9
klappy merged 4 commits into
feat/shareable-url-sourcefrom
feat/compare-mode-modal

Conversation

@klappy
Copy link
Copy Markdown
Owner

@klappy klappy commented May 27, 2026

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:

  • 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, 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:

  • 1:1 native zoom on open — zoom factor calculated so the larger image's natural pixels equal display pixels. You're pixel-peeping immediately.
  • Source baseline as the default left panel — every comparison has a "loss vs ground truth" reference unless you change it.
  • Pan/zoom locked together by design — no toggle, it's the architecture.

Controls

  • Buttons in modal header: 100% + Fit 1:1 ×
  • Keyboard: + zoom in, zoom out, 0 fit, 1 native pixels, Esc close
  • Picker dropdowns on each panel for arbitrary swaps
  • image-rendering: pixelated keeps pixels sharp at zoom (no bilinear smoothing)

What Was Removed

The single-image openModalForTile and openModalForBaseline functions are gone — both flows now route through openCompareModal(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

  • Both panels showing the same image: the browser caches the request, so loading the same /image/... URL twice doesn't double the proxy work. No special-case needed.
  • Both panels at extreme zoom on a tiny source (e.g. 16×16): at 1:1 the canvas is 32px wide. Modal has min-height to prevent it collapsing. Zoom in via the + button to see individual pixels at large display size — image-rendering: pixelated keeps them sharp.
  • Resizing the window with modal open: 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 + / - / 0 keys). The old openModalForTile / openModalForBaseline paths 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.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 27, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

@klappy klappy force-pushed the feat/shareable-url-source branch from 95431f6 to 14292d9 Compare May 27, 2026 22:07
claude added 2 commits May 27, 2026 22:07
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.
@klappy klappy force-pushed the feat/compare-mode-modal branch from 5de4912 to 02bf8b2 Compare May 27, 2026 22:07
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Fix All in Cursor

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::before from position: sticky (in-flow flex item that stole horizontal space from the img and produced different left/right widths) to position: absolute, which overlays the label without affecting flex layout since the cell is already position: relative.
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 the

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 02bf8b2. Configure here.

Comment thread src/demo-page.ts
Comment thread src/demo-page.ts
z-index: 2; align-self: flex-start;
}
.compare-image-cell img {
display: block; width: 100%; height: auto;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 02bf8b2. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/demo-page.ts

// Reset zoom and render
compareState.zoom = 1.0;
renderCompare();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 02bf8b2. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/demo-page.ts
- .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.
@cursor cursor Bot force-pushed the feat/compare-mode-modal branch from 26d18d1 to b645fc3 Compare May 27, 2026 22:18
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.
@klappy klappy merged commit efcea55 into feat/shareable-url-source May 27, 2026
2 checks passed
@klappy klappy deleted the feat/compare-mode-modal branch May 27, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants