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.
360 lines
14 KiB
TOML
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;'''
|