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
+28 -18
View File
@@ -1,5 +1,12 @@
# R2c — the review stage: every falsifier the contract claims
# (docs/contracts/r2c_review_stage.contract.md), and the change each forbids.
#
# r3 moved the stage machinery (the mode toggle, the storage listener, pannable
# and drag-pan) out of view.html into the shared _stage_js.html (r3 C4). The 15
# rows that anchor there were RE-POINTED, not rewritten: same test, same
# defeating change, in the code's new home, and every one re-proved after the
# move. The arrows (place, drawn, view's ResizeObserver) stayed in view.html,
# and so did their rows.
unit = "the review stage: fit / 1:1, the arrows at the picture, drag-pan"
@@ -23,12 +30,14 @@ new = '''
[[mutation]]
label = "S2 the toggle stays hidden (the per-picture hide is back)"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
# r3: BoothMode.bind is page level and holds no picture, so the re-pointed hide
# reads the page's one picture and stage itself.
old = '''
toggle.hidden = false;'''
new = '''
toggle.hidden = img.naturalWidth <= stage.clientWidth;'''
toggle.hidden = document.querySelector('.vstage img').naturalWidth <= document.querySelector('.vstage').clientWidth;'''
[[mutation]]
label = "S2 the toggle shows without JS (display beats [hidden])"
@@ -59,7 +68,7 @@ new = '''
[[mutation]]
label = "S2 a storage write that throws swallows the click"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
var setMode = function (one) {
@@ -112,7 +121,7 @@ new = '''
[[mutation]]
label = "S4 the pan runs backwards (the picture flees the pointer)"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
stage.scrollLeft = drag.l - dx;'''
@@ -121,7 +130,7 @@ new = '''
[[mutation]]
label = "S4 no drag threshold (a jittery click pans)"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */'''
@@ -166,7 +175,7 @@ new = '''
[[mutation]]
label = "a buttonless hover continues a press released outside the stage"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_a_pan_holds_past_the_stage_edge_and_never_starts_on_a_hover"
old = '''
if (!(e.buttons & 1)) { endDrag(); return; }'''
@@ -175,7 +184,7 @@ new = '''
[[mutation]]
label = "no pointer capture (a pan dies at the stage's edge)"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_a_pan_holds_past_the_stage_edge_and_never_starts_on_a_hover"
old = '''
try { stage.setPointerCapture(drag.id); } catch (x) {}'''
@@ -222,7 +231,7 @@ new = '''
[[mutation]]
label = "the drag threshold is per axis, not total (a 3,3 diagonal pans nothing)"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */'''
@@ -252,7 +261,7 @@ new = '''
[[mutation]]
label = "a stage mode chosen in another tab does not reach this one"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_a_stage_mode_chosen_in_one_tab_moves_the_others"
old = '''
if (e.key !== 'booth.fit' && e.key !== null) return;'''
@@ -261,7 +270,7 @@ new = '''
[[mutation]]
label = "the drag threshold drops to 3px"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */'''
@@ -270,7 +279,7 @@ new = '''
[[mutation]]
label = "a storage write that throws cuts the click short (buttons never update)"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
try {
@@ -281,7 +290,7 @@ new = '''
[[mutation]]
label = "the Fit button's pressed state is never drawn"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
bFit.setAttribute('aria-pressed', one ? 'false' : 'true');'''
@@ -302,16 +311,17 @@ new = '''
[[mutation]]
label = "S2 the toggle hides, on load, for a picture larger than the stage"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
# r3: `settle` is the stage's own (pannable) then the page's onSettle (place).
old = '''
function settle() { place(); pannable(); }'''
function settle() { pannable(); onSettle(); }'''
new = '''
function settle() { place(); pannable(); if (img) document.getElementById('vtoggle').hidden = img.naturalWidth > stage.clientWidth; }'''
function settle() { pannable(); onSettle(); if (img) document.getElementById('vtoggle').hidden = img.naturalWidth > stage.clientWidth; }'''
[[mutation]]
label = "S2 choosing Fit stores a word instead of forgetting 1:1"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.removeItem('booth.fit');'''
@@ -320,7 +330,7 @@ new = '''
[[mutation]]
label = "S4 pan only when BOTH axes overflow"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_a_picture_that_overflows_one_axis_pans_along_it"
old = '''
(stage.scrollWidth > stage.clientWidth || stage.scrollHeight > stage.clientHeight);'''
@@ -331,7 +341,7 @@ new = '''
[[mutation]]
label = "a press on the stage's scrollbar starts a pan"
file = "booth/templates/view.html"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_a_classic_scrollbar_is_neither_under_an_arrow_nor_a_pan"
old = '''
if (e.clientX - r.left - stage.clientLeft >= stage.clientWidth ||