diff --git a/README.md b/README.md
index 720b22e37..2b7c0c9df 100644
--- a/README.md
+++ b/README.md
@@ -209,8 +209,8 @@ iframe at it.
More about method 2 — mobile app
[Capacitor](https://capacitorjs.com) wraps the built maps into an Android app — the same files,
-no new code. Shown here: the UPG-100 prayer lists and posters, one poster per row, with print
-options that fit a phone.
+no new code. Shown here: the UPG-100 prayer lists and posters at phone size, each poster with its
+own print options.
```bash
bun run check:mobile # checks the app is wired up correctly — needs no Android tools
diff --git a/documentation/readme-images/cap1-single-element.png b/documentation/readme-images/cap1-single-element.png
index 372c4d3dc..1651fc8fe 100644
Binary files a/documentation/readme-images/cap1-single-element.png and b/documentation/readme-images/cap1-single-element.png differ
diff --git a/documentation/readme-images/cap2-adoption.png b/documentation/readme-images/cap2-adoption.png
index 8d354a5e7..a8e994b07 100644
Binary files a/documentation/readme-images/cap2-adoption.png and b/documentation/readme-images/cap2-adoption.png differ
diff --git a/documentation/readme-images/cap2-engagement.png b/documentation/readme-images/cap2-engagement.png
index 52f8ef714..2d7047d1b 100644
Binary files a/documentation/readme-images/cap2-engagement.png and b/documentation/readme-images/cap2-engagement.png differ
diff --git a/documentation/readme-images/cap2-prayer.png b/documentation/readme-images/cap2-prayer.png
index 9e0d23f5b..4b471d9b4 100644
Binary files a/documentation/readme-images/cap2-prayer.png and b/documentation/readme-images/cap2-prayer.png differ
diff --git a/documentation/readme-images/cap3-monolithic-research.png b/documentation/readme-images/cap3-monolithic-research.png
index b5fabd338..9325d5897 100644
Binary files a/documentation/readme-images/cap3-monolithic-research.png and b/documentation/readme-images/cap3-monolithic-research.png differ
diff --git a/documentation/readme-images/cap4-upg100-list.png b/documentation/readme-images/cap4-upg100-list.png
index 8809750b9..6ee94a503 100644
Binary files a/documentation/readme-images/cap4-upg100-list.png and b/documentation/readme-images/cap4-upg100-list.png differ
diff --git a/documentation/readme-images/cap4-upg100-mfe.png b/documentation/readme-images/cap4-upg100-mfe.png
index 92b896372..35afdc1a9 100644
Binary files a/documentation/readme-images/cap4-upg100-mfe.png and b/documentation/readme-images/cap4-upg100-mfe.png differ
diff --git a/documentation/readme-images/cap4-upg100-poster-wall.png b/documentation/readme-images/cap4-upg100-poster-wall.png
index 6ead9074e..b740da851 100644
Binary files a/documentation/readme-images/cap4-upg100-poster-wall.png and b/documentation/readme-images/cap4-upg100-poster-wall.png differ
diff --git a/documentation/readme-images/deploy2-mobile-upg100.png b/documentation/readme-images/deploy2-mobile-upg100.png
index d4914890a..e24bbaba4 100644
Binary files a/documentation/readme-images/deploy2-mobile-upg100.png and b/documentation/readme-images/deploy2-mobile-upg100.png differ
diff --git a/documentation/readme-images/shot-bundles.json b/documentation/readme-images/shot-bundles.json
index 538a39720..32aa1039e 100644
--- a/documentation/readme-images/shot-bundles.json
+++ b/documentation/readme-images/shot-bundles.json
@@ -1,35 +1,35 @@
{
"cap1-single-element": {
"bundle": "all-people-groups",
- "sha256": "db9e293144928477bc7fc1ef50f469db073d81575b29ba394b50d33273c6e1a5"
+ "sha256": "312b3a0f64b0842b498ae8c0c88491a9225c353a60c0420e05ac18fa0333a0a5"
},
"cap2-adoption": {
"bundle": "doxa-simple-map",
- "sha256": "27f32e7a2168391286d190ecfdb9445fb1a610700f1250d0769c50cbd422d778"
+ "sha256": "95ab02edf30ab19b1637238bb50f580286daa2808fbcb43478dbb0c3d0115d95"
},
"cap2-engagement": {
"bundle": "doxa-simple-map",
- "sha256": "27f32e7a2168391286d190ecfdb9445fb1a610700f1250d0769c50cbd422d778"
+ "sha256": "95ab02edf30ab19b1637238bb50f580286daa2808fbcb43478dbb0c3d0115d95"
},
"cap2-prayer": {
"bundle": "doxa-simple-map",
- "sha256": "27f32e7a2168391286d190ecfdb9445fb1a610700f1250d0769c50cbd422d778"
+ "sha256": "95ab02edf30ab19b1637238bb50f580286daa2808fbcb43478dbb0c3d0115d95"
},
"cap3-monolithic-research": {
"bundle": "doxa-research-map",
- "sha256": "56845dc5bd33198a7dd8fb42895700f21b4bc1c3b7f98f57832e8373c7782a83"
+ "sha256": "7e3637a635faf8223488a4bc21c1f308c5b3ea94fa6c80d5f1560a04ef7ec9cb"
},
"cap4-upg100-list": {
"bundle": "my-upg-100-list",
- "sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7"
+ "sha256": "57dc358c76f736f5c1b01afe57b3f722e08a1620143ca7f72b2d83133b43f86d"
},
"cap4-upg100-mfe": {
"bundle": "my-upg-100-list",
- "sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7"
+ "sha256": "57dc358c76f736f5c1b01afe57b3f722e08a1620143ca7f72b2d83133b43f86d"
},
"cap4-upg100-poster-wall": {
"bundle": "my-upg-100-list",
- "sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7"
+ "sha256": "57dc358c76f736f5c1b01afe57b3f722e08a1620143ca7f72b2d83133b43f86d"
},
"cap5-doxa-maps-spa": {
"bundle": "doxa-maps-spa",
@@ -41,6 +41,6 @@
},
"deploy2-mobile-upg100": {
"bundle": "my-upg-100-list",
- "sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7"
+ "sha256": "57dc358c76f736f5c1b01afe57b3f722e08a1620143ca7f72b2d83133b43f86d"
}
}
diff --git a/internal/browser-slot.sh b/internal/browser-slot.sh
new file mode 100755
index 000000000..217658959
--- /dev/null
+++ b/internal/browser-slot.sh
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+# browser-slot.sh — run ONE browser command inside a memory-floored, N-slot rendezvous.
+#
+# WHY A SEMAPHORE AND NOT A LOCK. A single lock made every agent's browser proof wait behind one
+# chrome: measured 2026-09-25, the queue sat 10-19 deep for hours. The real constraint is MEMORY,
+# not exclusivity — a headless chrome costs ~300-500MB — so the right shape is "as many browsers as
+# the box can hold", which is a counting semaphore with a memory floor, not a mutex.
+#
+# HOW: N slot files. Try each with flock -n (no wait). If all are busy, WAIT on slot 1. Before
+# taking any slot, require MemAvailable above the floor — a slot that is free while the box is out
+# of memory is an invitation to be OOM-killed mid-measurement, which reads as a flaky gate.
+#
+# ★ EXIT 75 MEANS "NOT MEASURED", NEVER "FAILED". flock returns 1 on timeout, the same code a
+# failing gate uses, and that once turned a busy queue into five red product gates. 75 is
+# EX_TEMPFAIL: distinct from 0 and from any gate's own verdict, so a caller can report it as the
+# precondition it is.
+#
+# Usage: internal/browser-slot.sh [args...]
+# Env: BROWSER_SLOTS (default 2), BROWSER_SLOT_DIR (default /tmp),
+# BROWSER_MEM_FLOOR_MB (default 2500), BROWSER_LOCK_WAIT (default 900),
+# BROWSER_MEM_WAIT (default 300)
+set -u
+
+SLOTS="${BROWSER_SLOTS:-2}"
+DIR="${BROWSER_SLOT_DIR:-/tmp}"
+BASE="$DIR/doxa-maps-browser-gate.lock"
+FLOOR_MB="${BROWSER_MEM_FLOOR_MB:-2500}"
+WAIT_S="${BROWSER_LOCK_WAIT:-900}"
+MEM_WAIT_S="${BROWSER_MEM_WAIT:-300}"
+BUSY=75
+
+slot_path() { [ "$1" = "1" ] && echo "$BASE" || echo "$BASE$1"; }
+
+mem_available_mb() {
+ awk '/^MemAvailable:/ { printf "%d", $2 / 1024 }' /proc/meminfo 2>/dev/null || echo 999999
+}
+
+# Hold off while the box is below the floor. Proceeding anyway is not an option here the way it is
+# for a load average: load makes a measurement slow, memory makes it get killed halfway through.
+waited=0
+while [ "$(mem_available_mb)" -lt "$FLOOR_MB" ]; do
+ if [ "$waited" -ge "$MEM_WAIT_S" ]; then
+ echo "browser-slot: MemAvailable $(mem_available_mb)MB below floor ${FLOOR_MB}MB after ${waited}s — not measuring" >&2
+ exit "$BUSY"
+ fi
+ sleep 10
+ waited=$((waited + 10))
+done
+
+# Fast path: any free slot, in a FIXED order so two processes can never take them in opposite
+# orders. -n means "do not wait": if this slot is busy we move on rather than queue behind it.
+i=1
+while [ "$i" -le "$SLOTS" ]; do
+ exec 9>"$(slot_path "$i")" 2>/dev/null || true
+ if flock -n 9; then
+ exec "$@"
+ fi
+ i=$((i + 1))
+done
+
+# Everything busy: queue on slot 1, and report a timeout as UNMEASURED rather than as a verdict.
+exec 9>"$(slot_path 1)"
+if flock -w "$WAIT_S" 9; then
+ exec "$@"
+fi
+echo "browser-slot: no slot within ${WAIT_S}s (${SLOTS} slots, floor ${FLOOR_MB}MB) — not measuring" >&2
+exit "$BUSY"
diff --git a/internal/run-parallel.mjs b/internal/run-parallel.mjs
index e65c579c1..57ed01d93 100644
--- a/internal/run-parallel.mjs
+++ b/internal/run-parallel.mjs
@@ -84,10 +84,10 @@ const CONCURRENCY = Math.max(
// processes can never take them in opposite orders and deadlock. Either file alone still
// serialises everything that uses it; taking both is what makes gates and hand-run probes queue
// against each other without the repo having to spell a name it must not ship.
-const BROWSER_LOCK = '/tmp/doxa-maps-browser-gate.lock'
-const EXTRA_LOCK = process.env.BROWSER_GATE_LOCK && process.env.BROWSER_GATE_LOCK !== BROWSER_LOCK
- ? process.env.BROWSER_GATE_LOCK
- : ''
+// The slot files live in browser-slot.sh, which owns the rendezvous now. BROWSER_GATE_LOCK from a
+// caller's environment is deliberately NOT consulted here any more: a single legacy lock taken
+// alongside the slots would collapse two slots back into one, which is the queue this replaced.
+// Agents' own wrappers should call internal/browser-slot.sh so everyone shares the same counter.
// ★ THE LOAD WAIT WAS COSTING MORE THAN IT SAVED. It was written at LOAD_MAX 12 when the risk was
// seven agents starting chrome at once; the FLOCK below is what actually prevents that — one
// browser gate at a time, across every agent on the box. The load check is belt-and-braces on top
@@ -177,18 +177,13 @@ function buildSteps() {
// wait-for-quiet.sh takes the same lock), so this queues rather than dogpiles. -w 1200
// caps the wait so a crashed holder cannot wedge a wave forever, and static gates —
// parse, sri, hygiene, the twenty that are node-startup — are left completely free.
- // flock can exec flock: nesting is how one command waits on both rendezvous points.
- // ★ -E 75: A LOCK TIMEOUT IS NOT A GATE FAILURE, AND flock CANNOT SAY SO BY DEFAULT.
- // Without it flock exits 1 on timeout — the same code a failing gate uses — and a wave that
- // simply could not get the browser lock prints ✗ against five product gates. MEASURED: five
- // browser gates each "failed" at exactly 1200064ms, which is the timeout to the millisecond
- // and not a verdict about the product at all. 75 (EX_TEMPFAIL) is distinct, so the runner can
- // report "could not take the lock" as the precondition it is.
- const lockArgs = ['-w', String(LOCK_WAIT_S), '-E', String(LOCK_BUSY_CODE)]
- const args = EXTRA_LOCK
- ? [...lockArgs, BROWSER_LOCK, 'flock', ...lockArgs, EXTRA_LOCK, 'bun', 'run', name]
- : [...lockArgs, BROWSER_LOCK, 'bun', 'run', name]
- return { ...step, browser: true, cmd: 'flock', args }
+ // ★ A TWO-SLOT SEMAPHORE, NOT A MUTEX. One lock made every agent's browser proof wait behind
+ // a single chrome — measured 2026-09-25, the queue sat 10-19 deep for hours. The constraint
+ // is MEMORY (~300-500MB per headless chrome), not exclusivity, so browser-slot.sh runs as
+ // many as the box can hold and refuses to start one when MemAvailable is below the floor.
+ // It keeps the same exit-code contract: 75 means "not measured", never "failed".
+ const args = [resolve(ROOT, 'internal/browser-slot.sh'), 'bun', 'run', name]
+ return { ...step, browser: true, cmd: 'sh', args }
})
}
// A bundle is a plugin-designs// directory — the same selector build:unlocked used