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
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">×</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
+ <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> mode
+ <kbd>t</kbd> toggle
+ <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);
+}