Improve snapshot diff viewer
Sidebar navigation, three comparison modes (side-by-side, slider,
toggle), keyboard shortcuts, and improved lightbox. Mobile screenshots
constrained to natural width and centered.

Fix main-column filling viewport height on mobile — flex: none in the
responsive breakpoint so element screenshots capture content, not
empty space.

Assisted-by: Claude Opus 4.6 via Claude Code
change vkwxxnnyonozykpzmyvlsporolwksmqm
commit de8fe7603b2f8ec2bbd46136e10e91e3e19f369d
author Alpha Chen <alpha@kejadlen.dev>
date
parent vrxwulnk
diff --git a/lib/ketchup/snapshots.rb b/lib/ketchup/snapshots.rb
index e3ce3dd..5d247c9 100644
--- a/lib/ketchup/snapshots.rb
+++ b/lib/ketchup/snapshots.rb
@@ -278,6 +278,9 @@ module Ketchup
 
         file = @viewport_dir / "#{name}.png"
         if selector && element_visible?(selector)
+          # Ferrum's selector screenshot captures the element's exact bounding
+          # box, which sits flush against the edges. Temporary padding gives the
+          # screenshot some breathing room so it doesn't look cropped.
           @browser.execute("document.querySelector(#{selector.to_json}).style.padding = '1.5rem'")
           @browser.screenshot(path: file.to_s, selector: selector)
           @browser.execute("document.querySelector(#{selector.to_json}).style.padding = ''")
diff --git a/public/css/app.css b/public/css/app.css
index cfc88cc..12995aa 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -674,6 +674,7 @@ button[type="submit"]:not(.complete-btn):hover {
   }
 
   .main-column {
+    flex: none;
     align-self: stretch;
     padding-inline: var(--space-s);
   }
diff --git a/templates/snapshot_diff.erb b/templates/snapshot_diff.erb
index 20a70c8..73c0498 100644
--- a/templates/snapshot_diff.erb
+++ b/templates/snapshot_diff.erb
@@ -9,69 +9,368 @@
   <link rel="stylesheet" href="snapshots.css">
 </head>
 <body>
-  <h1>Ketchup Snapshot Diff</h1>
-  <div class="tab-bar">
-    <% snapshots_by_viewport.each_with_index do |(viewport, _), i| -%>
-    <button class="tab<%= " active" if i == 0 %>" data-viewport="<%= viewport %>"><%= viewport.capitalize %></button>
+  <nav class="sidebar" id="sidebar">
+    <div class="sidebar-header">Snapshots</div>
+    <% snapshots_by_viewport.each_with_index do |(viewport, snapshots), i| -%>
+    <ul class="sidebar-list<%= " hidden" unless i == 0 %>" data-viewport="<%= viewport %>">
+      <% snapshots.each do |snap| -%>
+      <li>
+        <a href="#<%= viewport %>-<%= snap.name %>" class="sidebar-link">
+          <%= snap.name.tr("-", " ") %>
+          <% unless snap.baseline %><span class="sidebar-badge new"><svg width="8" height="8" viewBox="0 0 8 8"><path d="M4 1v6M1 4h6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></span><% end -%>
+          <% unless snap.current %><span class="sidebar-badge removed"><svg width="8" height="8" viewBox="0 0 8 8"><path d="M1 4h6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></span><% end -%>
+        </a>
+      </li>
+      <% end -%>
+    </ul>
     <% end -%>
-  </div>
-  <% snapshots_by_viewport.each_with_index do |(viewport, snapshots), i| -%>
-  <div class="viewport-group<%= " hidden" unless i == 0 %>" data-viewport="<%= viewport %>">
-    <% snapshots.each do |snap| %>
-    <% entry = snap.current || snap.baseline -%>
-    <div class="snapshot">
-      <h2>
-        <%= snap.name %>
-        <% unless snap.baseline %>
-        <span class="label new">new</span>
-        <% end -%>
-        <% unless snap.current %>
-        <span class="label removed">removed</span>
-        <% end -%>
-      </h2>
-      <code class="path"><%= entry.path %><%= " #{entry.selector}" if entry.selector %></code>
-      <div class="pair">
-        <div class="side">
-          <h3>Baseline</h3>
-          <% if snap.baseline %>
-          <img src="baseline/<%= viewport %>/<%= snap.name %>.png" alt="baseline <%= snap.name %>">
+  </nav>
+
+  <main class="content">
+    <header class="toolbar">
+      <h1>Snapshot Diff</h1>
+      <div class="toolbar-controls">
+        <div class="tab-bar">
+          <% snapshots_by_viewport.each_with_index do |(viewport, _), i| -%>
+          <button class="tab<%= " active" if i == 0 %>" data-viewport="<%= viewport %>"><%= viewport.capitalize %></button>
+          <% end -%>
+        </div>
+        <div class="mode-bar">
+          <button class="mode active" data-mode="side-by-side" title="Side by side (1)">
+            <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="2" width="6" height="12" rx="1" stroke="currentColor" stroke-width="1.5"/><rect x="9" y="2" width="6" height="12" rx="1" stroke="currentColor" stroke-width="1.5"/></svg>
+            Side by Side
+          </button>
+          <button class="mode" data-mode="slider" title="Slider overlay (2)">
+            <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="2" width="14" height="12" rx="1" stroke="currentColor" stroke-width="1.5"/><line x1="8" y1="2" x2="8" y2="14" stroke="currentColor" stroke-width="1.5"/><circle cx="8" cy="8" r="2" fill="currentColor"/></svg>
+            Slider
+          </button>
+          <button class="mode" data-mode="toggle" title="Toggle (3)">
+            <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="2" width="14" height="12" rx="1" stroke="currentColor" stroke-width="1.5"/><path d="M5 8h6M8 5v6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
+            Toggle
+          </button>
+        </div>
+      </div>
+    </header>
+
+    <% snapshots_by_viewport.each_with_index do |(viewport, snapshots), i| -%>
+    <div class="viewport-group<%= " hidden" unless i == 0 %>" data-viewport="<%= viewport %>">
+      <% snapshots.each do |snap| %>
+      <% entry = snap.current || snap.baseline -%>
+      <div class="snapshot" id="<%= viewport %>-<%= snap.name %>" data-name="<%= snap.name %>" data-has-baseline="<%= !!snap.baseline %>" data-has-current="<%= !!snap.current %>">
+        <div class="snapshot-header">
+          <h2>
+            <%= snap.name.tr("-", " ") %>
+            <% unless snap.baseline %>
+            <span class="label new">new</span>
+            <% end -%>
+            <% unless snap.current %>
+            <span class="label removed">removed</span>
+            <% end -%>
+          </h2>
+          <code class="path"><%= entry.path %><%= " #{entry.selector}" if entry.selector %></code>
+        </div>
+
+        <%# Side-by-side mode %>
+        <div class="pair view-side-by-side">
+          <div class="side">
+            <h3>Baseline</h3>
+            <% if snap.baseline %>
+            <img src="baseline/<%= viewport %>/<%= snap.name %>.png" alt="baseline <%= snap.name %>" loading="lazy">
+            <% else %>
+            <div class="placeholder">No baseline</div>
+            <% end %>
+          </div>
+          <div class="side">
+            <h3>Current</h3>
+            <% if snap.current %>
+            <img src="current/<%= viewport %>/<%= snap.name %>.png" alt="current <%= snap.name %>" loading="lazy">
+            <% else %>
+            <div class="placeholder">Removed</div>
+            <% end %>
+          </div>
+        </div>
+
+        <%# Slider mode %>
+        <div class="view-slider hidden">
+          <% if snap.baseline && snap.current %>
+          <div class="slider-container">
+            <div class="slider-img slider-baseline">
+              <img src="baseline/<%= viewport %>/<%= snap.name %>.png" alt="baseline" loading="lazy">
+            </div>
+            <div class="slider-img slider-current">
+              <img src="current/<%= viewport %>/<%= snap.name %>.png" alt="current" loading="lazy">
+            </div>
+            <div class="slider-handle" aria-label="Drag to compare">
+              <div class="slider-line"></div>
+              <div class="slider-grip">
+                <svg width="12" height="24" viewBox="0 0 12 24" fill="none"><path d="M4 6v12M8 6v12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
+              </div>
+            </div>
+            <div class="slider-labels">
+              <span>Baseline</span>
+              <span>Current</span>
+            </div>
+          </div>
           <% else %>
