Capture snapshots at desktop and mobile viewports
Single-viewport captures missed layout regressions on narrow screens.
Capture now runs at 1280x900 and 375x812, writing to desktop/ and
mobile/ subdirs. On mobile, series creation uses the standalone
/series/new page since the inline sidebar is hidden. Gallery and diff
viewers switch between viewports with a tab bar.

Assisted-by: Claude Opus 4.6 via Claude Code
change yolmmkposmvrytrwwplpwtlwsvrwytoq
commit aef7d77f9ec56a135eb78b3db2155b4040cfde77
author Alpha Chen <alpha@kejadlen.dev>
date
parent ltymmmnz
diff --git a/Rakefile b/Rakefile
index e267267..732c681 100644
--- a/Rakefile
+++ b/Rakefile
@@ -89,11 +89,11 @@ namespace :snapshots do
       puts "No baseline found — showing current screenshots only"
     end
 
-    snapshots = Ketchup::Snapshots::Diff.new(baseline_dir: baseline_dir, current_dir: current_dir).comparisons
+    snapshots_by_viewport = Ketchup::Snapshots::Diff.new(baseline_dir: baseline_dir, current_dir: current_dir).comparisons_by_viewport
 
     template = (Pathname(__dir__) / "templates/snapshot_diff.erb").read
     output_path = base_dir / "diff.html"
-    output_path.write(ERB.new(template, trim_mode: "-").result_with_hash(snapshots: snapshots))
+    output_path.write(ERB.new(template, trim_mode: "-").result_with_hash(snapshots_by_viewport: snapshots_by_viewport))
     puts "Diff viewer: #{output_path}"
   end
 
@@ -112,16 +112,18 @@ namespace :snapshots do
 
     css_sources.each_key { |src| cp src, output_path.dirname.to_s }
 
-    entries = Ketchup::Snapshots::Entry.read_manifest(images_dir)
-
     title = "Ketchup Snapshots"
-    images_rel = images_dir.relative_path_from(output_path.dirname)
-    images = entries.map do |entry|
-      { entry: entry, filename: (images_rel / "#{entry.name}.png").to_s }
+    images_by_viewport = Ketchup::Snapshots::VIEWPORTS.keys.each_with_object({}) do |viewport, result|
+      viewport_dir = images_dir / viewport
+      entries = Ketchup::Snapshots::Entry.read_manifest(viewport_dir)
+      images_rel = viewport_dir.relative_path_from(output_path.dirname)
+      result[viewport] = entries.map do |entry|
+        { entry: entry, filename: (images_rel / "#{entry.name}.png").to_s }
+      end
     end
 
     template = (Pathname(__dir__) / "templates/snapshot_gallery.erb").read
-    output_path.write(ERB.new(template, trim_mode: "-").result_with_hash(title: title, images: images))
+    output_path.write(ERB.new(template, trim_mode: "-").result_with_hash(title: title, images_by_viewport: images_by_viewport))
     puts output_path
   end
 end
diff --git a/lib/ketchup/snapshots.rb b/lib/ketchup/snapshots.rb
index 8ed25d7..1d24470 100644
--- a/lib/ketchup/snapshots.rb
+++ b/lib/ketchup/snapshots.rb
@@ -16,14 +16,21 @@ require_relative "web"
 
 module Ketchup
   module Snapshots
-    Entry = Data.define(:name, :path, :selector) do
-      def initialize(name:, path:, selector: nil) = super
+    VIEWPORTS = {
+      "desktop" => [1280, 900],
+      "mobile" => [375, 812],
+    }.freeze
+
+    Entry = Data.define(:name, :path, :selector, :viewport) do
+      def initialize(name:, path:, selector: nil, viewport: "desktop") = super
 
       def self.read_manifest(dir)
         manifest = dir / "manifest.json"
         return [] unless manifest.exist?
 
-        JSON.parse(manifest.read).map { |e| new(name: e.fetch("name"), path: e.fetch("path"), selector: e["selector"]) }
+        JSON.parse(manifest.read).map do |e|
+          new(name: e.fetch("name"), path: e.fetch("path"), selector: e["selector"], viewport: e.fetch("viewport", "desktop"))
+        end
       end
     end
 
@@ -31,18 +38,38 @@ module Ketchup
 
     class Diff
       def initialize(baseline_dir:, current_dir:)
