From 8c7fe77841f96429a38af72cbabc0bb411455ec1 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Thu, 24 Sep 2026 13:26:11 -0700 Subject: [PATCH] =?UTF-8?q?feat(r3):=20compare=20=E2=80=94=20two=20picked?= =?UTF-8?q?=20rels=20side=20by=20side,=20linked=20stepping,=20synced=20pan?= =?UTF-8?q?,=20flag=20the=20winner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- booth/app.py | 129 ++++++++ booth/templates/_stage_js.html | 122 +++++++ booth/templates/base.html | 76 ++++- booth/templates/compare.html | 270 +++++++++++++++ booth/templates/view.html | 108 ++---- docs/contracts/r3_compare.contract.md | 12 + tests/mutations/r2b.toml | 16 +- tests/mutations/r2c.toml | 46 ++- tests/mutations/r3.toml | 429 ++++++++++++++++++++++++ tests/test_compare.py | 293 +++++++++++++++++ tests/test_compare_browser.py | 451 ++++++++++++++++++++++++++ 11 files changed, 1841 insertions(+), 111 deletions(-) create mode 100644 booth/templates/_stage_js.html create mode 100644 booth/templates/compare.html create mode 100644 tests/mutations/r3.toml create mode 100644 tests/test_compare.py create mode 100644 tests/test_compare_browser.py diff --git a/booth/app.py b/booth/app.py index b0941bb..7555bcc 100644 --- a/booth/app.py +++ b/booth/app.py @@ -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//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) diff --git a/booth/templates/_stage_js.html b/booth/templates/_stage_js.html new file mode 100644 index 0000000..364f96d --- /dev/null +++ b/booth/templates/_stage_js.html @@ -0,0 +1,122 @@ +{# THE STAGE MACHINERY (r2c, shared by R3). One copy, behind a stated + interface, used by the review (one stage) and compare (two). It DEFINES two + things and binds nothing by itself: + + BoothMode.bind({toggle, fit, one, onChange}) page level, once + BoothStage.attach(stageEl, {img, onSettle}) once per stage + + A page binds BoothMode ONLY when at least one of its stages is an image (the + review's rule): two videos get no toggle. No key is bound here — the review + gains none, and compare's `Z` is compare's own (r3 C4, INV-6). No + ResizeObserver here either: each page owns its own, so the review's stays in + view.html with its mutation row. #} + diff --git a/booth/templates/base.html b/booth/templates/base.html index 339cd28..2846331 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -618,7 +618,8 @@ 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,.film-f.is-current::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, + .cmp-side.is-active>.cmp-stagewrap::after,.film-f.is-active::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), @@ -631,7 +632,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} + .film-f.is-current::after,.film-f.is-active::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 @@ -678,6 +679,13 @@ tail; the title still says it, as does every per-item reveal. */ .blur-all button,.reveal-all-btn{white-space:nowrap} @media (max-width:600px){.reveal-all-btn .ra-note{display:none}} + /* R3: the review's Compare control keeps only its glyph at phone width; its + title still says what it does (and C does it). */ + @media (max-width:600px){.vcompare-l{display:none}} + /* ...and at phone width a top bar that cannot hold its controls WRAPS rather + than scrolling the page sideways. The review's bar was full before R3 (a + fogged booth overflowed it by 3px at 390); compare's bar holds more. */ + @media (max-width:600px){.vbar{flex-wrap:wrap;row-gap:6px}} .badge-blur{color:var(--text-muted)} /* ---- inline docs ------------------------------------------------------ @@ -842,6 +850,70 @@ .vnext{right:0} } + /* ---- COMPARE (R3) ------------------------------------------------------- + Two stages, one judgment each. The root is `.viewer.review.compare`, so + the review's blur, 1:1 and Reveal-all rules apply unchanged (they are + scoped to `.review`); this overrides the review's four-row grid with bar, + the two sides, the key line and the filmstrip. There is no rail column: + each side carries its own flag under its stage. */ + .viewer.review.compare{grid-template-rows:auto minmax(0,1fr) auto auto} + /* The sides share ONE set of rows (subgrid): a caption under A takes its + height from both stages alike, never from A's alone — two stages of + different sizes would draw the same picture at two scales in Fit. */ + .cmp-body{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr); + grid-template-rows:auto minmax(0,1fr) auto;min-height:0} + .cmp-side{display:grid;grid-row:1 / -1;grid-template-rows:subgrid;min-width:0;min-height:0} + .cmp-side+.cmp-side{border-left:1px solid var(--border-subtle)} + .cmp-head{display:flex;align-items:center;gap:8px;min-width:0;padding:8px 12px; + border-bottom:1px solid var(--border-subtle);background:var(--surface-base)} + .cmp-label{flex:1;min-width:0;font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-body); + white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .cmp-letter{display:inline-block;min-width:1.5em;text-align:center;font-weight:700;color:var(--text-heading); + border:1px solid var(--border-strong);border-radius:var(--radius-sm)} + .cmp-side.is-active .cmp-letter{border-color:var(--accent);color:var(--accent-text)} + .cmp-label .ord{font-weight:600;color:var(--text-heading)} + .cmp-flagged{color:var(--success-text);font-weight:600} + .cmp-head .cmp-step{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:28px;height:28px; + border:1px solid var(--border-default);border-radius:var(--radius-md);color:var(--text-body);font-size:18px;line-height:1; + text-decoration:none} + .cmp-head .cmp-step:hover{border-color:var(--border-strong);background:var(--surface-overlay);text-decoration:none} + .cmp-review{flex:0 0 auto;font-family:var(--font-mono);font-size:var(--size-micro);white-space:nowrap} + /* The stage fills its side. The wrapper, never the stage, holds what sits + OVER the stage (the reveal, the reticle), so a 1:1 pan cannot carry them. */ + .cmp-stagewrap{position:relative;display:flex;min-height:0;min-width:0} + .cmp-stagewrap>.vstage{flex:1;min-width:0} + .review.compare .vstage video{max-width:100%;max-height:100%} + .cmp-reveal{position:absolute;top:10px;left:10px;z-index:5;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)} + .cmp-reveal[hidden]{display:none} + /* Reveal all stands the per-side reveals down BY STYLESHEET, as it does the + review's #vreveal (r3 C6). */ + .reveal-all .cmp-reveal{display:none} + .cmp-foot{display:flex;flex-direction:column;gap:6px;padding:8px 12px;border-top:1px solid var(--border-subtle); + background:var(--surface-card)} + .cmp-foot .vflag-btn{width:100%;height:36px;justify-content:center;gap:6px;font-family:var(--font-sans);font-weight:600} + /* each side's caption: the review's .vcap, clamped to 20vh with its own scroll */ + .cmp-cap{max-height:20vh;overflow-y:auto;font-size:var(--size-sm);line-height:var(--leading-body); + color:var(--text-body);white-space:pre-wrap} + .cmp-keys{padding:6px 14px;font-family:var(--font-mono);font-size:var(--size-micro);color:var(--text-muted); + border-top:1px solid var(--border-subtle);background:var(--surface-base)} + .cmp-link[aria-pressed="false"]{color:var(--text-muted);border-style:dashed} + .cmp-link[hidden]{display:none} /* .vbtn's inline-flex beats the UA's [hidden] */ + /* The strip marks which frame is on which side; the ACTIVE side's frame + wears the reticle (with the active stage, via the list above). */ + .film-f.is-a,.film-f.is-b{opacity:1;border-color:var(--border-strong)} + .film-ab{position:absolute;right:3px;bottom:3px;padding:1px 5px;border-radius:var(--radius-sm); + font:700 9.5px/1.2 var(--font-mono);background:var(--accent);color:var(--accent-contrast)} + @media (max-width:900px){ + .viewer.review.compare{display:block} + .cmp-body{grid-template-columns:minmax(0,1fr);grid-template-rows:none} + .cmp-side{grid-row:auto;grid-template-rows:auto auto auto} + .cmp-side+.cmp-side{border-left:0;border-top:1px solid var(--border-subtle)} + .review.compare .vstage{height:45vh} + .cmp-keys{display:none} + } + /* ---- 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 diff --git a/booth/templates/compare.html b/booth/templates/compare.html new file mode 100644 index 0000000..4c574ed --- /dev/null +++ b/booth/templates/compare.html @@ -0,0 +1,270 @@ +{% extends "base.html" %} +{% block title %}{{ sides.a.rel }} · {{ sides.b.rel }} · compare · {{ name }} · The Booth{% endblock %} +{# data-booth: without it Reveal all's script and the head script's reveal + restore both bail (r3 C6). #} +{% block html_attrs %} data-booth="{{ name }}"{% endblock %} +{# COMPARE (R3). Two items of the review ring side by side, one judgment each: + flag the winner. The pair is two rels in the URL, always (INV-1); the active + side and the linked stepping ride the URL as view state. Everything a mark + can change is a `data-region` keyed by SIDE, never by rel (`a == b` would + duplicate it) and never `item-` (the swap reads that as a stale tile). THE + STAGES NEVER ARE: swapping one would restart a playing track. + The root carries `review` so the review's blur, 1:1 and Reveal-all rules + apply unchanged; `.compare` overrides its grid. #} +{% macro num(n) -%}#{{ "%0*d"|format(ord_width, n) }}{%- endmacro %} +{% block content %} +
+
+ ✕ + compare {{ num(sides.a.ordinal) }} · {{ num(sides.b.ordinal) }} + + ‹‹ + ›› + {# JS-only, like the stage toggle: without JS there are no keys to link, + and the per-side and both-sides links above step either way. #} + + {% if any_image %} + + {% endif %} + {% if film | selectattr('blurred') | list %}{% endif %} +
+ +
+ {% for key in ('a', 'b') %}{% set s = sides[key] %}{% set L = key | upper %} +
+
+ ‹ +
{{ L }} {{ num(s.ordinal) }} {{ s.rel }}{% if s.flagged %} ✔ flagged{% endif %}
+ › + review {{ L }} +
+
+
+ {% if s.kind == 'image' %}{{ s.rel }} + {% elif s.kind == 'video' %} + {% else %} + {% endif %} +
+ {# Over the stage, never inside its scrolled content; JS-only, so + `hidden` until bound (the review's pattern). #} + {% if s.blurred %}{% endif %} +
+
+
+
+ + + + + + + + +
+
+ {% if s.caption %}
{{ s.caption }}
{% endif %} +
+
+ {% endfor %} +
+ +
← → Space step · A B flag · X side · L link · Z Fit/1:1 · Esc review
+ + {# THE FILMSTRIP IS THE PICKER: the review ring in ring order. Without JS a + frame is a link that replaces the active side from the URL (B by + default); with JS a click replaces the side that is active NOW. #} + +
+{% include "_stage_js.html" %} + +{% endblock %} diff --git a/booth/templates/view.html b/booth/templates/view.html index 9028d49..3a12647 100644 --- a/booth/templates/view.html +++ b/booth/templates/view.html @@ -13,6 +13,8 @@ ✕ {{ num(ordinal) }} {{ file }} + {# R3: this item against the next one in the ring, side by side (C). #} + compare {% 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 @@ -128,7 +130,7 @@ {% endif %} -
← → Space move · F flag · N note · Esc grid
+
← → Space move · F flag · N note · C compare · Esc grid
@@ -167,16 +169,18 @@ color:var(--text-body);white-space:pre-wrap} @media print{.vcap{max-height:none;overflow:visible}.vnav{display:none}} +{% include "_stage_js.html" %}