-          <div class="placeholder">No baseline</div>
+          <div class="single-image">
+            <% img = snap.current || snap.baseline -%>
+            <img src="<%= snap.current ? "current" : "baseline" %>/<%= viewport %>/<%= snap.name %>.png" alt="<%= snap.name %>" loading="lazy">
+            <div class="single-label"><%= snap.current ? "New — no baseline" : "Removed — no current" %></div>
+          </div>
           <% end %>
         </div>
-        <div class="side">
-          <h3>Current</h3>
-          <% if snap.current %>
-          <img src="current/<%= viewport %>/<%= snap.name %>.png" alt="current <%= snap.name %>">
+
+        <%# Toggle mode %>
+        <div class="view-toggle hidden">
+          <% if snap.baseline && snap.current %>
+          <div class="toggle-container" data-showing="current">
+            <img class="toggle-baseline" src="baseline/<%= viewport %>/<%= snap.name %>.png" alt="baseline" loading="lazy">
+            <img class="toggle-current" src="current/<%= viewport %>/<%= snap.name %>.png" alt="current" loading="lazy">
+            <div class="toggle-label">Current</div>
+          </div>
           <% else %>
-          <div class="placeholder">Removed</div>
+          <div class="single-image">
+            <% img = snap.current || snap.baseline -%>
+            <img src="<%= snap.current ? "current" : "baseline" %>/<%= viewport %>/<%= snap.name %>.png" alt="<%= snap.name %>" loading="lazy">
+            <div class="single-label"><%= snap.current ? "New — no baseline" : "Removed — no current" %></div>
+          </div>
           <% end %>
         </div>
       </div>
+      <% end %>
+    </div>
+    <% end -%>
+  </main>
+
+  <div class="lightbox" id="lightbox">
+    <div class="lightbox-chrome">
+      <div class="lightbox-title"></div>
+      <button class="lightbox-close" aria-label="Close">&times;</button>
+    </div>
+    <div class="lightbox-body">
+      <img>
     </div>
-    <% end %>
   </div>
-  <% end -%>
-  <div class="lightbox" id="lightbox"><img></div>
+
+  <div class="shortcuts-hint" id="shortcuts-hint">
+    <kbd>j</kbd><kbd>k</kbd> navigate &nbsp;
+    <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> mode &nbsp;
+    <kbd>t</kbd> toggle &nbsp;
+    <kbd>?</kbd> help
+  </div>
+
   <script>