-        @baseline = Entry.read_manifest(baseline_dir).each_with_object({}) { |e, h| h[e.name] = e }
-        @current = Entry.read_manifest(current_dir).each_with_object({}) { |e, h| h[e.name] = e }
+        @baseline_dir = Pathname(baseline_dir)
+        @current_dir = Pathname(current_dir)
+      end
+
+      # Returns { "desktop" => [Comparison, ...], "mobile" => [Comparison, ...] }
+      def comparisons_by_viewport
+        VIEWPORTS.keys.each_with_object({}) do |viewport, result|
+          baseline = read_entries(@baseline_dir / viewport)
+          current = read_entries(@current_dir / viewport)
+          result[viewport] = compare(baseline, current)
+        end
       end
 
+      # Flat list for backward compatibility (desktop only, or merged)
       def comparisons
-        return unchanged if @baseline.keys == @current.keys
+        comparisons_by_viewport.values.first || []
+      end
+
+      private
+
+      def read_entries(dir)
+        Entry.read_manifest(dir).each_with_object({}) { |e, h| h[e.name] = e }
+      end
+
+      def compare(baseline, current)
+        return current.map { |name, entry| Comparison.new(name: name, baseline: baseline[name], current: entry) } if baseline.keys == current.keys
 
         require "tempfile"
         baseline_file = Tempfile.new("baseline")
         current_file = Tempfile.new("current")
-        baseline_file.write(@baseline.keys.join("\n") + "\n")
-        current_file.write(@current.keys.join("\n") + "\n")
+        baseline_file.write(baseline.keys.join("\n") + "\n")
+        current_file.write(current.keys.join("\n") + "\n")
         baseline_file.close
         current_file.close
 
@@ -50,18 +77,12 @@ module Ketchup
           name = line[1..].chomp
           next if name.empty?
           case line[0]
-          when " " then Comparison.new(name: name, baseline: @baseline.fetch(name), current: @current.fetch(name))
-          when "-" then Comparison.new(name: name, baseline: @baseline.fetch(name), current: nil)
-          when "+" then Comparison.new(name: name, baseline: nil, current: @current.fetch(name))
+          when " " then Comparison.new(name: name, baseline: baseline.fetch(name), current: current.fetch(name))
+          when "-" then Comparison.new(name: name, baseline: baseline.fetch(name), current: nil)
+          when "+" then Comparison.new(name: name, baseline: nil, current: current.fetch(name))
           end
         end
       end
-
-      private
-
-      def unchanged
-        @current.map { |name, entry| Comparison.new(name: name, baseline: @baseline[name], current: entry) }
-      end
     end
 
     class Capture
@@ -77,13 +98,23 @@ module Ketchup
 
         @browser = Ferrum::Browser.new(
           headless: true,
-          window_size: [1280, 900]
+          window_size: VIEWPORTS.fetch("desktop")
         )
 
         @server.call(@browser) do |url|
           @base = url
           @logger.info("Server at #{@base}")
-          run_capture
+
+          VIEWPORTS.each do |viewport_name, (width, height)|
+            @viewport = viewport_name
+            @viewport_dir = @output_dir / viewport_name
+            @viewport_dir.mkpath
+            @browser.resize(width: width, height: height)
+            @logger.info("Capturing #{viewport_name} (#{width}x#{height})")
+
+            entries = run_capture(width: width, height: height)
+            (@viewport_dir / "manifest.json").write(JSON.pretty_generate(entries.map(&:to_h)))
+          end
         end
       ensure
         @browser&.quit
@@ -114,7 +145,7 @@ module Ketchup
       #   TODO: hover over un-noted task to reveal "add a note...", sidebar
       #   click "add a note", type markdown, sidebar
       #   focus an existing note's editor, sidebar
-      def run_capture
+      def run_capture(width:, height:)
         entries = []
 
         # 1. Whole dashboard
@@ -156,29 +187,57 @@ module Ketchup
         # There's probably a better way — captureBeyondViewport, full-page
         # screenshot with crop, etc. — but this works for now.
         page_h = @browser.evaluate("document.body.scrollHeight")
-        @browser.resize(width: 1280, height: page_h)
+        @browser.resize(width: width, height: page_h)
         entries << snap("upcoming-calendar-bottom", area: { x: col["x"], y: page_h - viewport_h, width: col["width"], height: viewport_h })
