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
+14 -2
View File
@@ -248,11 +248,23 @@ new = '''e.target !== player) {'''
label = "the top-bar controls squeeze into multi-line stacks at phone width"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_review_and_doc_top_bars_fit_a_phone"
# r3 re-anchored this row. r3 added a Compare control to the review's bar and
# made a full bar WRAP at phone width (a fogged booth already overflowed it by
# 3px at 390). A wrapping bar never squeezes, so removing the no-wrap rules
# alone went VACUOUS: the property is now held by both. The defeating change is
# losing both; r3.toml rows the wrap on its own.
old = '''
.blur-all button,.reveal-all-btn{white-space:nowrap}
@media (max-width:600px){.reveal-all-btn .ra-note{display:none}}'''
@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}}'''
new = '''
'''
@media (max-width:600px){.vcompare-l{display:none}}'''
[[mutation]]
label = "the Desk strip under another booth's reveal is lifted by a whisker (blur(0px) is not blurred)"
+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 ||
+429
View File
@@ -0,0 +1,429 @@
# R3 — compare: every falsifier the contract claims
# (docs/contracts/r3_compare.contract.md), and the change each forbids.
unit = "compare: two picked rels side by side, linked stepping, synced pan, flag the winner"
# ---- C1: the route and the pair
[[mutation]]
label = "C1 the conjunction loses containment (an outside symlink in the ring opens)"
file = "booth/app.py"
test = "tests/test_compare.py::test_an_outside_symlink_in_the_ring_is_404"
old = '''
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:'''
new = '''
if rel not in ring:'''
[[mutation]]
label = "C1 the conjunction loses the ring (a doc or a sidecar opens as a side)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_bad_side_is_a_404"
old = '''
if rel not in ring:
raise HTTPException(status_code=404, detail="no such item")
return rel'''
new = '''
return rel'''
[[mutation]]
label = "C1 a missing side is FastAPI's 422 (no default)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_missing_param_is_404_not_422"
old = '''def booth_compare(request: Request, name: str, a: str = "", b: str = "",'''
new = '''def booth_compare(request: Request, name: str, a: str, b: str,'''
[[mutation]]
label = "C1 a look records only A"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_look_records_both_seen"
old = '''
record_seen(booth, b, items)'''
new = '''
'''
[[mutation]]
label = "C1 the look is recorded above the 404s (a bad pair holds a booth open)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_look_records_both_seen"
old = '''
booth = resolve_booth(name)
items = booth_items(booth)
ring = review_chain(items)
a = _compare_side(booth, ring, a)'''
new = '''
booth = resolve_booth(name)
record_view(booth)
items = booth_items(booth)
ring = review_chain(items)
a = _compare_side(booth, ring, a)'''
[[mutation]]
label = "C6 compare does not carry data-booth (Reveal all and its restore bail)"
file = "booth/templates/compare.html"
test = "tests/test_compare.py::test_compare_carries_data_booth"
old = '''{% block html_attrs %} data-booth="{{ name }}"{% endblock %}'''
new = '''{% block html_attrs %}{% endblock %}'''
# ---- C2/C3: picking and stepping, server-built
[[mutation]]
label = "C3 linked steps do not keep the distance (B lands one after A)"
file = "booth/app.py"
test = "tests/test_compare.py::test_linked_steps_keep_the_distance_and_wrap"
old = ''' "both_next": url(ring[(ia + 1) % n], ring[(ib + 1) % n]),'''
new = ''' "both_next": url(ring[(ia + 1) % n], ring[(ia + 2) % n]),'''
[[mutation]]
label = "C3 linked steps stop at the end instead of wrapping"
file = "booth/app.py"
test = "tests/test_compare.py::test_linked_steps_keep_the_distance_and_wrap"
old = ''' "both_next": url(ring[(ia + 1) % n], ring[(ib + 1) % n]),'''
new = ''' "both_next": url(ring[min(ia + 1, n - 1)], ring[min(ib + 1, n - 1)]),'''
[[mutation]]
label = "INV-1 a link is keyed by ordinal"
file = "booth/app.py"
test = "tests/test_compare.py::test_the_urls_are_keyed_by_rel"
old = ''' u = f"/b/{name_url}/compare?a={quote(x, safe='/')}&b={quote(y, safe='/')}"'''
new = ''' u = f"/b/{name_url}/compare?a={quote(x, safe='/')}&b={quote(y, safe='/')}&i={by_rel[x].ordinal}"'''
[[mutation]]
label = "INV-1 a rel is not url-quoted in a link"
file = "booth/app.py"
test = "tests/test_compare.py::test_the_urls_are_keyed_by_rel"
old = ''' u = f"/b/{name_url}/compare?a={quote(x, safe='/')}&b={quote(y, safe='/')}"'''
new = ''' u = f"/b/{name_url}/compare?a={x}&b={y}"'''
[[mutation]]
label = "C2 the view state does not ride the links"
file = "booth/app.py"
test = "tests/test_compare.py::test_view_state_rides_the_links"
old = '''
if not linked:
u += "&link=0"
return u'''
new = '''
return u'''
[[mutation]]
label = "C2 an unknown side reads as A"
file = "booth/app.py"
test = "tests/test_compare.py::test_view_state_rides_the_links"
old = ''' side_a = side == "a"'''
new = ''' side_a = side not in ("", "b")'''
[[mutation]]
label = "C2 the review's Compare does not wrap (the last item compares with itself)"
file = "booth/app.py"
test = "tests/test_compare.py::test_the_review_offers_compare_with_the_next_item"
old = ''' f"&b={quote(ring[(pos + 1) % len(ring)], safe='/')}"),'''
new = ''' f"&b={quote(ring[min(pos + 1, len(ring) - 1)], safe='/')}"),'''
# ---- C5: the regions and the JS-off flag landing
[[mutation]]
label = "C5 the side regions share one id (B's control becomes A's after a save)"
file = "booth/templates/compare.html"
test = "tests/test_compare.py::test_no_data_region_repeats"
old = '''<div class="cmp-flag" data-region="flag-{{ key }}">'''
new = '''<div class="cmp-flag" data-region="flag">'''
[[mutation]]
label = "C5 back=compare lands on a pair outside the ring (no ring check)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_flag_without_js_lands_on_the_same_pair"
old = '''
if a in ring and b in ring:'''
new = '''
if True:'''
[[mutation]]
label = "C5 the landing echoes the form's side instead of mapping it"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_flag_without_js_lands_on_the_same_pair"
old = '''
if form.get("side") == "a":
url += "&side=a"'''
new = '''
if form.get("side"):
url += "&side=" + str(form.get("side"))'''
[[mutation]]
label = "C5 the landing carries a fragment"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_flag_without_js_lands_on_the_same_pair"
old = '''
return RedirectResponse(url=url, status_code=303)
return RedirectResponse(url=f"{base}#{anchor}", status_code=303)'''
new = '''
return RedirectResponse(url=url + "#" + anchor, status_code=303)
return RedirectResponse(url=f"{base}#{anchor}", status_code=303)'''
# ---- C4: the stages
[[mutation]]
label = "C4 the sides stack on a wide screen (compare's break is not the review's 900px)"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
# Not the column count: each side spans every row (subgrid), so a single
# declared column still auto-places the second side into an implicit column
# beside the first — that row was vacuous. The break is what a slip moves.
old = '''
@media (max-width:900px){
.viewer.review.compare{display:block}'''
new = '''
@media (max-width:1600px){
.viewer.review.compare{display:block}'''
[[mutation]]
label = "C4 a caption takes its height from one stage only (Fit draws the two at two scales)"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
old = '''
.cmp-side{display:grid;grid-row:1 / -1;grid-template-rows:subgrid;min-width:0;min-height:0}'''
new = '''
.cmp-side{display:grid;grid-row:1 / -1;grid-template-rows:auto minmax(0,1fr) auto;min-width:0;min-height:0}'''
[[mutation]]
label = "C4 stacked, a stage keeps the review's 60vh"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
old = '''
.review.compare .vstage{height:45vh}'''
new = '''
'''
[[mutation]]
label = "C4 Z is not bound (the mode key is missing on compare)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_one_mode_for_both_and_for_the_review"
old = '''
else if ((k === 'z' || k === 'Z') && mode) mode.flip();'''
new = '''
'''
[[mutation]]
label = "C4 no synced pan"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_synced_pan_lands_on_the_same_crop"
old = '''
sync(s.el, other);'''
new = '''
'''
[[mutation]]
label = "C4 the sync copies the pixel offset, not the fraction"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_synced_pan_by_fraction_for_different_sizes"
old = '''
if (fx > 0 && tx > 0) l = from.scrollLeft / fx * tx;
if (fy > 0 && ty > 0) t = from.scrollTop / fy * ty;'''
new = '''
if (fx > 0 && tx > 0) l = from.scrollLeft;
if (fy > 0 && ty > 0) t = from.scrollTop;'''
[[mutation]]
label = "C4 the synced-pan loop guard is gone (a sync echoes back and walks the side put)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_synced_pan_by_fraction_for_different_sizes"
old = '''
if (mine) {
s.el.__synced = null;
if (Math.abs(s.el.scrollLeft - mine.l) < 1 && Math.abs(s.el.scrollTop - mine.t) < 1) return;
}'''
new = '''
'''
# ---- C2/C3: the view state and the keys, in the page
[[mutation]]
label = "C3 unlinked, an arrow still moves both sides"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_unlinked_moves_only_the_active_side_and_the_strip_picks_it"
old = '''
var which = (linked ? 'both' : active) + (dir < 0 ? '-prev' : '-next');'''
new = '''
var which = 'both' + (dir < 0 ? '-prev' : '-next');'''
[[mutation]]
label = "C2 the unlinked state does not survive a step (it is not in the next URL)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_unlinked_moves_only_the_active_side_and_the_strip_picks_it"
old = '''
if (!linked) parts.push('link=0');'''
new = '''
'''
[[mutation]]
label = "C2 X does not swap the active side"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_unlinked_moves_only_the_active_side_and_the_strip_picks_it"
old = '''
else if (k === 'x' || k === 'X') setActive(active === 'a' ? 'b' : 'a');'''
new = '''
'''
[[mutation]]
label = "C2 the active stage does not wear the reticle"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_unlinked_moves_only_the_active_side_and_the_strip_picks_it"
old = '''
.cmp-side.is-active>.cmp-stagewrap::after,.film-f.is-active::after{content:"";'''
new = '''
.film-f.is-active::after{content:"";'''
[[mutation]]
label = "C2 a strip click always replaces B (not the side active now)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_save_keeps_the_active_side"
old = '''
var pick = f.getAttribute('data-pick-' + active);
if (!pick) return;'''
new = '''
var pick = f.getAttribute('data-pick-b');
if (!pick) return;'''
[[mutation]]
label = "C2 a press on a stage does not make its side active"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_save_keeps_the_active_side"
old = '''
s.el.addEventListener('pointerdown', function () { setActive(s.k); });'''
new = '''
'''
[[mutation]]
label = "C2 the active side is not written back into the URL (a reload forgets it)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_save_keeps_the_active_side"
old = '''
try { history.replaceState(history.state, '', withState(location.pathname + location.search)); } catch (e) {}'''
new = '''
'''
[[mutation]]
label = "C3 a held modifier does not make the keys inert"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
old = '''
if (isEditable(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
var k = e.key;'''
new = '''
if (isEditable(e.target)) return;
var k = e.key;'''
[[mutation]]
label = "C3 Space on a focused control steps instead of pressing it"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
old = '''
if (e.target.closest && e.target.closest('button, a, summary, video, audio')) return;'''
new = '''
'''
[[mutation]]
label = "INV-6 the review has no C key"
file = "booth/templates/view.html"
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
old = '''
else if (e.key === 'c' || e.key === 'C') { e.preventDefault(); window.location.href = COMPARE; }'''
new = '''
'''
# ---- C5: judging in place
[[mutation]]
label = "C5 a stage is a region (a save swaps it, and a playing track restarts)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_flags_A_in_place_and_the_stages_survive"
old = '''<div class="vstage{% if s.kind == 'image' %} is-img{% endif %}'''
new = '''<div data-region="stage-{{ key }}" class="vstage{% if s.kind == 'image' %} is-img{% endif %}'''
[[mutation]]
label = "C5 the B key presses the first flag button on the page (A's)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_flags_A_in_place_and_the_stages_survive"
old = '''
var btn = document.getElementById('cmp-flag-' + k.toLowerCase());'''
new = '''
var btn = document.querySelector('.cmp-flag .vflag-btn');'''
# ---- C6: blur
[[mutation]]
label = "C6 Reveal all leaves the per-side reveals standing"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_blur_is_honest_on_both_sides"
old = '''
.reveal-all .cmp-reveal{display:none}'''
new = '''
'''
[[mutation]]
label = "C6 a side's reveal lifts A's blur whichever side it sits on"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_blur_is_honest_on_both_sides"
old = '''
var stage = sides[btn.getAttribute('data-side')].querySelector('.vstage');'''
new = '''
var stage = sides.a.querySelector('.vstage');'''
[[mutation]]
label = "C6 compare's root is not a .review (the blur rules do not reach its stages)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_blur_is_honest_on_both_sides"
old = '''<div class="viewer review compare" data-linked'''
new = '''<div class="viewer compare" data-linked'''
# ---- INV-4: without JS
[[mutation]]
label = "INV-4 the JS-only Linked toggle shows without JS (inline-flex beats [hidden])"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_without_js_every_judgment_and_step_still_works"
old = '''
.cmp-link[hidden]{display:none}'''
new = '''
'''
[[mutation]]
label = "INV-4 without JS a strip frame goes nowhere useful (links the review instead)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_without_js_every_judgment_and_step_still_works"
old = '''
href="{{ x.pick }}" data-rel'''
new = '''
href="/b/{{ name_url }}/view?f={{ x.url }}" data-rel'''
# ---- the top bar at phone width (the review's, and compare's)
[[mutation]]
label = "at phone width a full top bar scrolls the page sideways instead of wrapping"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_review_and_doc_top_bars_fit_a_phone"
old = '''
@media (max-width:600px){.vbar{flex-wrap:wrap;row-gap:6px}}'''
new = '''
'''
[[mutation]]
label = "compare's top bar crushes its controls at phone width (the Fit | 1:1 toggle to 2px)"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
old = '''
@media (max-width:600px){.vbar{flex-wrap:wrap;row-gap:6px}}'''
new = '''
'''
# Accepted, not rowed: the mode's `onChange: settleAll` (re-deciding which stage
# can pan) is redundant with compare's ResizeObserver — 1:1 drops the stage's
# padding, so every mode change resizes the stage's content box and the
# observer settles both. Its row stayed green; each alone holds.
#
# Accepted, not rowed: the `booth:swapped` restate is redundant with the URL
# rewrite — the in-place client re-fetches `location.href`, which already
# carries `side`, so the fresh strip renders the active side itself. Each alone
# holds; the save test (a_save_keeps_the_active_side) sees the pair.