+    // ── State ──
+    var currentMode = "side-by-side";
+    var currentViewport = document.querySelector(".tab.active").dataset.viewport;
+
+    // ── Viewport tabs ──
     document.querySelector(".tab-bar").addEventListener("click", function(e) {
       if (!e.target.matches(".tab")) return;
-      var viewport = e.target.dataset.viewport;
-      document.querySelectorAll(".tab").forEach(function(t) { t.classList.toggle("active", t.dataset.viewport === viewport); });
-      document.querySelectorAll(".viewport-group").forEach(function(g) { g.classList.toggle("hidden", g.dataset.viewport !== viewport); });
+      currentViewport = e.target.dataset.viewport;
+      document.querySelectorAll(".tab").forEach(function(t) { t.classList.toggle("active", t.dataset.viewport === currentViewport); });
+      document.querySelectorAll(".viewport-group").forEach(function(g) { g.classList.toggle("hidden", g.dataset.viewport !== currentViewport); });
+      document.querySelectorAll(".sidebar-list").forEach(function(l) { l.classList.toggle("hidden", l.dataset.viewport !== currentViewport); });
+    });
+
+    // ── Comparison modes ──
+    function setMode(mode) {
+      currentMode = mode;
+      document.querySelectorAll(".mode").forEach(function(b) { b.classList.toggle("active", b.dataset.mode === mode); });
+
+      document.querySelectorAll(".snapshot").forEach(function(snap) {
+        snap.querySelector(".view-side-by-side").classList.toggle("hidden", mode !== "side-by-side");
+        snap.querySelector(".view-slider").classList.toggle("hidden", mode !== "slider");
+        snap.querySelector(".view-toggle").classList.toggle("hidden", mode !== "toggle");
+      });
+
+      // Initialize sliders when entering slider mode
+      if (mode === "slider") initSliders();
+    }
+
+    document.querySelector(".mode-bar").addEventListener("click", function(e) {
+      var btn = e.target.closest(".mode");
+      if (btn) setMode(btn.dataset.mode);
+    });
+
+    // ── Slider logic ──
+    function initSliders() {
+      document.querySelectorAll(".slider-container").forEach(function(container) {
+        if (container._initialized) return;
+        container._initialized = true;
+
+        var handle = container.querySelector(".slider-handle");
+        var currentLayer = container.querySelector(".slider-current");
+        var pct = 50;
+
+        function update(x) {
+          var rect = container.getBoundingClientRect();
+          pct = Math.max(0, Math.min(100, ((x - rect.left) / rect.width) * 100));
+          currentLayer.style.clipPath = "inset(0 0 0 " + pct + "%)";
+          handle.style.left = pct + "%";
+        }
+
+        update(0); // start at 0% — showing full baseline
+        // Then set to 50%
+        setTimeout(function() {
+          var rect = container.getBoundingClientRect();
+          update(rect.left + rect.width * 0.5);
+        }, 50);
+
+        var dragging = false;
+
+        handle.addEventListener("mousedown", function(e) { dragging = true; e.preventDefault(); });
+        container.addEventListener("mousedown", function(e) {
+          if (e.target === handle || handle.contains(e.target)) return;
+          dragging = true;
+          update(e.clientX);
+        });
+        document.addEventListener("mousemove", function(e) { if (dragging) update(e.clientX); });
+        document.addEventListener("mouseup", function() { dragging = false; });
+
+        // Touch support
+        handle.addEventListener("touchstart", function(e) { dragging = true; e.preventDefault(); }, { passive: false });
+        container.addEventListener("touchstart", function(e) {
+          if (e.target === handle || handle.contains(e.target)) return;
+          dragging = true;
+          update(e.touches[0].clientX);
+        });
+        document.addEventListener("touchmove", function(e) { if (dragging) update(e.touches[0].clientX); });
+        document.addEventListener("touchend", function() { dragging = false; });
+      });
+    }
+
+    // ── Toggle logic ──
+    document.addEventListener("click", function(e) {
+      var container = e.target.closest(".toggle-container");
+      if (!container) return;
+
+      var showing = container.dataset.showing;
+      var next = showing === "current" ? "baseline" : "current";
+      container.dataset.showing = next;
+      container.querySelector(".toggle-label").textContent = next.charAt(0).toUpperCase() + next.slice(1);
     });
 
+    // ── Lightbox ──
     var lightbox = document.getElementById("lightbox");
