docs: add parallax design
Assisted-by: Claude Opus 5.5 via Claude Code
diff --git a/docs/plans/2026-09-25-parallax-design.md b/docs/plans/2026-09-25-parallax-design.md
new file mode 100644
index 0000000..99fe389
--- /dev/null
+++ b/docs/plans/2026-09-25-parallax-design.md
@@ -0,0 +1,384 @@
+# Parallax design
+
+Parallax is a visual snapshot testing tool written in Rust and scripted
+in Fennel. It starts a web app, drives a headless browser through
+scripted flows at several viewport sizes, captures screenshots, and
+builds a static site that compares them against a baseline.
+
+It extracts the snapshot machinery from [ketchup][ketchup], where it
+lives as Ruby in `lib/ketchup/snapshots.rb`, the `snapshots:*` Rake
+tasks, and ERB templates. Ketchup is the first consumer and the
+reference for what parallax must support.
+
+[ketchup]: https://github.com/kejadlen/ketchup
+
+## Scope
+
+Parallax does the following:
+
+- Starts the app under test once per viewport and receives startup data
+ from it
+- Runs Fennel flows that navigate, interact, and capture screenshots
+- Fails a flow when the page logs console errors or warnings, or throws
+ an uncaught exception
+- Offers `post` and `sh` helpers for changing app state outside the UI
+- Compares screenshots against a baseline and reports difference stats
+- Reads baselines from GitHub releases, with a GitHub Action that
+ writes them
+- Generates a static site: a gallery when no baseline exists, a
+ side-by-side comparison when one does
+
+The following are out of scope for now:
+
+- Deciding whether differences fail a build. Parallax reports stats,
+ and consumers apply their own gates.
+- Controlling time. Apps that render dates must make them deterministic
+ themselves.
+- Custom site templates or renderers. The built-in site is the only
+ output; customization waits for a real need.
+- Restarting the server within a viewport. State resets only between
+ viewports.
+- Running viewports in parallel. The design allows it later (see
+ [Lifecycle](#lifecycle)).
+
+## What changes from ketchup
+
+The ketchup implementation has three problems that parallax fixes by
+design.
+
+Viewports share state. Ketchup starts one server, seeds it once, and
+runs the full flow for each viewport. The mobile pass starts from the
+state the desktop pass left behind, so it shows different data and
+creates the shared series twice. Parallax starts a fresh server per
+viewport.
+
+Page errors between snaps are lost. `check_page_errors` clears its
+buffers when a snap begins, so an error logged by a click before the
+snap is discarded. Parallax checks errors across the whole flow.
+
+Flows reach into the app's models. Ketchup's capture code calls
+`Series.create_with_first_task` and queries the database directly. An
+external tool can't, so parallax replaces this with startup data from
+the handshake and the `post` and `sh` helpers.
+
+## Components
+
+Parallax has the following parts:
+
+| Component | Responsibility |
+|---|---|
+| CLI | Loads the config, runs viewports, writes output, sets the exit code |
+| Server supervisor | Spawns the app, waits for the handshake, tears down |
+| Browser driver | Talks to Chrome over the DevTools Protocol |
+| Fennel runtime | Embeds Lua and the Fennel compiler, exposes the `parallax` module |
+| Comparator | Matches snapshots against the baseline and computes stats |
+| GitHub client | Downloads snapshot archives from release assets |
+| Site generator | Writes the static site and `report.json` |
+
+Likely crates are `mlua` (with its `async` feature, so Fennel code reads
+as synchronous while Rust awaits browser calls) and `chromiumoxide` for
+the DevTools Protocol. Parallax uses the [`xdg`][xdg] crate to resolve
+the cache directory. The site's template engine is an internal detail
+and not yet chosen.
+
+[xdg]: https://github.com/whitequark/rust-xdg
+
+## Lifecycle
+
+For each viewport, in config order, parallax does the following:
+
+1. Create a temporary directory and set `PARALLAX_HANDSHAKE` to a file
+ path inside it in the child's environment.
+2. Spawn the server command in its own process group, so that wrappers
+ like `bundle exec` don't leave orphaned children.
+3. Poll for the handshake file until it exists and parses. If the child
+ exits first or the timeout passes, fail and show the tail of its
+ stderr.
+4. Open a fresh browser context sized to the viewport.
+5. Run each flow in order. If a flow fails, stop the viewport, since
+ later flows may depend on its state.
+6. Send SIGTERM to the process group, wait a grace period, then send
+ SIGKILL.
+
+Parallax writes the server's stdout and stderr to
+`<output>/<viewport>/server.log`.
+
+Each viewport has its own server and port, so viewports could run in
+parallel later. That requires a separate Lua VM per viewport, which the
+config shape already allows because loading the config has no side
+effects.
+
+## Handshake
+
+The app writes JSON to the path in `PARALLAX_HANDSHAKE` once it is
+listening. It must write to a temporary file and rename it, so parallax
+never reads a partial file. The file's existence is the readiness
+signal.
+
+```json
+{
+ "url": "http://127.0.0.1:54321",
+ "data": {"series": {"mixed_history": 7}}
+}
+```
+
+`url` is required. The app binds to port 0 and reports the port it got,
+which avoids races over picking a free port. `data` is optional and
+opaque to parallax; flows read it with `(parallax.app)`.
+
+Stdout was the alternative. A file keeps stdout free for logs and
+avoids scanning output for a marker line.
+
+## Config
+
+A config is a Fennel file that returns the result of calling the
+`parallax` module. The call validates the table, fills in defaults, and
+returns it; it doesn't run anything. The CLI owns execution, so it can
+inspect a config without starting servers, for example to list flows.
+
+```fennel
+(local parallax (require :parallax))
+
+(fn dashboard []
+ (parallax.goto "/")
+ (parallax.wait-for ".dashboard")
+ (parallax.snap :dashboard)
+ (parallax.click ".complete-btn")
+ (parallax.wait-for ".flash-bar")
+ (parallax.snap :dashboard-after-complete))
+
+(fn series-history []
+ (parallax.goto (.. "/series/" (. (parallax.app) :series :mixed_history)))
+ (parallax.wait-for ".task-history")
+ (parallax.snap :series-history {:selector ".main-column"}))
+
+(parallax
+ {:viewports [[:desktop 1280 900] [:mobile 375 812]]
+ :server {:cmd ["bundle" "exec" "rake" "snapshots:serve"]
+ :timeout 30}
+ :baseline (parallax.github.baseline {:repo "kejadlen/ketchup"})
+ :flows [[:dashboard dashboard]
+ [:series-history series-history]]})
+```
+
+Viewports and flows are sequences, because Lua tables don't preserve
+key order and both orders matter. The module is callable through a
+`__call` metamethod, which avoids writing `parallax.parallax`.
+
+A bad config fails inside the `parallax` call, so the error points at
+the user's file. If the file returns anything else, the CLI reports
+that the config must return `(parallax {...})`.
+
+`:name` is optional and names the project's cache directory (see
+[Directories](#directories)). It defaults to the name of the directory
+that contains the config file.
+
+`:baseline` is an optional function that fills a baseline directory. The
+CLI calls it once, before starting any viewport, with the path to the
+project's cache directory for baselines. The directory persists between
+runs, so the function can skip work when its contents are already
+current, the way ketchup skips the download when the cached release tag
+matches. The function returns the path of the baseline to use, which is
+usually the directory it was given, or `nil` when no baseline exists.
+Relative paths resolve against the config file's directory. Without a
+`:baseline` function, a run has no baseline.
+
+A baseline directory is the output directory of an earlier run, so
+archiving one run's output produces the next run's baseline. For
+baselines stored in GitHub releases, `parallax.github.baseline` builds
+the function (see [GitHub releases](#github-releases)). Any other
+source can be a hand-written function that uses `sh`.
+
+## Module API
+
+Flows are plain functions that take no arguments. Module functions act
+on the current viewport, whose page, base URL, and handshake data Rust
+holds.
+
+| Function | Behavior |
+|---|---|
+| `(goto path)` | Navigates to `path`, relative to the handshake URL |
+| `(wait-for selector [opts])` | Polls until `selector` matches and returns the element; fails after a timeout |
+| `(click selector)` | Waits for `selector` and clicks it |
+| `(snap name [opts])` | Screenshots the page, or the element matching `opts.selector` |
+| `(post path body)` | Sends `body` as JSON to `path`, returns the parsed response, fails on a non-2xx status |
+| `(sh argv)` | Runs a command, returns `{:status :stdout :stderr}`, fails on a nonzero exit |
+| `(app)` | Returns the handshake `data` |
+| `(viewport)` | Returns `{:name :width :height}` |
+
+Inside a flow, `sh` runs with `PARALLAX_URL` and `PARALLAX_VIEWPORT` in
+its environment. The baseline function runs before any viewport, so
+neither variable is set there. The set of interaction functions
+(typing, selecting, and evaluating JavaScript) will grow to cover what
+ketchup's flows need.
+
+`snap` has no block form. Ketchup's `snap` took a block only to scope
+the page-error check, and parallax scopes that check to the flow.
+Element screenshots keep ketchup's temporary padding, so the capture
+isn't flush against the element's edges.
+
+Parallax wraps each flow call to attach context to failures, for
+example: `flow series-history: wait-for ".task-history" timed out after
+5s`.
+
+## Output
+
+### Directories
+
+Parallax keeps each project's files under
+`$XDG_CACHE_HOME/parallax/<name>/`, falling back to `~/.cache` when
+`XDG_CACHE_HOME` is unset. The directory holds the following:
+
+| Path | Contents |
+|---|---|
+| `baseline/` | Given to the `:baseline` function, kept between runs |
+| `current/` | The output directory, cleared at the start of each run |
+
+A CLI flag overrides the output directory. The CLI prints the output
+directory's path when it finishes, so CI can archive it without
+knowing the cache layout. The site links to baseline images where they
+are, so it is meant for viewing on the machine that ran it; the gallery
+built from a run without a baseline is self-contained.
+
+### Files
+
+Parallax writes the following to the output directory:
+
+- `<viewport>/<name>.png` for each snapshot
+- `<viewport>/<name>.diff.png` for each changed snapshot, highlighting
+ the differing pixels
+- `report.json`, described below
+- A static site. Without a baseline it is a gallery; with one it shows
+ baseline, current, and diff images side by side, grouped by viewport,
+ with each snapshot's stats.
+
+### Comparison
+
+When a baseline exists, parallax matches snapshots by viewport and name
+and gives each one of the following statuses:
+
+| Status | Meaning |
+|---|---|
+| `unchanged` | Same dimensions and identical pixels |
+| `changed` | Different dimensions or at least one differing pixel |
+| `added` | Present in the current run only |
+| `removed` | Present in the baseline only |
+
+The status is exact: any differing pixel makes a snapshot `changed`.
+Parallax applies no tolerance itself. Instead, it reports how large the
+differences are, so consumers can ignore rendering noise at whatever
+level suits them.
+
+For each pixel, parallax computes a delta from 0 (identical) to 1
+(maximally different), using a perceptual color distance like
+[pixelmatch's][pixelmatch] YIQ metric. It also flags differing pixels
+that look like anti-aliasing, using pixelmatch's heuristic. When
+dimensions differ, parallax compares the overlapping area and counts the
+rest of the larger image as differing with a delta of 1.
+
+[pixelmatch]: https://github.com/mapbox/pixelmatch
+
+The diff image shades each differing pixel by its delta and marks
+anti-aliased pixels in a separate color.
+
+### Report
+
+`report.json` is the interface for consumers, so it carries a `version`
+field and changes to it are deliberate. It records the following:
+
+- Per snapshot: viewport, flow, name, URL path, selector, image paths,
+ and status
+- Per compared snapshot: the stats in the following table
+- A summary with the count of snapshots in each status
+
+| Stat | Meaning |
+|---|---|
+| `baseline_size`, `current_size` | Image dimensions |
+| `pixels` | Pixels compared, including the non-overlapping area |
+| `differing` | Pixels with a delta above 0 |
+| `antialiased` | Differing pixels flagged as anti-aliasing |
+| `max_delta` | Largest delta |
+| `mean_delta` | Mean delta across differing pixels |
+| `histogram` | Ten counts of differing pixels, bucketed by delta in steps of 0.1 |
+
+The histogram lets consumers pick a threshold after the fact, such as
+"at most 0.1% of pixels with a delta above 0.2," at a resolution of
+0.1.
+
+Parallax reads the baseline's `report.json` to find its snapshots.
+
+### Gates
+
+Parallax exits nonzero only when a run fails: the server doesn't start,
+a flow fails, or the config is invalid. Differences never change the
+exit code. Consumers decide what fails a build by reading
+`report.json`. For example, this gate ignores anti-aliasing and allows
+up to 0.1% of any snapshot's pixels to differ:
+
+```sh
+jq -e '.snapshots | all(
+ .stats == null or
+ (.stats.differing - .stats.antialiased) / .stats.pixels <= 0.001
+)' report.json
+```
+
+## GitHub releases
+
+Parallax stores snapshots in GitHub releases as a single asset: a
+gzipped tarball of an output directory, named `parallax.tar.gz` by
+default. Because the archive holds `report.json`, a downloaded archive
+is a valid baseline as soon as it is unpacked. The reader accepts
+entries with or without a leading `./`, which `tar -C <dir> .` adds.
+
+Parallax calls the GitHub REST API directly instead of shelling out to
+`gh`. It reads a token from `GH_TOKEN`, then `GITHUB_TOKEN`, then the
+output of `gh auth token`. Public repositories work without a token,
+subject to GitHub's lower anonymous rate limit.
+
+### Reading
+
+`(parallax.github.baseline opts)` returns a `:baseline` function. It
+takes the following options:
+
+| Option | Meaning |
+|---|---|
+| `:repo` | The repository as `owner/name`; required |
+| `:asset` | The asset name; defaults to `parallax.tar.gz` |
+
+The function finds the newest release that has the asset, skipping
+releases that don't. A release can exist before its snapshots are
+uploaded, and ketchup's CI creates releases that way. If the release
+tag matches the tag recorded in the baseline directory, the function
+reuses the cached files. Otherwise it downloads and unpacks the asset,
+then records the tag. If no release has the asset, the function
+returns `nil` and the run has no baseline.
+
+### Writing
+
+A GitHub Action in the parallax repository runs a config and attaches
+the output to an existing release. Parallax doesn't create releases;
+the consumer's workflow does. The action archives the output directory
+with `tar czf` and uploads it with `gh release upload --clobber`, which
+replaces an asset with the same name so reruns succeed. The parallax
+binary has no upload command. The action's inputs and how it installs
+parallax are not designed yet.
+
+## Changes to ketchup
+
+Ketchup needs the following to adopt parallax:
+
+- A `snapshots:serve` task that boots Puma on port 0 behind `DevAuth`,
+ seeds data, looks up IDs that flows need (replacing
+ `find_series_with_mixed_history`), and writes the handshake.
+- Fixture routes such as `/__fixtures/shared-series`, mounted in the
+ snapshot rackup outside `Web`. `Web` enforces `route_csrf` on POST,
+ and mounting the routes outside it keeps `post` a plain HTTP call with
+ no session or token handling.
+- A `parallax.fnl` config that ports the flows from
+ `Snapshots::Capture#run_capture`.
+- A CI job that uses the parallax action in place of the `snapshots`
+ job's capture and upload steps. Existing releases hold
+ `snapshots.tar.gz` archives without `report.json`, so the first run
+ after switching has no baseline.
+- Deterministic dates. The seed data and flows use `Date.today`, so
+ screenshots change every day.