Skip to content

Instantly share code, notes, and snippets.

@johnsyweb
Created November 4, 2025 03:11
Show Gist options
  • Select an option

  • Save johnsyweb/fad45365661de4523d6e7f8f5ba85703 to your computer and use it in GitHub Desktop.

Select an option

Save johnsyweb/fad45365661de4523d6e7f8f5ba85703 to your computer and use it in GitHub Desktop.
Script to capture before/after screenshots of website changes using Jekyll server and Chrome headless
#!/usr/bin/env ruby
# frozen_string_literal: true
# Script to capture before/after screenshots of the about page
# and create a composite image showing the font change
#
# This script should be run in an environment where Ruby is managed by mise
# (or similar tool) to ensure the correct Ruby version is available.
require 'fileutils'
require 'open3'
require 'timeout'
require 'net/http'
require 'uri'
BEFORE_COMMIT = '0a6a959' # Commit before all font/filename changes
AFTER_COMMIT = '0b33c79' # Commit with font changes (Atkinson Hyperlegible)
OUTPUT_DIR = File.join(__dir__, '..', 'tmp', 'font_comparison')
BEFORE_SCREENSHOT = File.join(OUTPUT_DIR, 'before.png')
AFTER_SCREENSHOT = File.join(OUTPUT_DIR, 'after.png')
COMPOSITE_IMAGE = File.join(__dir__, '..', 'images', '2025-11-04-about-page-font-comparison.jpg')
JEKYLL_PORT = 4000
ABOUT_URL = "http://localhost:#{JEKYLL_PORT}/about/"
def run_command(cmd, description)
puts "→ #{description}..."
start_time = Time.now
stdout, stderr, status = Open3.capture3(cmd)
elapsed = Time.now - start_time
unless status.success?
puts "Error: #{description}"
puts stderr unless stderr.empty?
exit 1
end
puts " ✓ Completed in #{elapsed.round(2)}s"
stdout
end
def check_dependencies
puts "Checking dependencies..."
# Check for Chrome/Chromium
chrome_paths = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
`which google-chrome 2>/dev/null`.strip,
`which chromium 2>/dev/null`.strip
].compact.reject(&:empty?)
chrome = chrome_paths.find { |path| File.exist?(path) }
unless chrome
puts "Error: Chrome or Chromium not found. Please install Chrome."
puts " brew install --cask google-chrome"
exit 1
end
# Check for ImageMagick
unless system('which convert > /dev/null 2>&1')
puts "Error: ImageMagick not found. Install with:"
puts " brew install imagemagick"
exit 1
end
puts "✓ Dependencies OK"
chrome
end
def start_jekyll_server
puts "→ Starting Jekyll server on port #{JEKYLL_PORT}..."
# Start Jekyll server in background
pid = spawn('mise', 'exec', '--', 'bundle', 'exec', 'jekyll', 'serve', '--port', JEKYLL_PORT.to_s, '--host', 'localhost', out: '/dev/null', err: '/dev/null')
# Wait for server to be ready
max_attempts = 30
attempt = 0
while attempt < max_attempts
begin
uri = URI(ABOUT_URL)
http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = 1
http.read_timeout = 1
response = http.get(uri.path)
if response.code == '200'
puts " ✓ Jekyll server ready"
return pid
end
rescue => e
# Server not ready yet, wait a bit
sleep 0.5
end
attempt += 1
end
# If we get here, server didn't start
Process.kill('TERM', pid) if pid
puts "Error: Jekyll server did not start within 15 seconds"
exit 1
end
def stop_jekyll_server(pid)
if pid
puts "→ Stopping Jekyll server..."
Process.kill('TERM', pid)
Process.wait(pid) rescue nil
puts " ✓ Jekyll server stopped"
end
end
def capture_screenshot(url, output_path, chrome_path)
puts "→ Capturing screenshot of #{url}..."
start_time = Time.now
# Check if we should run in headless mode (default: true, set HEADLESS=false to disable)
headless = ENV['HEADLESS'] != 'false'
# Use Chrome without user data dir, just disable caching
# iPhone-sized viewport (390x844 is iPhone 12/13 size)
cmd = [
chrome_path
]
if headless
cmd << '--headless=new'
cmd << '--window-size=390,844' # iPhone-sized viewport
cmd << "--screenshot=#{output_path}"
else
cmd << '--window-size=390,844'
cmd << '--window-position=0,0'
puts " → Running Chrome in non-headless mode (window will appear briefly)..."
puts " → Press Ctrl+C to cancel, or wait for screenshot..."
end
cmd.concat([
'--hide-scrollbars',
'--no-first-run',
'--disable-logging',
'--disable-extensions',
'--disable-background-networking',
'--disable-dev-shm-usage',
'--disable-sync',
'--disable-translate',
'--disk-cache-size=1',
'--v8-cache-options=none',
url
])
# Always log the Chrome command line
puts " → Running Chrome command: #{cmd.join(' ')}"
# Show Chrome output if not in headless mode or if DEBUG is set
show_output = !headless || ENV['DEBUG'] == 'true'
if headless
# Run Chrome with timeout in headless mode
# Use system instead of Open3.capture3 to avoid threading issues with Chrome
begin
Timeout.timeout(30) do
if show_output
success = system(*cmd)
unless success
puts " Error: Chrome exited with non-zero status"
exit 1
end
else
# Redirect output to /dev/null for quiet operation
success = system(*cmd, out: '/dev/null', err: '/dev/null')
unless success
# If it failed, try again with stderr visible to see what went wrong
puts " Error: Chrome screenshot failed, trying again with output visible..."
system(*cmd, out: '/dev/null')
exit 1
end
end
end
rescue Timeout::Error
puts "Error: Chrome screenshot timed out after 30 seconds"
exit 1
end
else
# In non-headless mode, just run Chrome and let user see what happens
puts " → Starting Chrome (non-headless) - window will appear..."
puts " → Chrome will stay open so you can see what's happening"
puts " → Press Ctrl+C in this terminal to continue after inspecting..."
system(*cmd)
puts " → Note: Screenshot was not captured in non-headless mode"
puts " → Set HEADLESS=true or remove HEADLESS env var to use headless mode"
return
end
# Wait a moment for file to be written
sleep 1
unless File.exist?(output_path)
puts "Error: Screenshot file was not created"
exit 1
end
elapsed = Time.now - start_time
file_size = File.size(output_path)
puts " ✓ Screenshot saved to #{output_path} (#{file_size} bytes) in #{elapsed.round(2)}s"
end
def create_composite
puts "→ Creating composite image..."
start_time = Time.now
# Get image dimensions
before_info = `identify #{BEFORE_SCREENSHOT}`.split
after_info = `identify #{AFTER_SCREENSHOT}`.split
before_size = before_info[2] # e.g., "390x844"
after_size = after_info[2]
before_width, before_height = before_size.split('x').map(&:to_i)
after_width, after_height = after_size.split('x').map(&:to_i)
# Use same height for both, resize proportionally
target_height = [before_height, after_height].min
before_target_width = (before_width * target_height / before_height.to_f).to_i
after_target_width = (after_width * target_height / after_height.to_f).to_i
total_width = before_target_width + after_target_width
label_height = 50
padding = 10
# Solarized-dark colors
bg_color = '#002a35' # solarized-dark background
text_color = '#839496' # solarized-dark text
accent_color = '#2aa198' # solarized-dark accent
# Create composite with labels
# Use system with array to avoid shell escaping issues
# Calculate center positions for each label area
before_center_x = padding + before_target_width / 2
after_center_x = padding + before_target_width + after_target_width / 2
label_y = label_height / 2
# Create label images separately for proper centering
before_label_file = File.join(OUTPUT_DIR, 'before_label.png')
after_label_file = File.join(OUTPUT_DIR, 'after_label.png')
# Create label images
label_cmd1 = [
'convert',
'-size', "#{before_target_width}x#{label_height}",
"xc:#{bg_color}",
'-font', 'AtkinsonHyperlegible-Bold',
'-pointsize', '22',
'-fill', text_color,
'-gravity', 'Center',
'-annotate', '+0+0', 'Before (Verdana)',
before_label_file
]
label_cmd2 = [
'convert',
'-size', "#{after_target_width}x#{label_height}",
"xc:#{bg_color}",
'-font', 'AtkinsonHyperlegible-Bold',
'-pointsize', '22',
'-fill', text_color,
'-gravity', 'Center',
'-annotate', '+0+0', 'After (Atkinson Hyperlegible)',
after_label_file
]
Open3.capture3(*label_cmd1)
Open3.capture3(*label_cmd2)
# Composite everything together
cmd = [
'convert',
'-size', "#{total_width + padding * 2}x#{target_height + label_height + padding * 2}",
"xc:#{bg_color}",
'(', before_label_file, ')',
'-geometry', "+#{padding}+#{padding}", '-composite',
'(', after_label_file, ')',
'-geometry', "+#{padding + before_target_width}+#{padding}", '-composite',
'(', BEFORE_SCREENSHOT, '-resize', "#{before_target_width}x#{target_height}", ')',
'-geometry', "+#{padding}+#{label_height + padding}", '-composite',
'(', AFTER_SCREENSHOT, '-resize', "#{after_target_width}x#{target_height}", ')',
'-geometry', "+#{before_target_width + padding}+#{label_height + padding}", '-composite',
'-quality', '90',
COMPOSITE_IMAGE
]
stdout, stderr, status = Open3.capture3(*cmd)
unless status.success?
puts "Error: Creating composite image"
puts stderr unless stderr.empty?
exit 1
end
elapsed = Time.now - start_time
puts " ✓ Composite image saved to #{COMPOSITE_IMAGE} in #{elapsed.round(2)}s"
end
def verify_font_usage(should_have_atkinson)
# Check the CSS file for Atkinson Hyperlegible
# Try both possible CSS filenames (style.css or style-20150308.css)
css_dir = File.join(__dir__, '..', 'assets', 'css')
css_file = File.join(css_dir, 'style.css')
old_css_file = File.join(css_dir, 'style-20150308.css')
# Find which CSS file exists
actual_css_file = if File.exist?(css_file)
css_file
elsif File.exist?(old_css_file)
old_css_file
else
nil
end
unless actual_css_file
puts " Warning: CSS file not found (checked style.css and style-20150308.css), skipping verification"
return
end
css_content = File.read(actual_css_file)
has_atkinson = css_content.include?('Atkinson Hyperlegible')
if should_have_atkinson && !has_atkinson
puts " Error: Expected to find 'Atkinson Hyperlegible' in CSS (#{File.basename(actual_css_file)}), but it's not present"
exit 1
elsif !should_have_atkinson && has_atkinson
puts " Error: Found 'Atkinson Hyperlegible' in CSS (#{File.basename(actual_css_file)}), but it should not be present"
exit 1
end
puts " ✓ Font verification passed (#{should_have_atkinson ? 'has' : 'does not have'} Atkinson Hyperlegible in #{File.basename(actual_css_file)})"
end
def checkout_commit(commit)
current_branch = `git rev-parse --abbrev-ref HEAD`.strip
puts "→ Checking out commit #{commit}..."
# Stash any uncommitted changes to avoid them affecting the build
has_stash = false
unless system('git diff --quiet && git diff --cached --quiet')
puts " → Stashing uncommitted changes..."
system('git stash push -m "Auto-stash for font comparison script"')
has_stash = true
end
run_command("git checkout #{commit}", "Checking out commit")
# Ensure bundle dependencies are up to date
# Use mise exec to ensure correct Ruby version for this commit
puts "→ Checking bundle dependencies..."
unless system('mise exec -- bundle check > /dev/null 2>&1')
puts " → Running bundle install..."
# Use mise exec to ensure correct Ruby version
stdout, stderr, status = Open3.capture3('mise exec -- bundle install')
unless status.success?
if stderr.include?('requires ruby version') || stderr.include?('incompatible with the current version')
puts " Warning: bundle install failed due to Ruby version mismatch"
puts " → Continuing anyway (gems may already be installed)..."
else
puts " Error: bundle install failed"
puts stderr unless stderr.empty?
exit 1
end
else
puts " ✓ Bundle dependencies installed"
end
else
puts " ✓ Bundle dependencies OK"
end
[current_branch, has_stash]
end
# Main execution
puts "Font Comparison Screenshot Script"
puts "=" * 50
# Setup
FileUtils.mkdir_p(OUTPUT_DIR)
FileUtils.mkdir_p(File.dirname(COMPOSITE_IMAGE))
# Remove old screenshots before starting
puts "→ Cleaning up old screenshots..."
FileUtils.rm_f(BEFORE_SCREENSHOT) if File.exist?(BEFORE_SCREENSHOT)
FileUtils.rm_f(AFTER_SCREENSHOT) if File.exist?(AFTER_SCREENSHOT)
FileUtils.rm_f(COMPOSITE_IMAGE) if File.exist?(COMPOSITE_IMAGE)
puts " ✓ Cleanup complete"
chrome_path = check_dependencies
# Save current branch and stash state
original_branch = `git rev-parse --abbrev-ref HEAD`.strip
stash_was_created = false
begin
# Capture BEFORE screenshot
puts "\n--- Capturing BEFORE screenshot ---"
original_branch, stash_was_created = checkout_commit(BEFORE_COMMIT)
unless system('which mise > /dev/null 2>&1')
puts "Error: mise not found. Please install mise:"
puts " curl https://mise.run | sh"
exit 1
end
verify_font_usage(false) # Before should NOT have Atkinson Hyperlegible
jekyll_pid = start_jekyll_server
begin
capture_screenshot(ABOUT_URL, BEFORE_SCREENSHOT, chrome_path)
ensure
stop_jekyll_server(jekyll_pid)
end
# Capture AFTER screenshot
puts "\n--- Capturing AFTER screenshot ---"
checkout_commit(AFTER_COMMIT)
verify_font_usage(true) # After SHOULD have Atkinson Hyperlegible
jekyll_pid = start_jekyll_server
begin
capture_screenshot(ABOUT_URL, AFTER_SCREENSHOT, chrome_path)
ensure
stop_jekyll_server(jekyll_pid)
end
# Create composite
puts "\n--- Creating composite image ---"
create_composite
puts "\n✓ Done! Composite image created at: #{COMPOSITE_IMAGE}"
ensure
# Cleanup
if original_branch
run_command("git checkout #{original_branch}", "Restoring original branch")
if stash_was_created
puts "→ Restoring stashed changes..."
system('git stash pop')
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment