config: replace named flows and viewports with variants
Named flows had nothing left to orchestrate once the flow can branch
on its injected variant; a variant pairs a viewport with a color
scheme, which is the whole matrix ketchup needs.

Assisted-by: GLM-5.3 via pi
change wponxpkxqqltyypsosyrwnywkmwkmsxz
commit b0166afbd344d87ee429bbf434b0c5ef5c286dc9
author Alpha Chen <alpha@kejadlen.dev>
date
parent qmlypxun
diff --git a/docs/plans/2026-09-25-parallax-design.md b/docs/plans/2026-09-25-parallax-design.md
index bfe8b90..9da077e 100644
--- a/docs/plans/2026-09-25-parallax-design.md
+++ b/docs/plans/2026-09-25-parallax-design.md
@@ -1,9 +1,10 @@
 # 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.
+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
@@ -16,9 +17,11 @@ reference for what parallax must support.
 
 Parallax does the following:
 
-- Starts the app under test once per viewport and receives startup data
+- Starts the app under test once per variant and receives startup data
   from it
-- Runs Fennel flows that navigate, interact, and capture screenshots
+- 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
@@ -36,9 +39,9 @@ The following are out of scope for now:
   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
+- 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
@@ -46,11 +49,11 @@ The following are out of scope for now:
 The ketchup implementation has three problems that parallax fixes by
 design.
 
-Viewports share state. Ketchup starts one server, seeds it once, and
+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
-viewport.
+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
@@ -67,7 +70,7 @@ Parallax has the following parts:
 
 | Component | Responsibility |
 |---|---|
-| CLI | Loads the config, runs viewports, writes output, sets the exit code |
+| 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, exposes the `parallax` module |
@@ -85,7 +88,7 @@ and not yet chosen.
 
 ## Lifecycle
 
-For each viewport, in config order, parallax does the following:
+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.
@@ -95,18 +98,20 @@ For each viewport, in config order, parallax does the following:
 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, passing the flow context for this viewport.
-   If a flow fails, stop the viewport, since later flows may depend on
-   its state.
+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>/<viewport>/server.log`.
+`<output>/<variant>/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
+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.
 
@@ -126,7 +131,7 @@ 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 `(app)`.
+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.
@@ -135,46 +140,56 @@ avoids scanning output for a marker line.
 
 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
+app under test, and `(flow fn)` for the single flow. Registration
 records without running anything; the CLI owns execution, so it can
-inspect a config without starting servers, for example to list flows.
+inspect a config without starting servers, for example to list
+variants.
 
 ```fennel
 (local {: setup : flow : server} (require :parallax))
 
 (setup
-  {:viewports [[:desktop 1280 900] [:mobile 375 812]]
+  {: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 :dashboard
-  (fn [{: goto : wait-for : click : snap}]
+(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)))
-
-(flow :series-history
-  (fn [{: goto : wait-for : app : snap}]
+    (snap :dashboard-after-complete)
     (goto (.. "/series/" (. (app) :series :mixed_history)))
     (wait-for ".task-history")
-    (snap :series-history {:selector ".main-column"})))
+    (snap :series-history {:selector ".main-column"})
+    (when (= (. variant :scheme) :dark)
+      (click ".theme-toggle")
+      (snap :series-history-dark {:selector ".main-column"}))))
 ```
 
