docs: redesign the config around registration
Assisted-by: GLM-5.3 via pi
diff --git a/docs/plans/2026-09-25-parallax-design.md b/docs/plans/2026-09-25-parallax-design.md
index 99fe389..bfe8b90 100644
--- a/docs/plans/2026-09-25-parallax-design.md
+++ b/docs/plans/2026-09-25-parallax-design.md
@@ -89,14 +89,16 @@ 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.
+2. Call the registered 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 viewport.
-5. Run each flow in order. If a flow fails, stop the viewport, since
- later flows may depend on its state.
+5. Run each flow in order, passing the flow context for this viewport.
+ 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.
@@ -124,78 +126,80 @@ signal.
`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)`.
+opaque to parallax; flows read 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 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
+A config is a Fennel file that registers its pieces with the parallax
+module: `(setup {...})` with the static spec, `(server fn)` for the
+app under test, and one `(flow :name fn)` call per flow. Registration
+records without running 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
+(local {: setup : flow : server} (require :parallax))
+
+(setup
{: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]]})
+ :baseline {:repo "kejadlen/ketchup"}})
+
+(server
+ (fn []
+ {:cmd ["bundle" "exec" "rake" "snapshots:serve"]}))
+
+(flow :dashboard
+ (fn [{: goto : wait-for : click : snap}]
+ (goto "/")
+ (wait-for ".dashboard")
+ (snap :dashboard)
+ (click ".complete-btn")
+ (wait-for ".flash-bar")
+ (snap :dashboard-after-complete)))
+
+(flow :series-history
+ (fn [{: goto : wait-for : app : snap}]
+ (goto (.. "/series/" (. (app) :series :mixed_history)))
+ (wait-for ".task-history")
+ (snap :series-history {:selector ".main-column"})))
```
-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`.
+Registration calls can appear in any order. `setup` and `server` must
+each be called exactly once — a config that skips one gets an error
+saying so — and flows run in the order they were registered.
+Viewports are a sequence because Lua tables don't preserve key order.
-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 {...})`.
+The `setup` table and the table the `server` function returns are pure
+data, so both are validated where they're built and a bad table's
+error points into the config. Parallax calls the `server` function
+once per viewport, before starting that viewport'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` 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.
+`: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. 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`.
+archiving one run's output produces the next run's baseline.
## 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.
+The module exposes three registration functions: `setup`, `server`,
+and `flow`.
+
+Flows take one argument: the flow context, a table of command
+functions Rust builds for the current viewport, carrying its page,
+base URL, and handshake data. Flows destructure the commands they use.
-| Function | Behavior |
+| 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 |
@@ -207,10 +211,9 @@ holds.
| `(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.
+its environment. 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.
@@ -231,7 +234,7 @@ Parallax keeps each project's files under
| Path | Contents |
|---|---|
-| `baseline/` | Given to the `:baseline` function, kept between runs |
+| `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
@@ -337,21 +340,20 @@ subject to GitHub's lower anonymous rate limit.
### Reading
-`(parallax.github.baseline opts)` returns a `:baseline` function. It
-takes the following options:
+The `:baseline` field of `setup` names the source:
-| Option | Meaning |
+| Field | 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
+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, the function
+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 function
-returns `nil` and the run has no baseline.
+then records the tag. If no release has the asset, the run has no
+baseline.
### Writing
@@ -375,7 +377,7 @@ Ketchup needs the following to adopt parallax:
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`.
+ `Snapshots::Capture#run_capture`; flows take the flow context.
- 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