Files
vh 8c7fe77841 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.
2026-09-24 13:26:11 -07:00

360 lines
14 KiB
TOML

# 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"
[[mutation]]
label = "S1 Fit never enlarges (the old max-width/max-height cap)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_fit_fills_the_stage_up_or_down"
old = '''
.vstage.is-img img{width:100%;height:100%;object-fit:contain;'''
new = '''
.vstage.is-img img{width:auto;height:auto;max-width:100%;max-height:100%;object-fit:contain;'''
[[mutation]]
label = "S1 Fit crops (cover, not contain)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_fit_fills_the_stage_up_or_down"
old = '''
.vstage.is-img img{width:100%;height:100%;object-fit:contain;'''
new = '''
.vstage.is-img img{width:100%;height:100%;object-fit:cover;'''
[[mutation]]
label = "S2 the toggle stays hidden (the per-picture hide is back)"
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 = document.querySelector('.vstage img').naturalWidth <= document.querySelector('.vstage').clientWidth;'''
[[mutation]]
label = "S2 the toggle shows without JS (display beats [hidden])"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
old = '''
.vtoggle[hidden]{display:none}'''
new = '''
'''
[[mutation]]
label = "S2 1:1 applied late (after the stage exists: a Fit flash)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
if (localStorage.getItem('booth.fit') === 'one') d.classList.add('stage-one');'''
new = '''
if (localStorage.getItem('booth.fit') === 'one') document.addEventListener('DOMContentLoaded', function () { d.classList.add('stage-one'); });'''
[[mutation]]
label = "S2 a stray stored value is taken as 1:1"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
if (localStorage.getItem('booth.fit') === 'one') d.classList.add('stage-one');'''
new = '''
if (localStorage.getItem('booth.fit')) d.classList.add('stage-one');'''
[[mutation]]
label = "S2 a storage write that throws swallows the click"
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) {
d.classList.toggle('stage-one', one);'''
new = '''
var setMode = function (one) {
localStorage.setItem('booth.fit', 'x');
d.classList.toggle('stage-one', one);'''
[[mutation]]
label = "S3 the arrows stay at the stage edges (never placed)"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
old = '''
var p = drawn();
if (!p) {'''
new = '''
var p = null;
if (!p) {'''
[[mutation]]
label = "S3 the arrows track the file's natural width, not the drawn picture"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
old = '''
var k = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight), w = img.naturalWidth * k;'''
new = '''
var k = 1, w = img.naturalWidth * k;'''
[[mutation]]
label = "S3 an arrow is not clamped inside the stage"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
old = '''
x = Math.max(lo, Math.min(hi, x));'''
new = '''
'''
# NEITHER path: the first draft of this row disabled only the ResizeObserver and
# fell through to the window listener, so it stayed green — vacuous.
[[mutation]]
label = "S3 the arrows do not follow a resize"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
old = '''
if (window.ResizeObserver) new ResizeObserver(settle).observe(stage);
else window.addEventListener('resize', settle);'''
new = '''
'''
[[mutation]]
label = "S4 the pan runs backwards (the picture flees the pointer)"
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;'''
new = '''
stage.scrollLeft = drag.l + dx;'''
[[mutation]]
label = "S4 no drag threshold (a jittery click pans)"
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 */'''
new = '''
'''
[[mutation]]
label = "S4 the picture is draggable again"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''alt="{{ file }}" draggable="false">'''
new = '''alt="{{ file }}">'''
[[mutation]]
label = "S4 no grab cursor on a pannable 1:1 picture"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
.stage-one .vstage.can-pan{cursor:grab;user-select:none}'''
new = '''
.stage-one .vstage.can-pan{user-select:none}'''
[[mutation]]
label = "S4 the stage reveal back inside the scrolled content (a pan carries it off)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
.review-body > .reveal{position:absolute;top:14px;left:14px;z-index:5;'''
new = '''
.review-body > .reveal{position:absolute;top:14px;left:14px;z-index:-1;'''
# ---- the heid code-review fold ("VÍGUNDR")
[[mutation]]
label = "1:1 centres a large picture (its start side can never be scrolled to)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_every_pixel_of_a_large_picture_is_reachable"
old = '''
.stage-one .vstage.is-img{overflow:auto;padding:0;justify-content:flex-start;align-items:flex-start}'''
new = '''
.stage-one .vstage.is-img{overflow:auto;padding:0}'''
[[mutation]]
label = "a buttonless hover continues a press released outside the stage"
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; }'''
new = '''
'''
[[mutation]]
label = "no pointer capture (a pan dies at the stage's edge)"
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) {}'''
new = '''
'''
[[mutation]]
label = "stacked, the arrows' fallback centres on stage AND rail"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_before_placement_the_arrows_never_sit_over_the_rail_on_a_narrow_screen"
old = '''
@media (max-width:900px){.vnav{top:30vh}}'''
new = '''
'''
[[mutation]]
label = "Fit shifts the picture off-centre (object-position), cropping it"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_fit_fills_the_stage_up_or_down"
old = '''
.vstage.is-img img{width:100%;height:100%;object-fit:contain;'''
new = '''
.vstage.is-img img{width:100%;height:100%;object-fit:contain;object-position:-100px 50%;'''
[[mutation]]
label = "the arrows are not centred on the stage"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
old = '''
a.style.top = (s.top - o.top + s.height / 2) + 'px';'''
new = '''
a.style.top = (s.top - o.top + s.height / 4) + 'px';'''
[[mutation]]
label = "a booth.fit read that throws raises out of the head script"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
try {
if (localStorage.getItem('booth.fit') === 'one') d.classList.add('stage-one');
} catch (e) {}'''
new = '''
if (localStorage.getItem('booth.fit') === 'one') d.classList.add('stage-one');'''
[[mutation]]
label = "the drag threshold is per axis, not total (a 3,3 diagonal pans nothing)"
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 */'''
new = '''
if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return;'''
# ---- the heid bug-hunt fold ("ÞREKJUR"). Accepted, not rowed: no `touch-action`
# (on touch the stage scrolls natively and the pan yields on pointercancel);
# dragstart preventDefault beside draggable=false (defence in depth, one layer
# alone holds).
[[mutation]]
label = "the stage reveal shows with scripts off (and does nothing)"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_stage_reveal_never_shows_without_js_and_keeps_the_fit_shadow"
old = '''aria-label="reveal {{ file }}" hidden>'''
new = '''aria-label="reveal {{ file }}">'''
[[mutation]]
label = "a revealed review picture loses Fit's shadow"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_stage_reveal_never_shows_without_js_and_keeps_the_fit_shadow"
old = '''
.review .vstage.is-img.is-blurred.revealed img,.reveal-all .review .vstage.is-img.is-blurred img{'''
new = '''
.review .vstage.is-img.is-blurred.revealed-OFF img,.reveal-all .review .vstage.is-img.is-blurred img{'''
[[mutation]]
label = "a stage mode chosen in another tab does not reach this one"
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;'''
new = '''
return;'''
[[mutation]]
label = "the drag threshold drops to 3px"
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 */'''
new = '''
if (dx * dx + dy * dy < 9) return; /* under 4px in all: a click */'''
[[mutation]]
label = "a storage write that throws cuts the click short (buttons never update)"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
try {
if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.removeItem('booth.fit');
} catch (e) {}'''
new = '''
if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.removeItem('booth.fit');'''
[[mutation]]
label = "the Fit button's pressed state is never drawn"
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');'''
new = '''
'''
# ---- groa's retry supplement (code review)
[[mutation]]
label = "S1 Fit loses its drop shadow"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_fit_fills_the_stage_up_or_down"
old = '''
.vstage.is-img img{width:100%;height:100%;object-fit:contain;
filter:drop-shadow(0 10px 24px rgb(0 0 0 / .32))}'''
new = '''
.vstage.is-img img{width:100%;height:100%;object-fit:contain}'''
[[mutation]]
label = "S2 the toggle hides, on load, for a picture larger than the stage"
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() { pannable(); onSettle(); }'''
new = '''
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/_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');'''
new = '''
if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.setItem('booth.fit', 'fit');'''
[[mutation]]
label = "S4 pan only when BOTH axes overflow"
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);'''
new = '''
(stage.scrollWidth > stage.clientWidth && stage.scrollHeight > stage.clientHeight);'''
# ---- groa's retry supplement (bug hunt): classic scrollbars
[[mutation]]
label = "a press on the stage's scrollbar starts a pan"
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 ||
e.clientY - r.top - stage.clientTop >= stage.clientHeight) return;'''
new = '''
'''
[[mutation]]
label = "the arrows clamp to the border box (the next one sits under a classic scrollbar)"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_a_classic_scrollbar_is_neither_under_an_arrow_nor_a_pan"
old = '''
var cl = s.left + stage.clientLeft, cr = cl + stage.clientWidth;'''
new = '''
var cl = s.left, cr = s.right;'''