-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.
+Registration calls can appear in any order. `setup`, `server`, and
+`flow` must each be called exactly once — a config that skips one
+gets an error saying so.
+
+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 `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
+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.
 
@@ -195,9 +210,12 @@ archiving one run's output produces the next run's baseline.
 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.
+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 |
 |---|---|
@@ -208,21 +226,20 @@ base URL, and handshake data. Flows destructure the commands they use.
 | `(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 set of interaction functions (typing, selecting,
-and evaluating JavaScript) will grow to cover what ketchup's flows
-need.
+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 each flow call to attach context to failures, for
-example: `flow series-history: wait-for ".task-history" timed out after
-5s`.
+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
 
@@ -247,17 +264,17 @@ built from a run without a baseline is self-contained.
 
 Parallax writes the following to the output directory:
 
-- `<viewport>/<name>.png` for each snapshot
-- `<viewport>/<name>.diff.png` for each changed snapshot, highlighting
+- `<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 viewport,
+  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 viewport and name
+When a baseline exists, parallax matches snapshots by variant and name
 and gives each one of the following statuses:
 
 | Status | Meaning |
@@ -289,8 +306,8 @@ anti-aliased pixels in a separate color.
 `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 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
 
@@ -370,14 +387,15 @@ parallax are not designed yet.
 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
+  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 flows from
-  `Snapshots::Capture#run_capture`; flows take the flow context.
+- 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
diff --git a/src/bin/parallax/main.rs b/src/bin/parallax/main.rs
index a81e54b..f3f49e4 100644
--- a/src/bin/parallax/main.rs
+++ b/src/bin/parallax/main.rs
@@ -18,7 +18,7 @@ const VERSION: &str = env!("PARALLAX_VERSION");
 #[command(
     name = "parallax",
     version = VERSION,
-    about = "Run scripted flows against a web app and compare screenshots against a baseline"
+    about = "Run a scripted flow against a web app and compare screenshots against a baseline"
 )]
 struct Cli {
     /// Path to the config file
@@ -35,14 +35,14 @@ struct Cli {
 
 #[derive(Subcommand)]
 enum Commands {
-    /// Run the parallax's flows
+    /// Run the flow in every variant
     Run {
         /// Override the output directory
         #[arg(long)]
         output: Option<Utf8PathBuf>,
     },
 
-    /// Print the parallax's flow names in order
+    /// Print the variant names in run order
     List,
 }
 
@@ -71,8 +71,8 @@ async fn main() -> miette::Result<()> {
     match command {
         Commands::Run { output } => parallax.run(output.as_deref()).await?,
         Commands::List => {
-            for flow in parallax.flows() {
-                println!("{}", flow.name);
+            for variant in parallax.variants() {
+                println!("{}", variant.name);
             }
         }
     }
diff --git a/src/error.rs b/src/error.rs
index e4c9748..c4378a6 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -14,7 +14,7 @@ pub enum Error {
     #[error("{} must call ({what} ...)", .path)]
     #[diagnostic(
         code(parallax::config_registration),
-        help("a config registers with (setup {{...}}), (server fn), and (flow :name fn)")
+        help("a config registers with (setup {{...}}), (server fn), and (flow fn)")
     )]
     ConfigRegistration {
         path: Utf8PathBuf,
@@ -25,10 +25,12 @@ pub enum Error {
     #[diagnostic(code(parallax::io))]
     Io(#[from] std::io::Error),
 
-    #[error("running configs is not implemented yet")]
+    #[error("running variants is not implemented yet")]
     #[diagnostic(
         code(parallax::not_implemented),
-        help("loading and listing configs works; running flows is the next task")
+        help(
+            "loading and listing configs works; running the flow in every variant is the next task"
+        )
     )]
     NotImplemented,
 }
diff --git a/src/parallax.rs b/src/parallax.rs
index 828b796..b71b355 100644
--- a/src/parallax.rs
+++ b/src/parallax.rs
@@ -12,9 +12,8 @@ use mlua::UserData;
 use mlua::Value;
 
 use crate::Error;
-use crate::spec::Flow;
-use crate::spec::NonEmptyString;
 use crate::spec::Setup;
+use crate::spec::Variant;
 
 const FENNEL_LUA: &str = include_str!("../vendor/fennel.lua");
 
@@ -26,7 +25,7 @@ const FENNEL_LUA: &str = include_str!("../vendor/fennel.lua");
 struct Registrations {
     setup: Option<Setup>,
     server: Option<Function>,
-    flows: Vec<Flow>,
+    flow: Option<Function>,
 }
 
 impl Registrations {
@@ -40,8 +39,8 @@ impl Registrations {
         let setup: Setup = lua
             .from_value(Value::Table(table))
             .map_err(|error| error_at(lua, &error_message(&error)))?;
-        if setup.viewports.is_empty() {
-            return Err(error_at(lua, "viewports must not be empty"));
+        if setup.variants.is_empty() {
+            return Err(error_at(lua, "variants must not be empty"));
         }
         self.setup = Some(setup);
         Ok(())
@@ -58,22 +57,14 @@ impl Registrations {
         Ok(())
     }
 
-    fn flow(&mut self, lua: &Lua, name: Value, call: Value) -> Result<(), mlua::Error> {
-        let name = match name {
-            Value::String(name) => match name.to_str() {
-                Ok(name) => NonEmptyString::new(name.to_owned()),
-                Err(_) => None,
-            },
-            _ => None,
-        };
-        let call = match call {
-            Value::Function(call) => Some(call),
-            _ => None,
-        };
-        let (Some(name), Some(call)) = (name, call) else {
-            return Err(error_at(lua, "flow takes a non-empty name and a function"));
+    fn flow(&mut self, lua: &Lua, call: Value) -> Result<(), mlua::Error> {
+        let Value::Function(call) = call else {
+            return Err(error_at(lua, "flow takes a function"));
         };
-        self.flows.push(Flow { name, call });
+        if self.flow.is_some() {
+            return Err(error_at(lua, "flow must be called exactly once"));
+        }
+        self.flow = Some(call);
         Ok(())
     }
 }
@@ -84,17 +75,16 @@ impl Registrations {
 impl UserData for Registrations {}
 
 /// A loaded parallax: the executable form of a config. The server
-/// function and flows are owned handles into the embedded Lua state,
+/// function and flow are owned handles into the embedded Lua state,
 /// which stays alive in this struct; fields are ordered so the handles
 /// drop while the VM lives.
 pub struct Parallax {
-    flows: Vec<Flow>,
-    // Consumed once running configs lands: the server function is
-    // called once per viewport, and the setup drives viewport loops
-    // and the baseline download.
+    // Consumed once running variants lands.
     #[allow(dead_code)]
-    server: Function,
+    flow: Function,
+    // Consumed once running variants lands.
     #[allow(dead_code)]
+    server: Function,
     setup: Setup,
     name: String,
     #[allow(dead_code)]
@@ -115,7 +105,7 @@ impl Parallax {
         let Registrations {
             setup,
             server,
-            flows,
+            flow,
         } = run_config(&lua, &source, &filename).with_context(|_| format!("loading {path}"))?;
 
         let setup = setup.ok_or_else(|| Error::ConfigRegistration {
@@ -126,10 +116,14 @@ impl Parallax {
             path: path.to_owned(),
             what: "server",
         })?;
+        let flow = flow.ok_or_else(|| Error::ConfigRegistration {
+            path: path.to_owned(),
+            what: "flow",
+        })?;
         let name = setup.name.clone().unwrap_or_else(|| default_name(path));
 
         Ok(Parallax {
-            flows,
+            flow,
             server,
             setup,
             name,
@@ -138,15 +132,16 @@ impl Parallax {
         })
     }
 
-    pub fn flows(&self) -> &[Flow] {
-        &self.flows
+    /// The variants in run order: sorted by name.
+    pub fn variants(&self) -> &[Variant] {
+        &self.setup.variants
     }
 
     pub fn name(&self) -> &str {
         &self.name
     }
 
-    /// Runs every flow in every viewport.
+    /// Runs the flow in every variant.
     pub async fn run(&self, _output: Option<&Utf8Path>) -> Result<(), Error> {
         Err(Error::NotImplemented)
     }
@@ -182,8 +177,8 @@ fn install_module(lua: &Lua) -> Result<AnyUserData, mlua::Error> {
     };
     let flow = {
         let module = module.clone();
-        lua.create_function(move |lua, (name, call): (Value, Value)| {
-            module.borrow_mut::<Registrations>()?.flow(lua, name, call)
+        lua.create_function(move |lua, call: Value| {
+            module.borrow_mut::<Registrations>()?.flow(lua, call)
         })?
     };
 
diff --git a/src/spec.rs b/src/spec.rs
index 62a0ec4..7716ead 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -1,9 +1,9 @@
 #![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
 
+use std::collections::HashMap;
 use std::fmt;
 use std::num::NonZeroU32;
 
-use mlua::Function;
 use serde::Deserialize;
 
 /// A string that rejected its own emptiness at deserialization time.
@@ -33,30 +33,90 @@ impl<'de> Deserialize<'de> for NonEmptyString {
     }
 }
 
-/// A flow: its name from the config and the Lua function to call when
-/// the parallax runs.
+/// A color scheme parallax emulates for a variant.
+#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "lowercase")]
+pub enum Scheme {
+    #[default]
+    Light,
+    Dark,
+}
+
+/// A browser emulation the flow runs under: a named viewport size
+/// paired with a color scheme.
 #[derive(Debug)]
-pub struct Flow {
+pub struct Variant {
     pub name: NonEmptyString,
-    pub call: Function,
+    pub width: NonZeroU32,
+    pub height: NonZeroU32,
+    pub scheme: Scheme,
 }
 
-/// The table passed to `(setup {...})`. Pure data — a function
-/// anywhere in it is an error, which is why it deserializes with
-/// mlua's default strict options.
-#[derive(Debug, Deserialize)]
-#[serde(deny_unknown_fields)]
-#[allow(dead_code)] // baseline is read once the GitHub task lands
+/// The table passed to `(setup {...})`, with variants flattened into
+/// run order. Pure data — a function anywhere in it is an error, which
+/// is why it deserializes with mlua's default strict options.
+#[derive(Debug)]
+#[allow(dead_code)] // baseline and name are read once running lands
 pub(crate) struct Setup {
-    pub(crate) viewports: Vec<(NonEmptyString, NonZeroU32, NonZeroU32)>,
+    pub(crate) variants: Vec<Variant>,
 
-    #[serde(default)]
     pub(crate) baseline: Option<BaselineSource>,
 
-    #[serde(default)]
     pub(crate) name: Option<String>,
 }
 
+/// What `(setup {...})` deserializes from: a table keyed by variant
+/// name, so the names are data rather than struct fields.
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct SetupTable {
+    variants: HashMap<String, VariantTable>,
+
+    #[serde(default)]
+    baseline: Option<BaselineSource>,
+
+    #[serde(default)]
+    name: Option<String>,
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct VariantTable {
+    viewport: (NonZeroU32, NonZeroU32),
+
+    #[serde(default)]
+    scheme: Scheme,
+}
+
+impl<'de> Deserialize<'de> for Setup {
+    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+        let SetupTable {
+            variants,
+            baseline,
+            name,
+        } = SetupTable::deserialize(deserializer)?;
+        let mut parsed = Vec::with_capacity(variants.len());
+        for (key, variant) in &variants {
+            let name = NonEmptyString::new(key.clone())
+                .ok_or_else(|| serde::de::Error::custom("empty variant name"))?;
+            parsed.push(Variant {
+                name,
+                width: variant.viewport.0,
+                height: variant.viewport.1,
+                scheme: variant.scheme,
+            });
+        }
+        // Lua tables don't preserve key order, and no variant depends
+        // on another's state, so sorted name order is the run order.
+        parsed.sort_unstable_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
+        Ok(Setup {
+            variants: parsed,
+            baseline,
+            name,
+        })
+    }
+}
+
 /// Where comparison snapshots come from; see the design doc's GitHub
 /// releases section.
 #[derive(Debug, Deserialize)]
@@ -70,7 +130,7 @@ pub(crate) struct BaselineSource {
 }
 
 /// The table the registered server function returns, once per
-/// viewport. Validated when the function is called — a future task.
+/// variant. Validated when the function is called — a future task.
 #[derive(Debug, Deserialize)]
 #[serde(deny_unknown_fields)]
 #[allow(dead_code)] // constructed when running configs lands
@@ -103,11 +163,20 @@ mod tests {
     #[test]
     fn parses_a_setup_table() {
         with_value(
-            r#"return {viewports = {{"desktop", 1280, 900}}, baseline = {repo = "kejadlen/ketchup"}}"#,
+            r#"return {
+                variants = {
+                    mobile = {viewport = {375, 812}},
+                    desktop = {viewport = {1280, 900}, scheme = "dark"},
+                },
+                baseline = {repo = "kejadlen/ketchup"},
+            }"#,
             |lua, value| {
                 let setup: Setup = lua.from_value(value).expect("setup table");
-                assert_eq!(setup.viewports.len(), 1);
-                assert_eq!(setup.viewports[0].0.as_str(), "desktop");
+                assert_eq!(setup.variants.len(), 2);
+                assert_eq!(setup.variants[0].name.as_str(), "desktop");
+                assert_eq!(setup.variants[0].scheme, Scheme::Dark);
+                assert_eq!(setup.variants[1].name.as_str(), "mobile");
+                assert_eq!(setup.variants[1].scheme, Scheme::Light);
                 assert_eq!(
                     setup.baseline.as_ref().expect("baseline").repo.as_str(),
                     "kejadlen/ketchup"
@@ -120,18 +189,30 @@ mod tests {
     #[test]
     fn rejects_setup_shapes() {
         let cases = [
-            (r#"return {baseline = nil}"#, "missing field `viewports`"),
+            (r#"return {baseline = nil}"#, "missing field `variants`"),
+            (r#"return {variants = {}, extra = 1}"#, "unknown field"),
             (
-                r#"return {viewports = {{"desktop", 1280, 900}}, extra = 1}"#,
+                r#"return {variants = {desktop = {viewport = {1280, 900}, extra = 1}}}"#,
                 "unknown field",
             ),
-            (r#"return {viewports = {{"", 1280, 900}}}"#, "empty string"),
             (
-                r#"return {viewports = {{"desktop", 0, 900}}}"#,
+                r#"return {variants = {[""] = {viewport = {1280, 900}}}}"#,
+                "empty variant name",
+            ),
+            (
+                r#"return {variants = {desktop = {viewport = {0, 900}}}}"#,
                 "invalid value",
             ),
             (
-                r#"return {viewports = {{"desktop", 1280, 900}}, baseline = {asset = "x.tar.gz"}}"#,
+                r#"return {variants = {desktop = {scheme = "dark"}}}"#,
+                "missing field `viewport`",
+            ),
+            (
+                r#"return {variants = {desktop = {viewport = {1280, 900}, scheme = "sepia"}}}"#,
+                "unknown variant",
+            ),
+            (
+                r#"return {variants = {desktop = {viewport = {1280, 900}}}, baseline = {asset = "x.tar.gz"}}"#,
                 "missing field `repo`",
             ),
         ];
@@ -151,7 +232,7 @@ mod tests {
     #[test]
     fn rejects_functions_in_setup() {
         with_value(
-            r#"return {viewports = {{"desktop", 1280, 900}}, name = (function() end)}"#,
+            r#"return {variants = {desktop = {viewport = {1280, 900}}}, name = (function() end)}"#,
             |lua, value| {
                 let error = lua
                     .from_value::<Setup>(value)
diff --git a/tests/cli.rs b/tests/cli.rs
index fb7f3cc..ade03eb 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -9,18 +9,17 @@ use tempfile::TempDir;
 const VALID: &str = r#"(local {: setup : flow : server} (require :parallax))
 
 (setup
-  {:viewports [[:desktop 1280 900] [:mobile 375 812]]
+  {:variants {:mobile-dark {:viewport [375 812] :scheme :dark}
+              :desktop-light {:viewport [1280 900]}
+              :desktop-dark {:viewport [1280 900] :scheme :dark}}
    :baseline {:repo "kejadlen/ketchup"}})
 
 (server
   (fn []
     {:cmd ["bundle" "exec" "rake" "snapshots:serve"]}))
 
-(flow :dashboard
-  (fn [{: goto : wait-for : click : snap}] nil))
-
-(flow :series-history
-  (fn [{: goto : wait-for : app : snap}] nil))"#;
+(flow
+  (fn [{: goto : wait-for : click : snap : variant}] nil))"#;
 
 fn config_file(source: &str) -> (TempDir, PathBuf) {
     let dir = tempfile::tempdir().expect("tempdir");
@@ -30,7 +29,7 @@ fn config_file(source: &str) -> (TempDir, PathBuf) {
 }
 
 #[test]
-fn lists_flow_names_in_order() {
+fn lists_variant_names_in_run_order() {
     let (_dir, path) = config_file(VALID);
     cargo_bin_cmd!("parallax")
         .arg("list")
@@ -38,7 +37,7 @@ fn lists_flow_names_in_order() {
         .arg(&path)
         .assert()
         .success()
-        .stdout("dashboard\nseries-history\n");
+        .stdout("desktop-dark\ndesktop-light\nmobile-dark\n");
 }
 
 #[test]
@@ -50,7 +49,7 @@ fn list_defaults_to_parallax_fnl_in_the_current_directory() {
         .current_dir(dir.path())
         .assert()
         .success()
-        .stdout("dashboard\nseries-history\n");
+        .stdout("desktop-dark\ndesktop-light\nmobile-dark\n");
 }
 
 #[test]
@@ -83,10 +82,10 @@ fn setup_errors_point_at_the_setup_call() {
     let (_dir, path) = config_file(concat!(
         "(local {: setup : flow : server} (require :parallax))\n",
         "\n",
-        "(setup {:viewports []})\n",
+        "(setup {:variants {}})\n",
         "\n",
         "(server (fn [] {:cmd [\"true\"]}))\n",
-        "(flow :flow (fn [] nil))\n",
+        "(flow (fn [] nil))\n",
     ));
     cargo_bin_cmd!("parallax")
         .arg("list")
@@ -96,7 +95,7 @@ fn setup_errors_point_at_the_setup_call() {
         .failure()
         .stderr(
             predicate::str::contains("config.fnl:3:")
-                .and(predicate::str::contains("viewports must not be empty")),
+                .and(predicate::str::contains("variants must not be empty")),
         );
 }
 
@@ -105,7 +104,7 @@ fn missing_setup_registration_fails() {
     let (_dir, path) = config_file(concat!(
         "(local {: setup : flow : server} (require :parallax))\n",
         "(server (fn [] {:cmd [\"true\"]}))\n",
-        "(flow :flow (fn [] nil))\n",
+        "(flow (fn [] nil))\n",
     ));
     cargo_bin_cmd!("parallax")
         .arg("list")
@@ -120,8 +119,8 @@ fn missing_setup_registration_fails() {
 fn missing_server_registration_fails() {
     let (_dir, path) = config_file(concat!(
         "(local {: setup : flow : server} (require :parallax))\n",
-        "(setup {:viewports [[:desktop 1280 900]]})\n",
-        "(flow :flow (fn [] nil))\n",
+        "(setup {:variants {:desktop {:viewport [1280 900]}}})\n",
+        "(flow (fn [] nil))\n",
     ));
     cargo_bin_cmd!("parallax")
         .arg("list")
@@ -132,12 +131,28 @@ fn missing_server_registration_fails() {
         .stderr(predicate::str::contains("must call (server ...)"));
 }
 
+#[test]
+fn missing_flow_registration_fails() {
+    let (_dir, path) = config_file(concat!(
+        "(local {: setup : flow : server} (require :parallax))\n",
+        "(setup {:variants {:desktop {:viewport [1280 900]}}})\n",
+        "(server (fn [] {:cmd [\"true\"]}))\n",
+    ));
+    cargo_bin_cmd!("parallax")
+        .arg("list")
+        .arg("--config")
+        .arg(&path)
+        .assert()
+        .failure()
+        .stderr(predicate::str::contains("must call (flow ...)"));
+}
+
 #[test]
 fn duplicate_setup_registration_fails() {
     let (_dir, path) = config_file(concat!(
         "(local {: setup : flow : server} (require :parallax))\n",
-        "(setup {:viewports [[:desktop 1280 900]]})\n",
-        "(setup {:viewports [[:mobile 375 812]]})\n",
+        "(setup {:variants {:desktop {:viewport [1280 900]}}})\n",
+        "(setup {:variants {:mobile {:viewport [375 812]}}})\n",
         "(server (fn [] {:cmd [\"true\"]}))\n",
     ));
     cargo_bin_cmd!("parallax")
@@ -151,13 +166,33 @@ fn duplicate_setup_registration_fails() {
         );
 }
 
+#[test]
+fn duplicate_flow_registration_fails() {
+    let (_dir, path) = config_file(concat!(
+        "(local {: setup : flow : server} (require :parallax))\n",
+        "(setup {:variants {:desktop {:viewport [1280 900]}}})\n",
+        "(server (fn [] {:cmd [\"true\"]}))\n",
+        "(flow (fn [] nil))\n",
+        "(flow (fn [] nil))\n",
+    ));
+    cargo_bin_cmd!("parallax")
+        .arg("list")
+        .arg("--config")
+        .arg(&path)
+        .assert()
+        .failure()
+        .stderr(
+            predicate::str::contains("config.fnl:5:").and(predicate::str::contains("exactly")),
+        );
+}
+
 #[test]
 fn malformed_flow_registration_fails() {
     let (_dir, path) = config_file(concat!(
         "(local {: setup : flow : server} (require :parallax))\n",
-        "(setup {:viewports [[:desktop 1280 900]]})\n",
+        "(setup {:variants {:desktop {:viewport [1280 900]}}})\n",
         "(server (fn [] {:cmd [\"true\"]}))\n",
-        "(flow :flow \"not-a-function\")\n",
+        "(flow \"not-a-function\")\n",
     ));
     cargo_bin_cmd!("parallax")
         .arg("list")