feat(r3): compare — two picked rels side by side, linked stepping, synced pan, flag the winner

GET /b/{name}/compare with the conjunction 404 (containment AND the review
ring), both sides recorded as seen, view state (side, link) mapped from a
closed set onto every link, side-keyed regions, and back=compare in
_mark_redirect. compare.html: two stages sharing one set of rows, the strip
as picker (the side active now), linked and per-side stepping, X/L/Z/A/B/C
keys under the review's guards, synced pan by fraction with an echo guard,
per-side blur reveals, JS-off parity.

The stage machinery moves out of view.html into _stage_js.html
(BoothMode.bind, BoothStage.attach), shared by the review and compare. The
review gains a Compare control and a C key. At phone width a full top bar
wraps.

Tables: r2c's 15 stage rows re-pointed to _stage_js.html; r2b's phone
top-bar row re-anchored (the wrap made it vacuous alone); new r3.toml. The
contract records the wrap, equal stages and C on the compare page.
This commit is contained in:
vh
2026-09-24 13:26:11 -07:00
parent 5d785fe3c4
commit 8c7fe77841
11 changed files with 1841 additions and 111 deletions
+129
View File
@@ -1538,6 +1538,26 @@ def create_app(
return RedirectResponse(
url=f"/b/{quote(name, safe='')}/view?f={quote(f, safe='/')}#rail",
status_code=303)
elif form.get("back") == "compare":
# R3 C5: a flag made in compare lands back on the same PAIR — only
# when both sides are media of this booth, quoted as the view branch
# quotes. The view state is MAPPED from a closed set (`side` exactly
# `a`, `link` exactly `0`, in that order), never echoed. No fragment:
# the flags sit beside the stages, so there is nothing to scroll to.
a, b = form.get("a"), form.get("b")
if isinstance(a, str) and a and isinstance(b, str) and b:
try:
ring = review_chain(booth_items(resolve_booth(name)))
except HTTPException:
ring = []
if a in ring and b in ring:
url = (f"/b/{quote(name, safe='')}/compare"
f"?a={quote(a, safe='/')}&b={quote(b, safe='/')}")
if form.get("side") == "a":
url += "&side=a"
if form.get("link") == "0":
url += "&link=0"
return RedirectResponse(url=url, status_code=303)
return RedirectResponse(url=f"{base}#{anchor}", status_code=303)
def _mark_done(request: Request, name: str, form, anchor: str) -> Response:
@@ -1965,6 +1985,10 @@ def create_app(
"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}",
# R3 C2: this item against the NEXT in the ring (itself in a
# ring of one), keyed by rel like every compare URL
"compare_url": (f"/b/{quote(name, safe='')}/compare?a={quote(f, safe='/')}"
f"&b={quote(ring[(pos + 1) % len(ring)], safe='/')}"),
})
# .md renders, .txt/.log show as text — viewable in-booth, no download
@@ -1983,6 +2007,111 @@ def create_app(
url=f"/b/{quote(name, safe='')}/{quote(f, safe='/')}", status_code=307
)
def _compare_side(booth: Path, ring: list[str], rel) -> str:
"""One side of a compare, or a 404 (R3 C1). A CONJUNCTION: the view
route's resolve / containment / is_file check, AND membership of the
review ring. The ring alone is not enough — `booth_items` follows
symlinks, so a link pointing outside the booth is IN the ring and only
containment refuses it. The view's check alone is not enough — it
renders a doc, and compare takes media only."""
if not isinstance(rel, str) or not rel:
raise HTTPException(status_code=404, detail="no such item")
try:
target = (booth / rel).resolve()
except (OSError, ValueError):
# ValueError: an embedded NUL — hostile input, a 404, never a 500
raise HTTPException(status_code=404, detail="no such item")
if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
raise HTTPException(status_code=404, detail="no such item")
if rel not in ring:
raise HTTPException(status_code=404, detail="no such item")
return rel
# Registered BEFORE the catch-all file route, as view is. Accepted: a booth
# FILE literally named `compare` is unreachable at /b/<name>/compare — the
# same shadowing view, marks, asks and embed.json already cause.
@app.get("/b/{name}/compare", response_class=HTMLResponse)
def booth_compare(request: Request, name: str, a: str = "", b: str = "",
side: str = "", link: str = ""):
"""Two items of the review ring side by side (R3), for judging which is
better and flagging the winner.
The PAIR is two rels in the URL, never ordinals (INV-1): an ordinal is a
position in the set as it is now, and a file added mid-bakeoff would
turn a bookmarked compare into two different pictures. `side` and
`link` are VIEW STATE riding the URL so that every step, a full page
load, keeps them. They are `str`, not an int or a Literal, so an
unknown value reads as the default and never as a 422.
The filmstrip is the review ring in RING ORDER (the item order filtered
to media — the review's `film`).
"""
booth = resolve_booth(name)
items = booth_items(booth)
ring = review_chain(items)
a = _compare_side(booth, ring, a)
b = _compare_side(booth, ring, b)
# A look records both — below the 404s, so only a real pair counts.
record_view(booth)
record_seen(booth, a, items)
record_seen(booth, b, items)
side_a = side == "a"
linked = link != "0"
name_url = quote(name, safe="")
by_rel = {it.rel: it for it in items}
flagged_rels = flagged_targets(marks_for(booth))
n = len(ring)
ia, ib = ring.index(a), ring.index(b)
def url(x: str, y: str) -> str:
"""A compare URL for the pair (x, y), carrying the view state."""
u = f"/b/{name_url}/compare?a={quote(x, safe='/')}&b={quote(y, safe='/')}"
if side_a:
u += "&side=a"
if not linked:
u += "&link=0"
return u
def facts(rel: str) -> dict:
it = by_rel[rel]
return {"rel": rel, "url": it.url, "ordinal": it.ordinal,
"kind": it.kind, "caption": it.caption,
"blurred": it.blurred, "flagged": rel in flagged_rels,
"thumb": it.thumb,
"review": f"/b/{name_url}/view?f={it.url}"}
film = [{"name": r, "url": by_rel[r].url, "thumb": by_rel[r].thumb,
"ordinal": by_rel[r].ordinal, "kind": by_rel[r].kind,
"blurred": by_rel[r].blurred, "flagged": r in flagged_rels,
"is_a": r == a, "is_b": r == b,
# the link a frame is without JS: it replaces the ACTIVE side
"pick": url(r, b) if side_a else url(a, r),
# ...and with JS, the side active at the click
"pick_a": url(r, b), "pick_b": url(a, r)} for r in ring]
steps = {
"both_prev": url(ring[(ia - 1) % n], ring[(ib - 1) % n]),
"both_next": url(ring[(ia + 1) % n], ring[(ib + 1) % n]),
"a_prev": url(ring[(ia - 1) % n], b),
"a_next": url(ring[(ia + 1) % n], b),
"b_prev": url(a, ring[(ib - 1) % n]),
"b_next": url(a, ring[(ib + 1) % n]),
}
return templates.TemplateResponse(
request, "compare.html", {
**base_ctx,
"name": name,
"name_url": name_url,
"sides": {"a": facts(a), "b": facts(b)},
"active": "a" if side_a else "b",
"linked": linked,
"film": film,
"steps": steps,
"back_url": f"/b/{name_url}/view?f={quote(a, safe='/')}",
"ord_width": len(str(len(items))),
"any_image": any(by_rel[r].kind == "image" for r in (a, b)),
})
@app.get("/b/{name}/{filepath:path}")
def booth_file(name: str, filepath: str, dl: int = 0, thumb: int = 0):
booth = resolve_booth(name)