diff --git a/services/booth/booth/app.py b/services/booth/booth/app.py
index 526548e..d450387 100644
--- a/services/booth/booth/app.py
+++ b/services/booth/booth/app.py
@@ -77,6 +77,15 @@ def render_doc(text: str, kind: str) -> tuple[str, bool]:
return text, False
+def booth_image_names(child: Path) -> list[str]:
+ """Image files in a booth, in gallery (sorted-rel) order — for viewer prev/next."""
+ return sorted(
+ p.relative_to(child).as_posix()
+ for p in child.rglob("*")
+ if p.is_file() and not p.name.startswith(".") and classify(p.name) == "image"
+ )
+
+
def classify(name: str) -> str:
"""image | video | audio | other, by extension."""
ext = Path(name).suffix.lower()
@@ -525,7 +534,16 @@ def create_app(
file_url = quote(f, safe="/")
common = {**base_ctx, "name": name, "name_url": quote(name, safe=""), "file": f, "file_url": file_url}
if classify(target.name) == "image":
- return templates.TemplateResponse(request, "view.html", common)
+ # prev/next image nav (wraps around; only when >1 image in the booth)
+ names = booth_image_names(booth)
+ prev_url = next_url = None
+ if f in names and len(names) > 1:
+ i = names.index(f)
+ prev_url = quote(names[(i - 1) % len(names)], safe="/")
+ next_url = quote(names[(i + 1) % len(names)], safe="/")
+ return templates.TemplateResponse(
+ request, "view.html", {**common, "prev_url": prev_url, "next_url": next_url}
+ )
# .md renders, .txt/.log show as text — viewable in-booth, no download
dk = doc_kind(target.name)
if dk:
diff --git a/services/booth/booth/templates/view.html b/services/booth/booth/templates/view.html
index 28fe915..ed09ea0 100644
--- a/services/booth/booth/templates/view.html
+++ b/services/booth/booth/templates/view.html
@@ -11,8 +11,20 @@
⬇
+ {% if prev_url %}‹{% endif %}
+ {% if next_url %}›{% endif %}

+
diff --git a/services/booth/pyproject.toml b/services/booth/pyproject.toml
index d87e35d..e8f40eb 100644
--- a/services/booth/pyproject.toml
+++ b/services/booth/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "booth"
-version = "0.1.6"
+version = "0.1.7"
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
requires-python = ">=3.11"
dependencies = [
diff --git a/services/booth/tests/test_booth.py b/services/booth/tests/test_booth.py
index 6fd15b6..e544f1b 100644
--- a/services/booth/tests/test_booth.py
+++ b/services/booth/tests/test_booth.py
@@ -493,3 +493,38 @@ def test_gallery_links_docs_to_view(client):
assert "view?f=readme.md" in page.text # md -> viewer
assert "view?f=notes.txt" in page.text # txt -> viewer
assert 'href="readme.md" download' not in page.text # not a forced download
+
+
+# ---- image viewer prev/next nav ---------------------------------------------
+
+
+def test_view_image_prev_next_nav(client):
+ c, data = client
+ d = data / "run1"; d.mkdir()
+ for n in ("a.png", "b.png", "c.png"):
+ _touch(d / n)
+ r = c.get("/b/run1/view", params={"f": "b.png"}) # middle -> prev=a, next=c
+ assert r.status_code == 200
+ assert 'class="vnav vprev" href="?f=a.png"' in r.text
+ assert 'class="vnav vnext" href="?f=c.png"' in r.text
+
+
+def test_view_image_nav_wraps(client):
+ c, data = client
+ d = data / "run1"; d.mkdir()
+ for n in ("a.png", "b.png", "c.png"):
+ _touch(d / n)
+ first = c.get("/b/run1/view", params={"f": "a.png"}).text
+ assert 'vprev" href="?f=c.png"' in first and 'vnext" href="?f=b.png"' in first # first wraps prev->last
+ last = c.get("/b/run1/view", params={"f": "c.png"}).text
+ assert 'vnext" href="?f=a.png"' in last and 'vprev" href="?f=b.png"' in last # last wraps next->first
+
+
+def test_view_single_image_no_nav(client):
+ c, data = client
+ d = data / "run1"; d.mkdir()
+ _touch(d / "only.png")
+ r = c.get("/b/run1/view", params={"f": "only.png"})
+ assert r.status_code == 200
+ # no arrow anchors with a single image (the .vnav CSS rule is always present)
+ assert 'class="vnav vprev"' not in r.text and 'class="vnav vnext"' not in r.text