+
+    function openLightbox(src, title) {
+      lightbox.querySelector("img").src = src;
+      lightbox.querySelector(".lightbox-title").textContent = title || "";
+      lightbox.classList.add("open");
+    }
+
+    function closeLightbox() {
+      lightbox.classList.remove("open");
+    }
+
     document.addEventListener("click", function(e) {
-      if (e.target.matches(".snapshot img, .side img")) {
-        lightbox.querySelector("img").src = e.target.src;
-        lightbox.classList.add("open");
-      } else if (lightbox.classList.contains("open")) {
-        lightbox.classList.remove("open");
+      if (e.target.closest(".toggle-container")) return; // toggle clicks don't open lightbox
+
+      var img = e.target.closest(".snapshot img");
+      if (img) {
+        var snap = img.closest(".snapshot");
+        var name = snap ? snap.dataset.name : "";
+        var which = img.alt || "";
+        openLightbox(img.src, name + (which ? " — " + which : ""));
+        return;
+      }
+
+      if (lightbox.classList.contains("open") && !e.target.closest(".lightbox-body")) {
+        closeLightbox();
       }
     });
+
+    lightbox.querySelector(".lightbox-close").addEventListener("click", closeLightbox);
+
+    // ── Keyboard navigation ──
+    function visibleSnapshots() {
+      var group = document.querySelector('.viewport-group:not(.hidden)');
+      return group ? Array.from(group.querySelectorAll(".snapshot")) : [];
+    }
+
+    function currentIndex() {
+      var snaps = visibleSnapshots();
+      var scrollY = window.scrollY + 120;
+      for (var i = snaps.length - 1; i >= 0; i--) {
+        if (snaps[i].offsetTop <= scrollY) return i;
+      }
+      return 0;
+    }
+
+    function navigateTo(index) {
+      var snaps = visibleSnapshots();
+      if (index < 0) index = 0;
+      if (index >= snaps.length) index = snaps.length - 1;
+      snaps[index].scrollIntoView({ behavior: "smooth", block: "start" });
+      highlightSidebarLink(snaps[index].id);
+    }
+
+    function highlightSidebarLink(id) {
+      document.querySelectorAll(".sidebar-link").forEach(function(a) {
+        a.classList.toggle("active", a.getAttribute("href") === "#" + id);
+      });
+    }
+
+    var shortcutsHint = document.getElementById("shortcuts-hint");
+
     document.addEventListener("keydown", function(e) {
-      if (e.key === "Escape") lightbox.classList.remove("open");
+      if (lightbox.classList.contains("open")) {
+        if (e.key === "Escape") closeLightbox();
+        return;
+      }
+
+      switch (e.key) {
+        case "j": navigateTo(currentIndex() + 1); break;
+        case "k": navigateTo(currentIndex() - 1); break;
+        case "1": setMode("side-by-side"); break;
+        case "2": setMode("slider"); break;
+        case "3": setMode("toggle"); break;
+        case "t":
+          // Toggle visible toggle-containers
+          document.querySelectorAll('.viewport-group:not(.hidden) .toggle-container').forEach(function(c) {
+            var next = c.dataset.showing === "current" ? "baseline" : "current";
+            c.dataset.showing = next;
+            c.querySelector(".toggle-label").textContent = next.charAt(0).toUpperCase() + next.slice(1);
+          });
+          break;
+        case "?":
+          shortcutsHint.classList.toggle("expanded");
+          break;
+        case "Escape":
+          shortcutsHint.classList.remove("expanded");
+          break;
+      }
+    });
+
+    // ── Sidebar scroll tracking ──
+    var ticking = false;
+    window.addEventListener("scroll", function() {
+      if (!ticking) {
+        requestAnimationFrame(function() {
+          var snaps = visibleSnapshots();
+          var scrollY = window.scrollY + 120;
+          var activeId = null;
+          for (var i = snaps.length - 1; i >= 0; i--) {
+            if (snaps[i].offsetTop <= scrollY) { activeId = snaps[i].id; break; }
+          }
+          if (activeId) highlightSidebarLink(activeId);
+          ticking = false;
+        });
+        ticking = true;
+      }
+    });
+
+    // Sidebar link clicks
+    document.getElementById("sidebar").addEventListener("click", function(e) {
+      var link = e.target.closest(".sidebar-link");
+      if (!link) return;
+      e.preventDefault();
+      var target = document.getElementById(link.getAttribute("href").slice(1));
+      if (target) {
+        target.scrollIntoView({ behavior: "smooth", block: "start" });
+        highlightSidebarLink(target.id);
+      }
     });
   </script>
 </body>
diff --git a/templates/snapshots.css b/templates/snapshots.css
index ab32ae4..2a7f914 100644
--- a/templates/snapshots.css
+++ b/templates/snapshots.css
@@ -1,3 +1,5 @@
+/* ── Layout ── */
+
 body {
   background: #1a1a2e;
   color: #e0e0e0;
@@ -6,38 +8,527 @@ body {
   padding: var(--space-l);
 }
 
-h1 { margin-bottom: var(--space-l); color: #fff; }
+/* Diff view resets body padding — layout handled by sidebar + content */
+body:has(.sidebar) {
+  padding: 0;
+}
+
+/* ── Sidebar ── */
+
+.sidebar {
+  position: fixed;
+  top: 0;
+  left: 0;
+  bottom: 0;
+  width: 14rem;
+  background: #151525;
+  border-right: 1px solid #2a2a44;
+  overflow-y: auto;
+  z-index: 100;
+  padding: var(--space-s) 0;
+}
+
+.sidebar-header {
+  padding: var(--space-xs) var(--space-s);
+  font-size: var(--step--1);
+  font-weight: 600;
+  color: #666;
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+}
+
+.sidebar-list {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
 
-.snapshot { margin-bottom: var(--space-xl); }
-.snapshot h2 { margin-bottom: 0; color: #ccc; }
-.snapshot .path { display: block; margin-bottom: var(--space-s); font-size: var(--step--1); color: #888; }
-.snapshot img { max-width: 100%; border: 1px solid #333; border-radius: 4px; display: block; margin-inline: auto; }
+.sidebar-list.hidden { display: none; }
+
+.sidebar-link {
+  display: flex;
+  align-items: center;
+  gap: var(--space-3xs);
+  padding: var(--space-3xs) var(--space-s);
+  color: #888;
+  text-decoration: none;
+  font-size: var(--step--2);
+  border-left: 2px solid transparent;
+  transition: color 0.15s, border-color 0.15s, background 0.15s;
+  text-transform: capitalize;
+}
+
+.sidebar-link:hover {
+  color: #ccc;
+  background: #1e1e36;
+}
+
+.sidebar-link.active {
+  color: #fff;
+  border-left-color: #5e81ac;
+  background: #1e1e36;
+}
+
+.sidebar-badge {
+  width: 1rem;
+  height: 1rem;
+  display: inline-grid;
+  place-content: center;
+  border-radius: 50%;
+  margin-left: auto;
+  flex-shrink: 0;
+}
+
+.sidebar-badge.new { background: #2d6a4f; color: #b7e4c7; }
+.sidebar-badge.removed { background: #6a2d2d; color: #e4b7b7; }
+
+/* ── Content ── */
+
+.content {
+  margin-left: 14rem;
+  padding: var(--space-l);
+}
+
+/* ── Toolbar ── */
+
+.toolbar {
+  position: sticky;
+  top: 0;
+  z-index: 50;
+  background: #1a1a2e;
+  padding-bottom: var(--space-s);
+  margin-bottom: var(--space-l);
+  border-bottom: 1px solid #2a2a44;
+}
+
+.toolbar h1 {
+  color: #fff;
+  margin-bottom: var(--space-s);
+  font-size: var(--step-2);
+}
+
+.toolbar-controls {
+  display: flex;
+  align-items: center;
+  gap: var(--space-l);
+  flex-wrap: wrap;
+}
+
+/* ── Tabs ── */
+
+.tab-bar {
+  display: flex;
+  gap: var(--space-3xs);
+}
 
-/* Tabs */
+.tab {
+  background: none;
+  border: 1px solid #333;
+  color: #888;
+  font-size: var(--step--1);
+  padding: var(--space-3xs) var(--space-s);
+  cursor: pointer;
+  border-radius: 4px;
+  transition: color 0.15s, border-color 0.15s, background 0.15s;
+}
 
-.tab-bar { display: flex; gap: var(--space-xs); margin-bottom: var(--space-l); border-bottom: 1px solid #333; padding-bottom: var(--space-xs); }
-.tab { background: none; border: none; color: #888; font-size: var(--step-0); padding: var(--space-xs) var(--space-s); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
-.tab:hover { color: #ccc; }
-.tab.active { color: #fff; border-bottom-color: #5e81ac; }
+.tab:hover { color: #ccc; border-color: #555; }
+.tab.active { color: #fff; background: #2a2a44; border-color: #5e81ac; }
 .viewport-group.hidden { display: none; }
 
-/* Diff-specific */
+/* ── Mode bar ── */
+
+.mode-bar {
+  display: flex;
+  gap: var(--space-3xs);
+}
+
+.mode {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--space-3xs);
+  background: none;
+  border: 1px solid #333;
+  color: #888;
+  font-size: var(--step--2);
+  padding: var(--space-3xs) var(--space-xs);
+  cursor: pointer;
+  border-radius: 4px;
+  transition: color 0.15s, border-color 0.15s, background 0.15s;
+}
+
+.mode:hover { color: #ccc; border-color: #555; }
+.mode.active { color: #fff; background: #2a2a44; border-color: #5e81ac; }
+
+.mode svg { flex-shrink: 0; }
+
+/* ── Snapshot cards ── */
+
+.snapshot {
+  margin-bottom: var(--space-xl);
+  scroll-margin-top: 7rem;
+}
+
+.snapshot-header {
+  margin-bottom: var(--space-xs);
+}
+
+.snapshot h2 {
+  margin-bottom: 0;
+  color: #ccc;
+  font-size: var(--step-1);
+  text-transform: capitalize;
+}
+
+.snapshot .path {
+  display: block;
+  margin-top: var(--space-3xs);
+  font-size: var(--step--2);
+  color: #666;
+}
+
+.snapshot img {
+  max-width: 100%;
+  border: 1px solid #333;
+  border-radius: 4px;
+  display: block;
+  margin-inline: auto;
+  cursor: zoom-in;
+}
+
+/* ── Labels (new/removed) ── */
+
+.label {
+  font-size: var(--step--2);
+  padding: 0.15rem 0.5rem;
+  border-radius: 4px;
+  vertical-align: middle;
+  font-weight: 600;
+}
 
-.label { font-size: var(--step--2); padding: 0.15rem 0.5rem; border-radius: 4px; vertical-align: middle; }
 .label.new { background: #2d6a4f; color: #b7e4c7; }
 .label.removed { background: #6a2d2d; color: #e4b7b7; }
 
-.pair { display: flex; gap: var(--space-s); }
-.side { flex: 1; min-width: 0; }
-.side h3 { margin-bottom: var(--space-xs); color: #999; font-size: var(--step--1); text-transform: uppercase; }
-.side img { max-width: 100%; border: 1px solid #333; border-radius: 4px; display: block; margin-inline: auto; }
+/* ── Side-by-side mode ── */
+
+.pair {
+  display: flex;
+  gap: var(--space-s);
+}
+
+.side {
+  flex: 1;
+  min-width: 0;
+}
+
+.side h3 {
+  margin-bottom: var(--space-xs);
+  color: #999;
+  font-size: var(--step--1);
+  text-transform: uppercase;
+  font-weight: 500;
+}
+
+.side img {
+  max-width: 100%;
+  border: 1px solid #333;
+  border-radius: 4px;
+  display: block;
+  margin-inline: auto;
+}
+
+.placeholder {
+  padding: var(--space-xl);
+  text-align: center;
+  color: #555;
+  border: 1px dashed #333;
+  border-radius: 4px;
+  font-style: italic;
+}
 
-.placeholder { padding: var(--space-xl); text-align: center; color: #666; border: 1px dashed #333; border-radius: 4px; }
+/* ── Slider mode ── */
 
-/* Lightbox */
+.slider-container {
+  position: relative;
+  overflow: hidden;
+  border-radius: 4px;
+  border: 1px solid #333;
+  cursor: ew-resize;
+  user-select: none;
+}
 
-.snapshot img { cursor: zoom-in; }
+.slider-img {
+  display: block;
+}
+
+.slider-baseline img,
+.slider-current img {
+  display: block;
+  width: 100%;
+  border: none;
+  border-radius: 0;
+  cursor: ew-resize;
+}
+
+.slider-current {
+  position: absolute;
+  inset: 0;
+  clip-path: inset(0 0 0 50%);
+}
+
+.slider-current img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.slider-handle {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 50%;
+  width: 3px;
+  transform: translateX(-50%);
+  z-index: 10;
+  pointer-events: auto;
+}
+
+.slider-line {
+  position: absolute;
+  inset: 0;
+  background: #5e81ac;
+  opacity: 0.8;
+}
+
+.slider-grip {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  width: 28px;
+  height: 40px;
+  background: #2a2a44;
+  border: 2px solid #5e81ac;
+  border-radius: 6px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #8faabe;
+  cursor: ew-resize;
+  pointer-events: auto;
+  box-shadow: 0 2px 8px rgba(0,0,0,0.4);
+}
+
+.slider-labels {
+  position: absolute;
+  bottom: var(--space-xs);
+  left: 0;
+  right: 0;
+  display: flex;
+  justify-content: space-between;
+  padding: 0 var(--space-s);
+  pointer-events: none;
+}
+
+.slider-labels span {
+  font-size: var(--step--2);
+  color: #999;
+  text-transform: uppercase;
+  font-weight: 500;
+  background: rgba(26, 26, 46, 0.7);
+  padding: 0.1rem 0.4rem;
+  border-radius: 3px;
+}
+
+/* ── Toggle mode ── */
+
+.toggle-container {
+  position: relative;
+  border-radius: 4px;
+  border: 1px solid #333;
+  overflow: hidden;
+  cursor: pointer;
+}
+
+.toggle-container img {
+  display: block;
+  width: 100%;
+  border: none;
+  border-radius: 0;
+  cursor: pointer;
+}
+
+.toggle-container[data-showing="baseline"] .toggle-baseline { display: block; }
+.toggle-container[data-showing="baseline"] .toggle-current { display: none; }
+.toggle-container[data-showing="current"] .toggle-baseline { display: none; }
+.toggle-container[data-showing="current"] .toggle-current { display: block; }
+
+.toggle-label {
+  position: absolute;
+  top: var(--space-xs);
+  right: var(--space-xs);
+  font-size: var(--step--2);
+  color: #ccc;
+  text-transform: uppercase;
+  font-weight: 600;
+  background: rgba(26, 26, 46, 0.8);
+  padding: 0.15rem 0.5rem;
+  border-radius: 4px;
+  border: 1px solid #444;
+  pointer-events: none;
+}
+
+/* ── Single image fallback (new/removed in slider/toggle) ── */
+
+.single-image {
+  position: relative;
+  border-radius: 4px;
+  border: 1px solid #333;
+  overflow: hidden;
+}
+
+.single-image img {
+  display: block;
+  width: 100%;
+  border: none;
+  border-radius: 0;
+}
+
+.single-label {
+  position: absolute;
+  top: var(--space-xs);
+  right: var(--space-xs);
+  font-size: var(--step--2);
+  color: #999;
+  font-style: italic;
+  background: rgba(26, 26, 46, 0.8);
+  padding: 0.15rem 0.5rem;
+  border-radius: 4px;
+}
+
+/* ── Lightbox ── */
+
+.lightbox {
+  display: none;
+  position: fixed;
+  inset: 0;
+  z-index: 1000;
+  background: rgba(0, 0, 0, 0.92);
+  flex-direction: column;
+}
 
-.lightbox { display: none; position: fixed; inset: 0; z-index: 1000; background: rgba(0,0,0,0.9); cursor: zoom-out; justify-content: center; align-items: center; padding: var(--space-s); }
 .lightbox.open { display: flex; }
-.lightbox img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; }
+
+.lightbox-chrome {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: var(--space-xs) var(--space-s);
+  flex-shrink: 0;
+}
+
+.lightbox-title {
+  color: #aaa;
+  font-size: var(--step--1);
+  text-transform: capitalize;
+}
+
+.lightbox-close {
+  background: none;
+  border: none;
+  color: #888;
+  font-size: var(--step-2);
+  cursor: pointer;
+  padding: 0 var(--space-3xs);
+  line-height: 1;
+}
+
+.lightbox-close:hover { color: #fff; }
+
+.lightbox-body {
+  flex: 1;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  padding: var(--space-s);
+  cursor: zoom-out;
+  min-height: 0;
+}
+
+.lightbox img {
+  max-width: 100%;
+  max-height: 100%;
+  object-fit: contain;
+  border-radius: 4px;
+}
+
+/* ── Keyboard shortcuts hint ── */
+
+.shortcuts-hint {
+  position: fixed;
+  bottom: var(--space-s);
+  right: var(--space-s);
+  background: #2a2a44;
+  border: 1px solid #3a3a5a;
+  border-radius: 6px;
+  padding: var(--space-3xs) var(--space-xs);
+  font-size: var(--step--2);
+  color: #888;
+  z-index: 200;
+  opacity: 0.7;
+  transition: opacity 0.2s;
+}
+
+.shortcuts-hint:hover,
+.shortcuts-hint.expanded {
+  opacity: 1;
+}
+
+.shortcuts-hint kbd {
+  display: inline-block;
+  background: #1a1a2e;
+  border: 1px solid #444;
+  border-radius: 3px;
+  padding: 0 0.3rem;
+  font-family: inherit;
+  font-size: var(--step--2);
+  color: #ccc;
+  margin: 0 0.1rem;
+  min-width: 1.2rem;
+  text-align: center;
+}
+
+/* ── Mobile viewport constraints ── */
+
+.viewport-group[data-viewport="mobile"] .pair,
+.viewport-group[data-viewport="mobile"] .view-slider,
+.viewport-group[data-viewport="mobile"] .view-toggle {
+  max-width: 50rem;
+  margin-inline: auto;
+}
+
+.viewport-group[data-viewport="mobile"] .side img,
+.viewport-group[data-viewport="mobile"] .slider-container,
+.viewport-group[data-viewport="mobile"] .toggle-container,
+.viewport-group[data-viewport="mobile"] .single-image {
+  max-width: 375px;
+  margin-inline: auto;
+}
+
+/* ── Hidden utility ── */
+
+.hidden { display: none !important; }
+
+/* ── Gallery-specific (shared stylesheet) ── */
+
+body > h1 {
+  color: #fff;
+  margin-bottom: var(--space-l);
+}
+
+/* Gallery tab-bar sits at top level */
+body > .tab-bar {
+  margin-bottom: var(--space-l);
+  border-bottom: 1px solid #333;
+  padding-bottom: var(--space-xs);
+}