-        @browser.resize(width: 1280, height: 900)
-
-        # 8. Sidebar (new series)
-        entries << snap("new-series", selector: ".column-aside")
-
-        # 9. New series form filled in (with markdown), not created yet
-        fill_new_series(
-          note: "Call the vet\n\nAsk about *vaccination schedule*\n- Bring **shot records**\n- Check flea meds",
-          interval_count: 2,
-          interval_unit: "week"
-        )
-        entries << snap("new-series-editing", selector: ".column-aside")
-
-        # 10. Series detail, post-creation
-        wait_for("#create-series-btn").click
-        wait_for("#series-note-detail")
-        entries << snap("series-created", selector: ".column-aside")
+        @browser.resize(width: width, height: height)
+
+        # Steps 8-11: on desktop, these use the inline sidebar on the
+        # dashboard. On mobile, series creation uses the standalone
+        # /series/new page and the detail view stacks at the top.
+        if element_visible?(".column-aside")
+          # 8. Sidebar (new series)
+          entries << snap("new-series", selector: ".column-aside")
+
+          # 9. New series form filled in (with markdown), not created yet
+          fill_new_series(
+            note: "Call the vet\n\nAsk about *vaccination schedule*\n- Bring **shot records**\n- Check flea meds",
+            interval_count: 2,
+            interval_unit: "week"
+          )
+          entries << snap("new-series-editing", selector: ".column-aside")
+
+          # 10. Series detail, post-creation
+          wait_for("#create-series-btn").click
+          wait_for("#series-note-detail")
+          entries << snap("series-created", selector: ".column-aside")
+
+          # 11. Series detail, editing note, post-creation
+          wait_for("button.aside-heading-action").click
+          entries << snap("series-editing", selector: ".column-aside")
+        else
+          # 8. Standalone new-series page
+          entries << snap("new-series") do
+            goto "#{@base}/series/new"
+            wait_for("form[action='/series']")
+          end
 
-        # 11. Series detail, editing note, post-creation
-        wait_for("button.aside-heading-action").click
-        entries << snap("series-editing", selector: ".column-aside")
+          # 9. New series form filled in
+          fill_new_series_standalone(
+            note: "Call the vet\n\nAsk about *vaccination schedule*\n- Bring **shot records**\n- Check flea meds",
+            interval_count: 2,
+            interval_unit: "week"
+          )
+          entries << snap("new-series-editing")
+
+          # 10. Series detail, post-creation
+          wait_for("button[type='submit']").click
+          wait_for("#series-note-detail")
+          entries << snap("series-created")
+
+          # 11. Series detail, editing note, post-creation
+          wait_for("button.aside-heading-action").click
+          entries << snap("series-editing")
+        end
 
         # 12. Series detail of a series with multiple finished tasks
         noted_ids = Task.exclude(completed_at: nil).where(Sequel.like(:note, "%*%")).select(:series_id)
@@ -209,7 +268,7 @@ module Ketchup
         existing.focus
         entries << snap("task-edit-note", selector: ".column-aside")
 
-        (@output_dir / "manifest.json").write(JSON.pretty_generate(entries.map(&:to_h)))
+        entries
       end
 
       def default_server(browser)
@@ -271,10 +330,10 @@ module Ketchup
       def snap(name, selector: nil, area: nil)
         yield if block_given?
 
-        file = @output_dir / "#{name}.png"
+        file = @viewport_dir / "#{name}.png"
         if area
           @browser.screenshot(path: file.to_s, area: area)
-        elsif selector
+        elsif selector && element_visible?(selector)
           @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 = ''")
@@ -282,8 +341,13 @@ module Ketchup
           @browser.screenshot(path: file.to_s)
         end
         url_path = URI.parse(@browser.current_url).path
-        @logger.info(name)
-        Entry.new(name: name, path: url_path, selector: selector)
+        @logger.info("#{@viewport}/#{name}")
+        Entry.new(name: name, path: url_path, selector: selector, viewport: @viewport)
+      end
+
+      def element_visible?(selector)
+        @browser.evaluate("(function() { var el = document.querySelector(#{selector.to_json}); if (!el) return null; var r = el.getBoundingClientRect(); return { width: r.width, height: r.height }; })()")
+          &.then { |rect| rect["width"] > 0 && rect["height"] > 0 } || false
       end
 
       def fill_new_series(note:, interval_count: 1, interval_unit: "day")
