Add snapshot capture, diff, and review rake tasks
Ferrum screenshots the app at fixed routes, compares against
baselines downloaded from GitHub releases, and generates a
side-by-side HTML viewer. Screenshots stored in XDG_CACHE_HOME.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/Rakefile b/Rakefile
index 88e6e02..b16fa74 100644
--- a/Rakefile
+++ b/Rakefile
@@ -96,4 +96,75 @@ task :seed do
puts "Seeded #{notes.length} series for #{user.name} (#{user.login})"
end
+namespace :snapshots do
+ desc "Capture screenshots of the app in key states"
+ task :capture do
+ ENV["DATABASE_URL"] = ":memory:"
+ require "ketchup/web"
+ require "ketchup/seed"
+ require "ketchup/snapshots"
+ require "puma"
+ require "puma/configuration"
+
+ output_dir = File.join(Snapshots.cache_dir, "current")
+
+ config = Puma::Configuration.new do |c|
+ c.app Web.freeze.app
+ c.bind "tcp://127.0.0.1:0"
+ c.log_requests false
+ c.quiet
+ end
+
+ launcher = Puma::Launcher.new(config)
+ thread = Thread.new { launcher.run }
+ sleep 1 until launcher.connected_ports.any?
+
+ port = launcher.connected_ports.first
+ Snapshots.capture(output_dir: output_dir, port: port)
+ launcher.stop
+ thread.join
+
+ puts "Screenshots saved to #{output_dir}"
+ end
+
+ desc "Compare current screenshots against baseline from latest release"
+ task :diff do
+ require "ketchup/snapshots"
+
+ base_dir = Snapshots.cache_dir
+ baseline_dir = File.join(base_dir, "baseline")
+ current_dir = File.join(base_dir, "current")
+
+ FileUtils.rm_rf(baseline_dir)
+ FileUtils.mkdir_p(baseline_dir)
+
+ tarball = File.join(base_dir, "baseline.tar.gz")
+ system("gh", "release", "download", "--pattern", "snapshots.tar.gz", "--output", tarball, "--clobber", exception: false)
+
+ if File.exist?(tarball)
+ system("tar", "xzf", tarball, "-C", baseline_dir, exception: true)
+ File.delete(tarball)
+ puts "Downloaded baseline from latest release"
+ else
+ puts "No baseline found — showing current screenshots only"
+ end
+
+ Rake::Task["snapshots:capture"].invoke
+
+ output_path = File.join(base_dir, "diff.html")
+ Snapshots.generate_diff_html(
+ baseline_dir: baseline_dir,
+ current_dir: current_dir,
+ output_path: output_path
+ )
+ puts "Diff viewer: #{output_path}"
+ end
+
+ desc "Capture, diff, and open the viewer"
+ task :review do
+ Rake::Task["snapshots:diff"].invoke
+ system("open", File.join(Snapshots.cache_dir, "diff.html"))
+ end
+end
+
task default: %i[ test binstubs ]
diff --git a/lib/ketchup/snapshots.rb b/lib/ketchup/snapshots.rb
new file mode 100644
index 0000000..97d2de5
--- /dev/null
+++ b/lib/ketchup/snapshots.rb
@@ -0,0 +1,220 @@
+# frozen_string_literal: true
+
+require "fileutils"
+
+module Snapshots
+ SEED_DATA = [
+ {
+ note: "Call Mom",
+ interval_unit: "week",
+ interval_count: 2,
+ due_date: Date.new(2026, 1, 15),
+ history: [
+ { due_date: Date.new(2026, 1, 1), completed_at: Time.new(2026, 1, 2, 10, 0, 0), note: "Left a message" },
+ { due_date: Date.new(2025, 12, 18), completed_at: Time.new(2025, 12, 18, 14, 0, 0), note: nil }
+ ]
+ },
+ {
+ note: "Water the plants\n\nCheck soil moisture *before* watering",
+ interval_unit: "day",
+ interval_count: 3,
+ due_date: Date.new(2026, 2, 10),
+ history: []
+ },
+ {
+ note: "Clean the kitchen",
+ interval_unit: "week",
+ interval_count: 1,
+ due_date: Date.new(2026, 2, 20),
+ history: [
+ { due_date: Date.new(2026, 2, 13), completed_at: Time.new(2026, 2, 13, 9, 0, 0), note: "Deep cleaned the oven" }
+ ]
+ },
+ {
+ note: "Review finances",
+ interval_unit: "month",
+ interval_count: 1,
+ due_date: Date.new(2026, 3, 1),
+ history: []
+ },
+ {
+ note: "Dentist appointment\n\n**Dr. Chen**, 10am\n555-0142 to reschedule",
+ interval_unit: "quarter",
+ interval_count: 1,
+ due_date: Date.new(2026, 4, 15),
+ history: []
+ }
+ ].freeze
+
+ def self.cache_dir
+ base = ENV.fetch("XDG_CACHE_HOME", File.expand_path("~/.cache"))
+ File.join(base, "ketchup", "snapshots")
+ end
+
+ def self.capture(output_dir:, port:)
+ require "ferrum"
+
+ FileUtils.rm_rf(output_dir)
+ FileUtils.mkdir_p(output_dir)
+
+ user = User.find_or_create(login: "snapshot@example.com") { |u| u.name = "Snapshot User" }
+ Seed.call(user: user, series: SEED_DATA)
+
+ snapshots = build_snapshot_list(user, port)
+
+ browser = Ferrum::Browser.new(
+ headless: true,
+ window_size: [1280, 900]
+ )
+
+ snapshots.each do |snap|
+ browser.goto(snap[:url])
+ # Allow JS/Alpine to initialize
+ sleep 0.3
+
+ path = File.join(output_dir, "#{snap[:name]}.png")
+
+ if snap[:selector]
+ browser.screenshot(path: path, selector: snap[:selector])
+ else
+ browser.screenshot(path: path)
+ end
+ end
+ ensure
+ browser&.quit
+ end
+
+ def self.build_snapshot_list(user, port)
+ base = "http://127.0.0.1:#{port}"
+
+ # Find the first series with history (for detail view)
+ detail_series = user.series.find { |s| !s.completed_tasks.empty? }
+ detail_series ||= user.series.first
+
+ [
+ { name: "dashboard", url: base, selector: nil },
+ { name: "overdue-column", url: base, selector: ".column:first-child" },
+ { name: "upcoming-column", url: base, selector: ".column:nth-child(2)" },
+ { name: "series-detail", url: "#{base}/series/#{detail_series.id}", selector: nil },
+ { name: "series-sidebar", url: "#{base}/series/#{detail_series.id}", selector: ".column-aside" }
+ ]
+ end
+
+ def self.generate_diff_html(baseline_dir:, current_dir:, output_path:)
+ baseline_images = Dir.glob(File.join(baseline_dir, "*.png")).map { |f| File.basename(f, ".png") }.sort
+ current_images = Dir.glob(File.join(current_dir, "*.png")).map { |f| File.basename(f, ".png") }.sort
+ all_names = (baseline_images + current_images).uniq.sort
+
+ rows = all_names.map do |name|
+ has_baseline = baseline_images.include?(name)
+ has_current = current_images.include?(name)
+
+ label = if !has_baseline
+ " <span class=\"label new\">new</span>"
+ elsif !has_current
+ " <span class=\"label removed\">removed</span>"
+ else
+ ""
+ end
+
+ baseline_cell = if has_baseline
+ baseline_rel = File.join("baseline", "#{name}.png")
+ "<img src=\"#{baseline_rel}\" alt=\"baseline #{name}\">"
+ else
+ "<div class=\"placeholder\">No baseline</div>"
+ end
+
+ current_cell = if has_current
+ current_rel = File.join("current", "#{name}.png")
+ "<img src=\"#{current_rel}\" alt=\"current #{name}\">"
+ else
+ "<div class=\"placeholder\">Removed</div>"
+ end
+
+ <<~ROW
+ <div class="snapshot">
+ <h2>#{name}#{label}</h2>
+ <div class="pair">
+ <div class="side">
+ <h3>Baseline</h3>
+ #{baseline_cell}
+ </div>
+ <div class="side">
+ <h3>Current</h3>
+ #{current_cell}
+ </div>
+ </div>
+ </div>
+ ROW
+ end
+
+ html = <<~HTML
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <title>Ketchup Snapshot Diff</title>
+ <style>
+ * { box-sizing: border-box; margin: 0; padding: 0; }
+ body { background: #1a1a2e; color: #e0e0e0; font-family: system-ui, sans-serif; padding: 2rem; }
+ h1 { margin-bottom: 2rem; color: #fff; }
+ .snapshot { margin-bottom: 3rem; }
+ .snapshot h2 { margin-bottom: 1rem; color: #ccc; }
+ .label { font-size: 0.75rem; 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: 1rem; }
+ .side { flex: 1; min-width: 0; }
+ .side h3 { margin-bottom: 0.5rem; color: #999; font-size: 0.875rem; text-transform: uppercase; }
+ .side img { width: 100%; border: 1px solid #333; border-radius: 4px; }
+ .placeholder { padding: 3rem; text-align: center; color: #666; border: 1px dashed #333; border-radius: 4px; }
+ </style>
+ </head>
+ <body>
+ <h1>Ketchup Snapshot Diff</h1>
+ #{rows.join("\n")}
+ </body>
+ </html>
+ HTML
+
+ File.write(output_path, html)
+ end
+
+ def self.generate_gallery_html(images_dir:, output_path:)
+ images = Dir.glob(File.join(images_dir, "*.png")).sort.map { |f| File.basename(f) }
+
+ items = images.map do |filename|
+ name = File.basename(filename, ".png")
+ <<~ITEM
+ <div class="snapshot">
+ <h2>#{name}</h2>
+ <img src="#{filename}" alt="#{name}">
+ </div>
+ ITEM
+ end
+
+ html = <<~HTML
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <title>Ketchup Snapshots</title>
+ <style>
+ * { box-sizing: border-box; margin: 0; padding: 0; }
+ body { background: #1a1a2e; color: #e0e0e0; font-family: system-ui, sans-serif; padding: 2rem; }
+ h1 { margin-bottom: 2rem; color: #fff; }
+ .snapshot { margin-bottom: 3rem; }
+ .snapshot h2 { margin-bottom: 1rem; color: #ccc; }
+ .snapshot img { max-width: 100%; border: 1px solid #333; border-radius: 4px; }
+ </style>
+ </head>
+ <body>
+ <h1>Ketchup Snapshots</h1>
+ #{items.join("\n")}
+ </body>
+ </html>
+ HTML
+
+ File.write(output_path, html)
+ end
+end