1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
# 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 a scripted
flow under several variants — each a viewport size paired with a
color scheme — 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 variant and receives startup data
  from it
- Runs one Fennel flow per variant that navigates, interacts, and
  captures screenshots; Fennel conditionals handle variant-specific
  behavior
- 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 variant. State resets only between
  variants.
- Running variants 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.

Variants 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
variant.

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 variants, 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, evaluates the config |
| 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 variant, in sorted name 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. Call the config's server function and spawn the returned 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 variant's viewport and
   emulating its color scheme, so `prefers-color-scheme` media queries
   drive theming.
5. Run the flow, passing the flow context for this variant. If the
   flow fails, stop the variant; the remaining variants still run,
   since each starts from a fresh server.
6. Send SIGTERM to the process group, wait a grace period, then send
   SIGKILL.

Parallax writes the server's stdout and stderr to
`<output>/<variant>/server.log`.

Each variant has its own server and port, so variants could run in
parallel later. That requires a separate Lua VM per variant, 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; the flow reads it with `(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 one table: the static spec,
the app under test's start command, and the flow. Loading a config
only evaluates it; the CLI owns execution, so it can inspect a config
without starting servers, for example to list variants.

```fennel
{:variants {:desktop-light {:viewport [1280 900] :scheme :light}
            :desktop-dark {:viewport [1280 900] :scheme :dark}
            :mobile-light {:viewport [375 812] :scheme :light}
            :mobile-dark {:viewport [375 812] :scheme :dark}}
 :baseline {:repo "kejadlen/ketchup"}
 :server (fn []
   {:cmd ["bundle" "exec" "rake" "snapshots:serve"]})
 :flow (fn [{: goto : wait-for : click : snap : app : variant}]
   (goto "/")
   (wait-for ".dashboard")
   (snap :dashboard)
   (click ".complete-btn")
   (wait-for ".flash-bar")
   (snap :dashboard-after-complete)
   (goto (.. "/series/" (. (app) :series :mixed_history)))
   (wait-for ".task-history")
   (snap :series-history {:selector ".main-column"})
   (when (= (. variant :scheme) :dark)
     (click ".theme-toggle")
     (snap :series-history-dark {:selector ".main-column"})))}
```

Parallax validates the returned table after the config finishes
running, so validation errors — an unknown field, a non-function
`:server`, no variants — name the offending key path, such as
`variants.desktop.shceme`, rather than a line. Syntax and runtime
errors inside the config still report its line numbers.

A variant names a browser emulation: a viewport size paired with a
color scheme. The variant's name is its key in the `:variants` table,
and `:scheme` is `:light` or `:dark`, defaulting to `:light`. Lua
tables don't preserve key order, and no variant depends on another's
state — each gets a fresh server — so variants run in sorted name
order, and the report and site inherit that grouping.

The `:server` and `:flow` functions are taken out of the table
before the rest deserializes, so everything else is pure data,
validated where it's built. Parallax calls the `:server` function
once per variant, before starting that variant's server, and spawns
the returned `:cmd` in its own process group. `:timeout` bounds the
handshake wait and defaults to 30 seconds.

`: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` names where comparison snapshots come from, as data
rather than a function. It is absent by default, meaning a run has no
baseline. Only GitHub releases are supported for now (see
[GitHub releases](#github-releases)); other sources wait for a need.

A baseline directory is the output directory of an earlier run, so
archiving one run's output produces the next run's baseline.

## Flow API

The flow takes one argument: the flow context, a table of command
functions Rust builds for the current variant, carrying its page, base
URL, and handshake data. The context also carries the variant itself
under `:variant` — `{:name :desktop-dark :width 1280 :height 900
:scheme :dark}` — so Fennel conditionals handle variant-specific
behavior. The flow destructures the commands it uses.

| Command | 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` |

Inside the flow, `sh` runs with `PARALLAX_URL` and `PARALLAX_VARIANT`
in its environment. The set of interaction functions (typing,
selecting, and evaluating JavaScript) will grow to cover what
ketchup's flow needs.

`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 the flow call in each variant to attach context to
failures, for example: `variant desktop-dark: 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/` | Cached baseline contents, 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:

- `<variant>/<name>.png` for each snapshot
- `<variant>/<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 variant,
  with each snapshot's stats.

### Comparison

When a baseline exists, parallax matches snapshots by variant 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: variant, 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

The `:baseline` field of the config table names the source:

| Field | Meaning |
|---|---|
| `:repo` | The repository as `owner/name`; required |
| `:asset` | The asset name; defaults to `parallax.tar.gz` |

Parallax 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, parallax
reuses the cached files. Otherwise it downloads and unpacks the asset,
then records the tag. If no release has the asset, 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 the flow needs (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 capture steps from
  `Snapshots::Capture#run_capture` into the single flow, branching on
  the variant for dark-mode snapshots.
- 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.