@@ -300,6 +364,21 @@ module Ketchup
         unit_select = @browser.at_css("select[name='interval_unit']")
         unit_select.select(interval_unit)
       end
+
+      def fill_new_series_standalone(note:, interval_count: 1, interval_unit: "day")
+        textarea = @browser.at_css("textarea#note")
+        textarea.focus
+        textarea.evaluate("this.value = #{note.to_json}")
+        textarea.evaluate('this.dispatchEvent(new Event("input", { bubbles: true }))')
+
+        count_input = @browser.at_css("input#interval_count")
+        count_input.focus
+        count_input.evaluate("this.value = ''")
+        count_input.type(interval_count.to_s)
+
+        unit_select = @browser.at_css("select#interval_unit")
+        unit_select.select(interval_unit)
+      end
     end
   end
 end
diff --git a/templates/snapshot_diff.erb b/templates/snapshot_diff.erb
index de85f96..c1853bd 100644
--- a/templates/snapshot_diff.erb
+++ b/templates/snapshot_diff.erb
@@ -9,38 +9,55 @@
 </head>
 <body>
   <h1>Ketchup Snapshot Diff</h1>
-  <% 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/<%= snap.name %>.png" alt="baseline <%= snap.name %>">
-        <% else %>
-        <div class="placeholder">No baseline</div>
-        <% end %>
-      </div>
-      <div class="side">
-        <h3>Current</h3>
-        <% if snap.current %>
-        <img src="current/<%= snap.name %>.png" alt="current <%= snap.name %>">
-        <% else %>
-        <div class="placeholder">Removed</div>
-        <% end %>
+  <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>
+  <% 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 %>">
+          <% 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 %>">
+          <% else %>
+          <div class="placeholder">Removed</div>
+          <% end %>
+        </div>
       </div>
     </div>
+    <% end %>
   </div>
-  <% end %>
+  <% end -%>
+  <script>
+    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); });
+    });
+  </script>
 </body>
 </html>
diff --git a/templates/snapshot_gallery.erb b/templates/snapshot_gallery.erb
index 85ad833..f585a95 100644
--- a/templates/snapshot_gallery.erb
+++ b/templates/snapshot_gallery.erb
@@ -9,12 +9,29 @@
 </head>
 <body>
   <h1><%= title %></h1>
-  <% images.each do |image| %>
-  <div class="snapshot">
-    <h2><%= image[:entry].name %></h2>
-    <code class="path"><%= image[:entry].path %><%= " #{image[:entry].selector}" if image[:entry].selector %></code>
-    <img src="<%= image[:filename] %>" alt="<%= image[:entry].name %>">
+  <div class="tab-bar">
+    <% images_by_viewport.each_with_index do |(viewport, _), i| -%>
+    <button class="tab<%= " active" if i == 0 %>" data-viewport="<%= viewport %>"><%= viewport.capitalize %></button>
+    <% end -%>
   </div>
-  <% end %>
+  <% images_by_viewport.each_with_index do |(viewport, images), i| -%>
+  <div class="viewport-group<%= " hidden" unless i == 0 %>" data-viewport="<%= viewport %>">
+    <% images.each do |image| %>
+    <div class="snapshot">
+      <h2><%= image[:entry].name %></h2>
+      <code class="path"><%= image[:entry].path %><%= " #{image[:entry].selector}" if image[:entry].selector %></code>
+      <img src="<%= image[:filename] %>" alt="<%= image[:entry].name %>">
+    </div>
+    <% end %>
+  </div>
+  <% end -%>
+  <script>
+    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); });
+    });
+  </script>
 </body>
 </html>
diff --git a/templates/snapshots.css b/templates/snapshots.css
index 290fa5e..1256ba5 100644
--- a/templates/snapshots.css
+++ b/templates/snapshots.css
@@ -13,6 +13,14 @@ h1 { margin-bottom: var(--space-l); color: #fff; }
 .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; }
 
+/* Tabs */
+
+.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; }
+.viewport-group.hidden { display: none; }
+
 /* Diff-specific */
 
 .label { font-size: var(--step--2); padding: 0.15rem 0.5rem; border-radius: 4px; vertical-align: middle; }