feat(review): the review stage fills, its arrows sit at the picture, 1:1 pans (r2c)

The operator: "fit and 1:1 modes as well as moving the forward and back
arrows closer to the edge of the image ... mouse click and pan for 1:1
mode if it exceeds page width (defeat drag drop of image)". Ruled: "Fit
may enlarge."

- Fit: the picture's box is the stage's inner box, and object-fit: contain
  draws it whole at the largest size that fits, up or down, never
  cropped. It works with or without JS. 1:1 is natural pixels.
- The Fit | 1:1 toggle shows for every picture; the per-picture hide is
  gone. It stays hidden without JS.
- The mode persists as `stage-one` on <html>, set by the head script
  before the stage exists, so a 1:1 reel never paints a stage in Fit.
  Anything stored but "one" reads as Fit. Storage never raises.
- The arrows sit wholly outside the DRAWN picture (near edge 8px),
  clamped 8px inside the stage. They sit over the picture only when it
  spans the stage, and never over the rail. They are re-placed on load,
  resize, mode switch and 1:1 scroll, and keep their CSS spot until the
  drawn box is known.
- 1:1 drag-to-pan when the picture overflows either axis: the picture
  follows the pointer, a 4px threshold, pointer capture, grab/grabbing.
  The picture is draggable=false. The stage's reveal button moves out of
  the scrolled content to sit over the stage (a pan carried it off), so
  no control is a pan source.

Contract docs/contracts/r2c_review_stage.contract.md (heid contract
panel 4/4 folded; it changed the no-flash mechanism). Declared test
changes: the Nyx stage-edge arrow test is replaced; the stage class and
the toggle's `hidden` are updated. tests/mutations/r2c.toml 16/16. 803
passed.
This commit is contained in:
vh
2026-09-24 00:20:15 -07:00
parent 0781aa5ee5
commit 7151a45ec2
6 changed files with 667 additions and 58 deletions
+154
View File
@@ -0,0 +1,154 @@
# R2c — the review stage: every falsifier the contract claims
# (docs/contracts/r2c_review_stage.contract.md), and the change each forbids.
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/view.html"
test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
old = '''
toggle.hidden = false;'''
new = '''
toggle.hidden = img.naturalWidth <= stage.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/view.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) return;'''
new = '''
var p = null;
if (!p) return;'''
[[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/view.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/view.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return;'''
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;'''