diff --git a/booth/app.py b/booth/app.py
index 1852388..0b8d79b 100644
--- a/booth/app.py
+++ b/booth/app.py
@@ -1706,19 +1706,56 @@ def create_app(
"flagged": any(m.shape == "flag" for m in item_marks),
}
- if item is not None and item.kind == "image":
- # prev/next ring (wraps; only when there is more than one item in
- # it). R2 C2: the ring is `review_chain` — the item order filtered to
- # MEDIA — so a set that mixes pictures and sound steps through both.
- names = review_chain(items)
+ if item is not None and item.kind in REVIEW_KINDS:
+ # THE REVIEW (R2 C6): images, video and audio at full size with the
+ # judgment on screen. The ring is `review_chain` — the item order
+ # filtered to MEDIA — and the prev/next, the filmstrip and the tape
+ # all read that ONE list, so they cannot disagree about "next".
+ ring = review_chain(items)
+ by_rel = {it.rel: it for it in items}
+ pos = ring.index(f)
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="/")
+ if len(ring) > 1:
+ prev_url = quote(ring[(pos - 1) % len(ring)], safe="/")
+ next_url = quote(ring[(pos + 1) % len(ring)], safe="/")
+ flagged_rels = {m.target for m in marks
+ if m.shape == "flag" and m.error is None}
+ # recorded above, before this read: the current item counts as seen
+ seen = read_seen(booth) & set(ring)
+ film = [{"name": r, "url": by_rel[r].url, "ordinal": by_rel[r].ordinal,
+ "kind": by_rel[r].kind, "blurred": by_rel[r].blurred,
+ "flagged": r in flagged_rels, "seen": r in seen,
+ "current": r == f} for r in ring]
+ # Position within the group, only when there IS grouping: two or
+ # more groups among the ring. One group for everything says nothing.
+ group = None
+ ring_groups = {by_rel[r].group for r in ring if by_rel[r].group}
+ if item.group and len(ring_groups) > 1:
+ members = [r for r in ring if by_rel[r].group == item.group]
+ group = {"key": item.group, "k": members.index(f) + 1, "n": len(members)}
+ open_now = open_marks(marks)
return templates.TemplateResponse(
- request, "view.html", {**common, "prev_url": prev_url, "next_url": next_url}
- )
+ request, "view.html", {
+ **common,
+ "kind": item.kind,
+ "ordinal": item.ordinal,
+ "ord_width": len(str(len(items))),
+ "ring_k": pos + 1,
+ "ring_m": len(ring),
+ "prev_url": prev_url,
+ "next_url": next_url,
+ "film": film,
+ "seen_n": len(seen),
+ "group": group,
+ # a question ABOUT this item is answerable here; the rest are
+ # a count and a link — until the last item, where the end of
+ # the set offers them all
+ "item_picks": [m for m in open_now if m.target == f],
+ "other_picks": [m for m in open_now if m.target != f],
+ "is_last": pos == len(ring) - 1,
+ "tray": [x for x in film if x["flagged"]],
+ "back_url": f"/b/{quote(name, safe='')}/#item-{item.url}",
+ })
# .md renders, .txt/.log show as text — viewable in-booth, no download
if item is not None:
diff --git a/booth/templates/_marks.html b/booth/templates/_marks.html
index de38719..4792bf8 100644
--- a/booth/templates/_marks.html
+++ b/booth/templates/_marks.html
@@ -26,6 +26,11 @@
{% set flags = marks | selectattr('shape', 'equalto', 'flag') | rejectattr('error') | list %}
+{# `picks_only` + `back_view`: the review rail (view.html) includes this panel
+ with `marks` narrowed to the open picks it should offer, and wants only the
+ pick forms, each landing back on the review (`back=view`, R2 C3). ONE
+ renderer of a pick form, whichever page it sits on. #}
+{% if not picks_only %}
{% for a in broken %}
@@ -42,6 +47,7 @@
{% endfor %}
+{% endif %}
{% for a in picks %}
@@ -83,6 +89,7 @@
{# On the standalone page, come back HERE — the booth's own page is a
verbatim report that cannot show the recorded judgment. #}
{% if marks_page %}{% endif %}
+ {% if back_view %}{% endif %}
{% for q in a.questions %}
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
{% set qa = a.answer.answers.get(q.key) if (a.answer and a.multi) else a.answer %}
@@ -117,6 +124,7 @@
{% endfor %}
+{% if not picks_only %}
{# FLAGS come right after the picks. On the lightbox (`tray` defined) they
render as the TRAY: the flagged items in SET order — by tile number, the
declared R2 change from the click order below — each the original shown
@@ -176,4 +184,5 @@
+{% endif %}
diff --git a/booth/templates/base.html b/booth/templates/base.html
index 93e9fac..728a0b6 100644
--- a/booth/templates/base.html
+++ b/booth/templates/base.html
@@ -547,7 +547,7 @@
pseudo-element, so no markup is added. They sit 4px INSIDE the box: an
overflow:hidden tile clips an outside bracket's entire stroke (the SVOS
foot-gun). A hairline shadow keeps them visible over a bright image. */
- .item.is-cursor::after,.item:target::after,.mark-opt:has(input:checked)::after{content:"";position:absolute;inset:4px;z-index:3;
+ .item.is-cursor::after,.item:target::after,.mark-opt:has(input:checked)::after,.film-f.is-current::after{content:"";position:absolute;inset:4px;z-index:3;
pointer-events:none;--rl:16px;--rt:2px;
background:
linear-gradient(var(--accent) 0 0) top left / var(--rl) var(--rt),
@@ -560,6 +560,7 @@
linear-gradient(var(--accent) 0 0) bottom right / var(--rt) var(--rl);
background-repeat:no-repeat;filter:drop-shadow(0 0 1px oklch(0.14 0.01 250 / .7))}
.mark-opt:has(input:checked)::after{inset:3px;--rl:10px;--rt:1.5px;filter:none}
+ .film-f.is-current::after{inset:2px;--rl:9px;--rt:1.5px}
.item.is-cursor{border-color:color-mix(in oklab,var(--accent) 60%,transparent)}
/* ⚠ Blur is COSMETIC. The file is still served at its own URL and still in
@@ -657,6 +658,69 @@
.vnote pre{flex:1;min-width:0}
pre.vnote{display:block}
+ /* ---- THE REVIEW (R2 C6) ------------------------------------------------
+ Bar, tape, stage + rail, filmstrip. The rail scrolls on its own so the
+ judgment stays beside the artifact at any caption length. */
+ .viewer.review{display:grid;grid-template-rows:auto auto minmax(0,1fr) auto}
+ .review-body{position:relative;display:grid;grid-template-columns:minmax(0,1fr) 360px;min-height:0}
+ .review .vstage{position:relative;min-height:0}
+ .review .vstage audio{width:min(640px,92%)}
+ .review .vstage video{max-width:100%;max-height:100%}
+ .review .vstage.is-blurred img,.review .vstage.is-blurred video{filter:blur(22px)}
+ .review .vstage.is-blurred.revealed img,.review .vstage.is-blurred.revealed video{filter:none}
+ .review .vstage .reveal{position:absolute;top:14px;left:14px;z-index:3;cursor:pointer;font-family:var(--font-mono);
+ font-size:var(--size-micro);padding:6px 9px;border-radius:var(--radius-md);border:1px solid rgb(255 255 255 / .16);
+ background:oklch(0.17 0.01 250 / .86);color:oklch(0.91 0.008 216)}
+ .vname .ord{font-weight:600;color:var(--text-heading)}
+ .vrail{display:flex;flex-direction:column;min-height:0;overflow:auto;border-left:1px solid var(--border-subtle);
+ background:var(--surface-card)}
+ .vr-sec{padding:12px 14px;border-bottom:1px solid var(--border-subtle)}
+ .vr-sec .marks{margin:0}
+ .vr-where{font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)}
+ .vr-where .ord{color:var(--text-heading);font-weight:600}
+ .vr-judge{display:flex;flex-direction:column;gap:8px}
+ .vr-judge .vflag-btn{width:100%;height:40px;justify-content:center;font-family:var(--font-sans);font-weight:600}
+ .vr-judge .vaddnote{flex-direction:column;align-items:stretch}
+ .vr-judge .vaddnote button{align-self:flex-end}
+ .vr-end-head{margin:0 0 8px;font-family:var(--font-mono);font-size:var(--size-micro);letter-spacing:var(--tracking-caps);
+ text-transform:uppercase;color:var(--success-text)}
+ .vr-end .tray{padding:0 0 10px}
+ .vr-more a{font-size:var(--size-sm)}
+ .vr-keys{margin-top:auto;padding:10px 14px;font-family:var(--font-mono);font-size:var(--size-micro);color:var(--text-muted);
+ border-top:1px solid var(--border-subtle)}
+ kbd{display:inline-block;min-width:1.4em;padding:1px 5px;border:1px solid var(--border-strong);border-radius:var(--radius-sm);
+ background:var(--surface-sunken);font-family:var(--font-mono);font-size:var(--size-micro);line-height:1.3;
+ color:var(--text-body);text-align:center}
+ .tape{display:flex;align-items:center;gap:12px;padding:8px 14px;background:var(--surface-base);
+ border-bottom:1px solid var(--border-subtle)}
+ .tape-segs{flex:1;display:flex;gap:2px;align-items:center;height:14px;min-width:0}
+ .tape-s{flex:1;min-width:2px;height:6px;border-radius:2px;background:var(--surface-raised);border:1px solid var(--border-default)}
+ .tape-s:hover{border-color:var(--text-muted)}
+ .tape-s.is-seen{background:var(--border-strong);border-color:var(--border-strong)}
+ .tape-s.is-flagged{background:var(--success);border-color:var(--success)}
+ .tape-s.is-current{height:12px;background:var(--accent);border-color:var(--accent)}
+ .tape-count{font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted);white-space:nowrap}
+ .film{display:flex;gap:6px;padding:8px 14px;overflow-x:auto;background:var(--surface-base);
+ border-top:1px solid var(--border-subtle);scrollbar-width:thin}
+ .film-f{position:relative;flex:0 0 auto;width:84px;height:62px;border-radius:var(--radius-md);overflow:hidden;
+ border:1px solid var(--border-default);background:var(--surface-sunken);opacity:.72;
+ transition:opacity var(--dur-1) var(--ease-out)}
+ .film-f:hover{opacity:1;text-decoration:none}
+ .film-f img{width:100%;height:100%;object-fit:cover;display:block}
+ .film-f.is-blurred img{filter:blur(6px)}
+ .film-f.is-flagged{border-color:var(--success);box-shadow:inset 0 -3px 0 var(--success);opacity:.9}
+ .film-f.is-current{opacity:1;border-color:color-mix(in oklab,var(--accent) 60%,transparent)}
+ .film-kind{display:flex;align-items:center;justify-content:center;height:100%;font-size:20px;color:var(--text-muted)}
+ .film-ord{position:absolute;left:3px;top:3px;padding:1px 4px;border-radius:var(--radius-sm);
+ font:600 9.5px/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .85);color:oklch(0.91 0.008 216)}
+ @media (max-width:900px){
+ .viewer.review{position:static;display:block;min-height:100vh}
+ .review-body{grid-template-columns:1fr}
+ .review .vstage.fit{height:60vh}
+ .vrail{border-left:0;border-top:1px solid var(--border-subtle)}
+ .vnext{right:0}
+ }
+
/* ---- the standing link board ------------------------------------------
Rows, not a markdown blob. Dense enough that thirty entries stay
scannable; provenance recedes so the description leads, and the × only
@@ -801,7 +865,7 @@
playing, a decoded image does not collapse to zero height and jolt the
page), and the per-viewer view state a reload would have reset anyway
but an in-place save must not — a revealed blur, a closed doc. */
- function carry(oldEl, newEl) {
+ function carry(oldEl, newEl, sent) {
var olds = [].slice.call(oldEl.querySelectorAll('img[src], video[src], audio[src]'));
newEl.querySelectorAll('img[src], video[src], audio[src]').forEach(function (m) {
for (var i = 0; i < olds.length; i++) {
@@ -815,8 +879,18 @@
['revealed', 'is-closed'].forEach(function (c) {
if (oldEl.classList.contains(c)) newEl.classList.add(c);
});
+ /* An unsaved DRAFT survives a swap it was not part of: a note half-typed
+ on one tile must not vanish because a flag landed on another. The
+ form that was just sent is the exception — its field is supposed to
+ come back empty. Matched by name and position within the region. */
+ var fresh = newEl.querySelectorAll('textarea, input[type=text]');
+ oldEl.querySelectorAll('textarea, input[type=text]').forEach(function (f, i) {
+ if (!f.value || (sent && sent.contains(f))) return;
+ var t = fresh[i];
+ if (t && t.name === f.name && !t.value) t.value = f.value;
+ });
}
- function swap(html) {
+ function swap(html, sent) {
var fresh = new DOMParser().parseFromString(html, 'text/html');
document.querySelectorAll('[data-region]').forEach(function (el) {
var id = el.getAttribute('data-region');
@@ -827,7 +901,7 @@
});
if (next) {
var node = document.importNode(next, true);
- carry(el, node);
+ carry(el, node, sent);
el.replaceWith(node);
}
});
@@ -848,7 +922,7 @@
}).then(function (r) {
if (!r.ok) throw new Error('status ' + r.status);
return r.text();
- }).then(swap).catch(function () {
+ }).then(function (html) { swap(html, form); }).catch(function () {
say('Could not save in place — reloading to show what was saved.');
window.location.reload();
});
diff --git a/booth/templates/view.html b/booth/templates/view.html
index 2b174ba..5fe6aca 100644
--- a/booth/templates/view.html
+++ b/booth/templates/view.html
@@ -1,52 +1,136 @@
{% extends "base.html" %}
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
+{# THE REVIEW (R2 C6). One media item at full size — image, video or audio —
+ with the judgment on screen beside it, the whole set as a filmstrip below and
+ the tape above. Docs keep doc.html. Everything a mark can change is a
+ `data-region` the in-place script swaps (the rail, the filmstrip, the tape);
+ THE STAGE NEVER IS — swapping it would restart a playing track. #}
+{% macro num(n) -%}#{{ "%0*d"|format(ord_width, n) }}{%- endmacro %}
{% block content %}
-
+
- ✕
- {{ file }}
+ ✕
+ {{ num(ordinal) }} {{ file }}
+ {% if kind == 'image' %}
+ {# A JS-only VIEWING convenience (INV-3): hidden until the script shows it,
+ and only ever rendered for a picture. With scripts off the image shows at
+ fit size and no judgment depends on this. #}
+ {% endif %}
⬇
- {% if prev_url %}‹{% endif %}
- {% if next_url %}›{% endif %}
-
- {# THE ANNOTATION, at full size. It was never rendered here before U1 — not
- because the template dropped it, but because the route never resolved it.
- A caption is most useful at the size where you are actually judging the
- thing, so it belongs here at least as much as in the grid. #}
- {% if caption %}
{{ caption }}
{% endif %}
- {# INV-3: the JUDGMENT travels to full size too, not just the caption. This is
- the size at which the operator is actually deciding, so the flag toggle and
- the notes belong here at least as much as on the tile. #}
-
-
- {% for m in marks if m.shape == 'note' %}
-
{{ m.text }}
-
diff --git a/tests/test_flow.py b/tests/test_flow.py
index 2c2606d..e0b5c07 100644
--- a/tests/test_flow.py
+++ b/tests/test_flow.py
@@ -476,3 +476,85 @@ def test_every_mark_dependent_element_is_a_swappable_region(tmp_path):
_booth(tmp_path, "links", {"links.md": b"- [x](http://x/) \xc2\xb7 a \xc2\xb7 2026-09-01 10:00\n"})
board = _client(tmp_path).get("/b/links/").text
assert 'data-region="verdict"' not in board and 'class="lightbox"' not in board
+
+
+# ---- C6: the review -----------------------------------------------------------
+
+def test_an_audio_item_is_reviewed_like_a_picture(tmp_path):
+ """The tracer for C6. A track gets the review page — its native player on
+ the stage, no Fit/1:1 toggle (that is for images only), and the judgment
+ rail with a flag that lands back here."""
+ _booth(tmp_path, "g", {"a.png": PNG, "b.mp3": b"ID3", "c.mp3": b"ID3"})
+ r = _client(tmp_path).get("/b/g/view?f=b.mp3", follow_redirects=False)
+ assert r.status_code == 200
+ body = r.text
+ stage = body[body.index('id="vstage"'):]
+ assert len(re.findall(r"