fix(review): the heid code-review and bug-hunt panels on r2c, folded (both 4/4 with retries)

- 1:1 start-aligns. The centred flex item overflowed both sides and the
  start was unreachable; measured, a 3000px picture hid its leftmost
  980px. Auto margins still centre a small picture.
- Drag lifecycle: a move with no button ends the drag, so a press
  released outside the stage never pans on a later hover. Capture is now
  load-bearing in a test. The threshold is 4px of total movement.
- A press on the stage's own scrollbar is never a pan. The arrows clamp
  to the stage's client box, so they are never under a classic
  scrollbar. The test runs a browser without --hide-scrollbars and
  asserts the gutter exists.
- Stacked, the arrows' CSS spot is the stage's centre (30vh), set in
  view.html because base.html lost to the page's later rule.
- The stage reveal is `hidden` until bound, and keeps Fit's drop shadow
  when revealed. A blurred picture composes blur() drop-shadow().
- The mode follows another tab. A failed or unknown size returns the
  arrows to their CSS spot.
- Tests: object-position, vertical centring, the Fit half of
  aria-pressed, a storage read that throws, a large picture's toggle,
  Fit forgetting 1:1, single-axis pan.
- Declared: the r2b reveal test reads "no blur" (the shadow stays), and
  the r2_flow 360px-offset row is retired.

Mutation tables 137/137 across four. 810 passed.
This commit is contained in:
vh
2026-09-24 00:20:15 -07:00
parent 7151a45ec2
commit 7c879e6038
6 changed files with 546 additions and 46 deletions
+12 -3
View File
@@ -660,6 +660,10 @@
.reveal-all .tray-item.is-blurred img,.reveal-all .film-f.is-blurred img, .reveal-all .tray-item.is-blurred img,.reveal-all .film-f.is-blurred img,
.reveal-all .review .vstage.is-blurred img,.reveal-all .review .vstage.is-blurred video{filter:none} .reveal-all .review .vstage.is-blurred img,.reveal-all .review .vstage.is-blurred video{filter:none}
.reveal-all .item.blurred .reveal,.reveal-all #vreveal{display:none} .reveal-all .item.blurred .reveal,.reveal-all #vreveal{display:none}
/* a revealed review picture keeps Fit's shadow on its own pixels (r2c) */
.review .vstage.is-img.is-blurred.revealed img,.reveal-all .review .vstage.is-img.is-blurred img{
filter:drop-shadow(0 10px 24px rgb(0 0 0 / .32))}
#vreveal[hidden]{display:none}
/* The two booth-wide blur controls: the fog (server state, a form) and the /* The two booth-wide blur controls: the fog (server state, a form) and the
reveal (this tab only, a button). Same quiet chip as the rest of the chrome. */ reveal (this tab only, a button). Same quiet chip as the rest of the chrome. */
.blur-all{display:inline-block;margin:0} .blur-all{display:inline-block;margin:0}
@@ -755,9 +759,11 @@
.vstage.is-img img{width:100%;height:100%;object-fit:contain; .vstage.is-img img{width:100%;height:100%;object-fit:contain;
filter:drop-shadow(0 10px 24px rgb(0 0 0 / .32))} filter:drop-shadow(0 10px 24px rgb(0 0 0 / .32))}
/* 1:1 — the pixel truth. The mode is `stage-one` on <html> (set before the /* 1:1 — the pixel truth. The mode is `stage-one` on <html> (set before the
stage exists). Auto margins centre a small picture and still let a large stage exists). START alignment, never centring: a centred flex item larger
one scroll from its true edge. */ than its scroll box overflows BOTH sides, and the start side can never be
.stage-one .vstage.is-img{overflow:auto;padding:0} scrolled to (heid code-review; measured: a 3000px picture hid its leftmost
980px). Auto margins still centre a picture smaller than the stage. */
.stage-one .vstage.is-img{overflow:auto;padding:0;justify-content:flex-start;align-items:flex-start}
.stage-one .vstage.is-img img{flex:none;width:auto;height:auto;max-width:none;max-height:none;margin:auto;object-fit:fill} .stage-one .vstage.is-img img{flex:none;width:auto;height:auto;max-width:none;max-height:none;margin:auto;object-fit:fill}
/* r2c S4: a 1:1 picture larger than the stage pans by dragging */ /* r2c S4: a 1:1 picture larger than the stage pans by dragging */
.stage-one .vstage.can-pan{cursor:grab;user-select:none} .stage-one .vstage.can-pan{cursor:grab;user-select:none}
@@ -779,6 +785,9 @@
.review .vstage audio{width:min(640px,92%)} .review .vstage audio{width:min(640px,92%)}
.review .vstage video{max-width:100%;max-height:100%} .review .vstage video{max-width:100%;max-height:100%}
.review .vstage.is-blurred img,.review .vstage.is-blurred video{filter:blur(22px)} .review .vstage.is-blurred img,.review .vstage.is-blurred video{filter:blur(22px)}
/* `filter` is one property: a blur rule REPLACES the shadow unless it says
both (heid code-review, groa). A blurred review picture keeps Fit's shadow. */
.review .vstage.is-img.is-blurred img{filter:blur(22px) drop-shadow(0 10px 24px rgb(0 0 0 / .32))}
.review .vstage.is-blurred.revealed img,.review .vstage.is-blurred.revealed video{filter:none} .review .vstage.is-blurred.revealed img,.review .vstage.is-blurred.revealed video{filter:none}
.review-body > .reveal{position:absolute;top:14px;left:14px;z-index:5;cursor:pointer;font-family:var(--font-mono); .review-body > .reveal{position:absolute;top:14px;left:14px;z-index:5;cursor:pointer;font-family:var(--font-mono);
font-size:var(--size-micro);padding:6px 9px;border-radius:var(--radius-md);border:1px solid rgb(255 255 255 / .16); font-size:var(--size-micro);padding:6px 9px;border-radius:var(--radius-md);border:1px solid rgb(255 255 255 / .16);
+45 -10
View File
@@ -55,7 +55,9 @@
{# The stage's reveal sits OVER the stage, outside its scrolled content {# The stage's reveal sits OVER the stage, outside its scrolled content
(r2c): in 1:1 a panned picture would otherwise carry it out of view, and (r2c): in 1:1 a panned picture would otherwise carry it out of view, and
outside the stage it can never start a pan. #} outside the stage it can never start a pan. #}
{% if blurred %}<button type="button" class="reveal" id="vreveal" aria-label="reveal {{ file }}">👁 reveal — blur is cosmetic</button>{% endif %} {# JS-only, so `hidden` until the script binds it (heid bug-hunt: shown with
scripts off, it did nothing) — the toggle's own pattern. #}
{% if blurred %}<button type="button" class="reveal" id="vreveal" aria-label="reveal {{ file }}" hidden>👁 reveal — blur is cosmetic</button>{% endif %}
{% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next">›</a>{% endif %} {% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next">›</a>{% endif %}
<aside class="vrail" id="rail" data-region="rail" aria-label="your judgment"> <aside class="vrail" id="rail" data-region="rail" aria-label="your judgment">
@@ -156,6 +158,11 @@
parking the arrow 360px in from the edge of a phone. */ parking the arrow 360px in from the edge of a phone. */
.vprev{left:0}.vnext{right:0} .vprev{left:0}.vnext{right:0}
@media (min-width:901px){.vnext{right:360px}} @media (min-width:901px){.vnext{right:360px}}
/* Stacked (<=900px) the stage is the body's first 60vh, so its centre is 30vh
down: the arrows' spot before the script places them (JS off, loading,
failed), never over the rail below (heid code-review). HERE, after .vnav:
in base.html this page's own later rule silently won it (the Nyx trap). */
@media (max-width:900px){.vnav{top:30vh}}
.vcap{margin-top:10px;max-height:30vh;overflow-y:auto;font-size:var(--size-sm);line-height:var(--leading-body); .vcap{margin-top:10px;max-height:30vh;overflow-y:auto;font-size:var(--size-sm);line-height:var(--leading-body);
color:var(--text-body);white-space:pre-wrap} color:var(--text-body);white-space:pre-wrap}
@media print{.vcap{max-height:none;overflow:visible}.vnav{display:none}} @media print{.vcap{max-height:none;overflow:visible}.vnav{display:none}}
@@ -178,13 +185,13 @@
/* The DRAWN picture's left and right edges, in viewport px, or null until /* The DRAWN picture's left and right edges, in viewport px, or null until
they are known (still loading, or failed): the arrows then keep their CSS they are known (still loading, or failed): the arrows then keep their CSS
spot. Fit: the object-fit: contain box. 1:1: what shows in the stage. */ spot. The object-fit: contain box — which in 1:1 (scale 1) IS the
picture's own box; where it runs past the stage, the clamp in place()
keeps the arrows inside. */
function drawn() { function drawn() {
var s = stage.getBoundingClientRect();
if (img) { if (img) {
if (!img.naturalWidth) return null; if (!img.naturalWidth) return null;
var b = img.getBoundingClientRect(); var b = img.getBoundingClientRect();
if (d.classList.contains('stage-one')) return {l: Math.max(b.left, s.left), r: Math.min(b.right, s.right)};
var k = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight), w = img.naturalWidth * k; var k = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight), w = img.naturalWidth * k;
return {l: b.left + (b.width - w) / 2, r: b.left + (b.width + w) / 2}; return {l: b.left + (b.width - w) / 2, r: b.left + (b.width + w) / 2};
} }
@@ -199,12 +206,21 @@
the stage (operator: "unless the image spans the entire width"). */ the stage (operator: "unless the image spans the entire width"). */
function place() { function place() {
var p = drawn(); var p = drawn();
if (!p) return; if (!p) {
/* unknown (loading, failed): back to the CSS spot, never a stale one */
[prevA, nextA].forEach(function (a) {
if (a) { a.classList.remove('is-placed'); a.style.left = ''; a.style.top = ''; }
});
return;
}
var s = stage.getBoundingClientRect(), o = rbody.getBoundingClientRect(); var s = stage.getBoundingClientRect(), o = rbody.getBoundingClientRect();
/* the stage's CLIENT box: a classic scrollbar is not stage an arrow may
sit on (heid bug-hunt, groa — the border box put the next arrow under it) */
var cl = s.left + stage.clientLeft, cr = cl + stage.clientWidth;
[[prevA, -1], [nextA, 1]].forEach(function (pair) { [[prevA, -1], [nextA, 1]].forEach(function (pair) {
var a = pair[0]; var a = pair[0];
if (!a) return; if (!a) return;
var w = a.offsetWidth, lo = s.left + 8, hi = s.right - 8 - w; var w = a.offsetWidth, lo = cl + 8, hi = cr - 8 - w;
var x = pair[1] < 0 ? p.l - 8 - w : p.r + 8; var x = pair[1] < 0 ? p.l - 8 - w : p.r + 8;
x = Math.max(lo, Math.min(hi, x)); x = Math.max(lo, Math.min(hi, x));
a.classList.add('is-placed'); a.classList.add('is-placed');
@@ -242,6 +258,15 @@
}; };
toggle.hidden = false; toggle.hidden = false;
show(); show();
/* A mode chosen in another tab moves this one (the theme's rule). */
window.addEventListener('storage', function (e) {
if (e.key !== 'booth.fit' && e.key !== null) return;
var one = false;
try { one = localStorage.getItem('booth.fit') === 'one'; } catch (x) {}
d.classList.toggle('stage-one', one);
show();
settle();
});
bFit.addEventListener('click', function () { setMode(false); }); bFit.addEventListener('click', function () { setMode(false); });
bOne.addEventListener('click', function () { setMode(true); }); bOne.addEventListener('click', function () { setMode(true); });
img.addEventListener('load', settle); img.addEventListener('load', settle);
@@ -255,14 +280,24 @@
var drag = null; var drag = null;
stage.addEventListener('pointerdown', function (e) { stage.addEventListener('pointerdown', function (e) {
if (e.button !== 0 || !pannable()) return; if (e.button !== 0 || !pannable()) return;
/* a press on the stage's own scrollbar is the scrollbar's, not a pan
(heid bug-hunt, groa: the pan fought the thumb, backwards) */
var r = stage.getBoundingClientRect();
if (e.clientX - r.left - stage.clientLeft >= stage.clientWidth ||
e.clientY - r.top - stage.clientTop >= stage.clientHeight) return;
/* nothing interactive lives in the stage today (its reveal sits over
it); this keeps a future control's click its own */
if (e.target.closest && e.target.closest('button, a, input, textarea, select, summary')) return; if (e.target.closest && e.target.closest('button, a, input, textarea, select, summary')) return;
drag = {x: e.clientX, y: e.clientY, l: stage.scrollLeft, t: stage.scrollTop, on: false, id: e.pointerId}; drag = {x: e.clientX, y: e.clientY, l: stage.scrollLeft, t: stage.scrollTop, on: false, id: e.pointerId};
}); });
stage.addEventListener('pointermove', function (e) { stage.addEventListener('pointermove', function (e) {
if (!drag || e.pointerId !== drag.id) return; if (!drag || e.pointerId !== drag.id) return;
/* No button held: the press ended where the stage could not hear it
(released outside before the drag began). Never pan on a hover. */
if (!(e.buttons & 1)) { endDrag(); return; }
var dx = e.clientX - drag.x, dy = e.clientY - drag.y; var dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if (!drag.on) { if (!drag.on) {
if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return; if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */
drag.on = true; drag.on = true;
stage.classList.add('is-grabbing'); stage.classList.add('is-grabbing');
try { stage.setPointerCapture(drag.id); } catch (x) {} try { stage.setPointerCapture(drag.id); } catch (x) {}
@@ -271,17 +306,16 @@
stage.scrollTop = drag.t - dy; stage.scrollTop = drag.t - dy;
e.preventDefault(); e.preventDefault();
}); });
var endDrag = function () { function endDrag() {
if (drag && drag.on) stage.classList.remove('is-grabbing'); if (drag && drag.on) stage.classList.remove('is-grabbing');
drag = null; drag = null;
}; }
stage.addEventListener('pointerup', endDrag); stage.addEventListener('pointerup', endDrag);
stage.addEventListener('pointercancel', endDrag); stage.addEventListener('pointercancel', endDrag);
} }
if (video) video.addEventListener('loadedmetadata', settle); if (video) video.addEventListener('loadedmetadata', settle);
if (window.ResizeObserver) new ResizeObserver(settle).observe(stage); if (window.ResizeObserver) new ResizeObserver(settle).observe(stage);
else window.addEventListener('resize', settle); else window.addEventListener('resize', settle);
stage.addEventListener('scroll', place);
/* Keep the current frame in view on the filmstrip — on load, and after an /* Keep the current frame in view on the filmstrip — on load, and after an
in-place save swaps the strip for a fresh one. Additive: without it the in-place save swaps the strip for a fresh one. Additive: without it the
@@ -297,6 +331,7 @@
/* Blur reveal on the stage — per-viewer, never persisted. Cosmetic, and /* Blur reveal on the stage — per-viewer, never persisted. Cosmetic, and
the button says so. */ the button says so. */
var rv = document.getElementById('vreveal'); var rv = document.getElementById('vreveal');
if (rv) rv.hidden = false;
if (rv) rv.addEventListener('click', function () { if (rv) rv.addEventListener('click', function () {
var on = document.getElementById('vstage').classList.toggle('revealed'); var on = document.getElementById('vstage').classList.toggle('revealed');
rv.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic'; rv.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic';
+61 -13
View File
@@ -32,9 +32,15 @@ assumptions:
the image shows at fit size"): the size changes, the promise (one picture at a the image shows at fit size"): the size changes, the promise (one picture at a
readable size, no judgment behind a script) holds. readable size, no judgment behind a script) holds.
- The drop shadow follows the picture's own pixels (`drop-shadow`), not the - The drop shadow follows the picture's own pixels (`drop-shadow`), not the
letterboxed box. letterboxed box, on every path: blurred (`blur() drop-shadow()`, because
`filter` is one property and a blur rule would replace the shadow),
revealed, and under Reveal all.
- **1:1 shows natural pixels**, centred when smaller than the stage and - **1:1 shows natural pixels**, centred when smaller than the stage and
scrollable when larger. The upscale softness in Fit is exactly why 1:1 exists scrollable when larger, with EVERY pixel reachable. The stage aligns to its
START edge in 1:1, and the picture's auto margins centre it when it is
smaller. A centred flex item larger than its scroll box overflows both
sides, and the start side can never be scrolled to (heid code-review,
measured: a 3000px picture hid its leftmost 980px). The upscale softness in Fit is exactly why 1:1 exists
and is always one click away. and is always one click away.
## S2 — the toggle is always there for a picture ## S2 — the toggle is always there for a picture
@@ -54,7 +60,10 @@ assumptions:
stage's CSS keys off it (`.stage-one .vstage`); the server renders the stage's CSS keys off it (`.stage-one .vstage`); the server renders the
stage as plain `vstage` (Fit is the default, no class needed). stage as plain `vstage` (Fit is the default, no class needed).
- A stored value other than `one` reads as Fit. A read that throws reads as - A stored value other than `one` reads as Fit. A read that throws reads as
Fit; a write that throws still applies the click. Never raises. Fit; a write that throws still applies the click, the pressed state
included. Never raises.
- A mode chosen in another tab moves every open review (the `storage` event),
as the theme does.
- The buttons' pressed state is drawn from the `<html>` class, the one - The buttons' pressed state is drawn from the `<html>` class, the one
record of the mode on the page (storage is its memory, off the page). record of the mode on the page (storage is its memory, off the page).
@@ -62,12 +71,14 @@ assumptions:
- **Each arrow sits wholly outside the picture's DRAWN edge, its near edge 8px - **Each arrow sits wholly outside the picture's DRAWN edge, its near edge 8px
from the picture**, vertically centred on the stage. The measure is always from the picture**, vertically centred on the stage. The measure is always
the DRAWN picture, never the file's natural size: in Fit, the `object-fit: the DRAWN picture, never the file's natural size: the `object-fit: contain`
contain` content box (from the natural size and the box); in 1:1, the part of content box (from the natural size and the box). In 1:1 (scale 1) that IS the
the picture visible in the stage (its box intersected with the stage's). A picture's own box, and where it runs past the stage the clamp keeps the arrows
inside. A
video's own box counts the same way; audio keeps the stage-edge arrows. video's own box counts the same way; audio keeps the stage-edge arrows.
- **Clamped to the stage**: an arrow never goes past the stage's edge (8px - **Clamped to the stage's CLIENT box**: an arrow never goes past the stage's
inset), never over the rail, never off the stage. When there is no room for edge (8px inset), never over the rail, never off the stage, and never under
a classic scrollbar (the client box excludes it). When there is no room for
it outside the drawn picture — the drawn picture spans, or nearly spans, the it outside the drawn picture — the drawn picture spans, or nearly spans, the
stage's width — it sits at the stage edge, over the picture. That is the stage's width — it sits at the stage edge, over the picture. That is the
ONLY case the arrows sit at the stage edge (the operator: "closer to the edge ONLY case the arrows sit at the stage edge (the operator: "closer to the edge
@@ -75,10 +86,17 @@ assumptions:
width"). width").
- Re-placed on picture load (or at once when it is already loaded), stage - Re-placed on picture load (or at once when it is already loaded), stage
resize (a `ResizeObserver`, which covers window resizes and the rail resize (a `ResizeObserver`, which covers window resizes and the rail
stacking), mode switch and 1:1 scroll. stacking) and mode switch. (Scrolling a 1:1 picture cannot move its drawn
horizontal edges past the clamp, so it needs no re-placement.)
- **Before the picture's size is known** (JS on, picture still loading), and if - **Before the picture's size is known** (JS on, picture still loading), and if
it fails to load, the arrows stay at today's stage-edge CSS; they move once it fails to load, the arrows stay at their CSS spot; they move once the drawn
the drawn box is known. Without JS they stay there. box is known. Without JS they stay there. If the size ever becomes unknown
again, a placed arrow returns to that spot rather than keeping a stale one.
- That CSS spot is the STAGE's vertical centre. When stacked (≤900px) that
is 30vh down: the stage is the body's first 60vh, and centring on the
whole body put the arrows over a tall rail (heid code-review). The rule
lives in view.html after `.vnav`, because a base.html rule loses to the
page's own later one.
- The anchors, their classes and their hrefs are unchanged (test_booth, - The anchors, their classes and their hrefs are unchanged (test_booth,
test_navigation, test_flow pin them). test_navigation, test_flow pin them).
@@ -89,12 +107,24 @@ assumptions:
`grabbing` while dragging, pointer capture. `grabbing` while dragging, pointer capture.
- **The picture follows the pointer** (the grab convention): a drag of +dx, - **The picture follows the pointer** (the grab convention): a drag of +dx,
+dy changes the stage's scroll by −dx, −dy. +dy changes the stage's scroll by −dx, −dy.
- A press that moves less than 4px is not a drag: nothing pans. - A press that moves less than 4px IN TOTAL (Euclidean) is not a drag:
nothing pans. A (3, 3) diagonal is 4.24px, so it pans.
- A press on the stage's own scrollbar is the scrollbar's, never a pan.
- The drag is CAPTURED once it begins, so it keeps panning past the stage's
edge. A press released outside the stage before the drag began never
becomes a pan: a move with no button held ends it.
- **Pan listens on the stage only**, and no control is in the stage's - **Pan listens on the stage only**, and no control is in the stage's
scrolled content: the arrows never were, and **the stage's own reveal scrolled content: the arrows never were, and **the stage's own reveal
button moves OUT of the stage to sit over it** (found building this: in button moves OUT of the stage to sit over it** (found building this: in
1:1 a panned picture carried the button out of view with it). So a control 1:1 a panned picture carried the button out of view with it). So a control
is never a pan source and keeps its own click, at any scroll. is never a pan source and keeps its own click, at any scroll.
- That reveal is JS-only, so it renders `hidden` until the script binds it,
the toggle's pattern (heid bug-hunt: shown with scripts off, it did
nothing). A revealed picture keeps Fit's drop shadow.
- Accepted: no `touch-action`. On touch the stage scrolls natively, and the
pan yields on `pointercancel`; `none` would take native touch scrolling
away. The `dragstart` `preventDefault` sits beside `draggable=false` as a
second layer.
- **The picture cannot be dragged away**: `draggable="false"` on `#vimg` and a - **The picture cannot be dragged away**: `draggable="false"` on `#vimg` and a
`dragstart` `preventDefault` on the stage. `dragstart` `preventDefault` on the stage.
- Fit, or a 1:1 picture that fits: no pan, no grab cursor. - Fit, or a 1:1 picture that fits: no pan, no grab cursor.
@@ -130,6 +160,22 @@ assumptions:
edge 8px (±2) from it; a landscape drawn as wide as the stage — arrows inside edge 8px (±2) from it; a landscape drawn as wide as the stage — arrows inside
the stage at its edges, over the picture, never over the rail; after a window the stage at its edges, over the picture, never over the rail; after a window
resize they follow the new drawn box. resize they follow the new drawn box.
- `in_one_to_one_every_pixel_of_a_large_picture_is_reachable`: at scroll
(0, 0) the picture's top-left is the stage's; at the far scroll its
bottom-right is; a small picture is centred.
- `a_pan_holds_past_the_stage_edge_and_never_starts_on_a_hover`: a drag
carried past the stage's edge keeps panning; a press released outside,
then a buttonless hover, pans nothing.
- `before_placement_the_arrows_never_sit_over_the_rail_on_a_narrow_screen`:
JS off at 390px with a rail taller than the stage, the arrows sit within
the stage; a picture that fails to load leaves them unplaced, with no error.
- `the_stage_reveal_never_shows_without_js_and_keeps_the_fit_shadow`.
- `a_stage_mode_chosen_in_one_tab_moves_the_others`.
- `a_classic_scrollbar_is_neither_under_an_arrow_nor_a_pan`: with a forced 15px
classic bar (asserted real first: headless Chromium hides scrollbars), the
next arrow sits inside the client box, and a press dispatched on the bar
pans nothing.
- `a_picture_that_overflows_one_axis_pans_along_it`.
- `in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away`: a picture - `in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away`: a picture
overflowing both axes in 1:1 — a drag of (+80, +60) changes the scroll by overflowing both axes in 1:1 — a drag of (+80, +60) changes the scroll by
(−80, −60); a 2px press pans nothing; a press on the stage's reveal button (−80, −60); a 2px press pans nothing; a press on the stage's reveal button
@@ -141,7 +187,9 @@ assumptions:
| test | today | after | why | | test | today | after | why |
|---|---|---|---| |---|---|---|---|
| test_flow_browser `test_the_next_arrow_clears_the_rail_only_beside_it` | the next arrow's computed `right` is 360px wide / 0px narrow | replaced by `the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage` (never over the rail; at the picture's edge) | the arrows now track the picture, not the stage edge (operator) | | test_flow_browser `test_the_next_arrow_clears_the_rail_only_beside_it` | the next arrow's computed `right` is 360px wide / 0px narrow | replaced by `the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage` (never over the rail; at the picture's edge) | the arrows now track the picture, not the stage edge (operator) |
| test_flow `test_only_a_picture_gets_the_fit_toggle_and_blur_stays_honest` | `id="vtoggle"` present for a picture (hidden by inline style); the stage is `class="vstage fit is-blurred"` | the same presence, now with the `hidden` attribute; the stage is `class="vstage is-blurred"` | the toggle is `hidden` until the script shows it; the mode moved to `<html>` (never flash) | | test_flow_browser `test_reveal_all_reveals_every_blurred_surface_and_survives_the_next_page` (r2b) | the revealed review stage's filter is `none` | it carries no blur (`blur(` absent); Fit's `drop-shadow` stays | the stage keeps its shadow on every path (S1) |
| tests/mutations/r2_flow.toml, the row on the next arrow's 360px offset | proved `.vnext{right:360px}` wide / 0 narrow | retired, with successors in r2c.toml | its test was replaced (row one above) |
| test_flow `test_only_a_picture_gets_the_fit_toggle_and_blur_stays_honest` | `id="vtoggle"` present for a picture (hidden by inline style); the stage is `class="vstage fit is-blurred"` | the same presence, now with the `hidden` attribute; the stage is `class="vstage is-img is-blurred"` | the toggle is `hidden` until the script shows it; the mode moved to `<html>` (never flash); `is-img` scopes the picture-only 1:1 rules |
## Out of scope ## Out of scope
+4 -10
View File
@@ -11,6 +11,10 @@
# covers them together); and `flagged_targets`' `error is None`, since # covers them together); and `flagged_targets`' `error is None`, since
# hydration already strips the target from a damaged mark. # hydration already strips the target from a damaged mark.
# #
# RETIRED (r2c S3, 2026-09-23): the row on the next arrow's 360px rail offset —
# the arrows now sit at the drawn picture, clamped inside the stage; the test was
# replaced as declared in r2c's contract, and its successors are in r2c.toml.
#
# RETIRED (r2b D1, 2026-09-23): four rows proving the facts-line row controls # RETIRED (r2b D1, 2026-09-23): four rows proving the facts-line row controls
# (visible at rest, compact, on the facts line) — the operator ruled those # (visible at rest, compact, on the facts line) — the operator ruled those
# controls hover-revealed over the preview strip, and their tests were replaced # controls hover-revealed over the preview strip, and their tests were replaced
@@ -139,16 +143,6 @@ test = "tests/test_flow_browser.py::test_the_standalone_marks_page_updates_in_pl
old = '''<div class="marks-panel" data-region="marks-panel">''' old = '''<div class="marks-panel" data-region="marks-panel">'''
new = '''<div class="marks-panel">''' new = '''<div class="marks-panel">'''
[[mutation]]
label = "C6 the next arrow's rail offset applies at phone width"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_next_arrow_clears_the_rail_only_beside_it"
old = ''' .vprev{left:0}.vnext{right:0}
@media (min-width:901px){.vnext{right:360px}}
'''
new = ''' .vprev{left:0}.vnext{right:360px}
'''
[[mutation]] [[mutation]]
label = "resolver: an entry that cannot be stat'd raises out of booth_items" label = "resolver: an entry that cannot be stat'd raises out of booth_items"
file = "booth/items.py" file = "booth/items.py"
+198 -3
View File
@@ -75,10 +75,10 @@ file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage" test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
old = ''' old = '''
var p = drawn(); var p = drawn();
if (!p) return;''' if (!p) {'''
new = ''' new = '''
var p = null; var p = null;
if (!p) return;''' if (!p) {'''
[[mutation]] [[mutation]]
label = "S3 the arrows track the file's natural width, not the drawn picture" label = "S3 the arrows track the file's natural width, not the drawn picture"
@@ -124,7 +124,7 @@ label = "S4 no drag threshold (a jittery click pans)"
file = "booth/templates/view.html" 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" test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = ''' old = '''
if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return;''' if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */'''
new = ''' new = '''
''' '''
@@ -152,3 +152,198 @@ old = '''
.review-body > .reveal{position:absolute;top:14px;left:14px;z-index:5;''' .review-body > .reveal{position:absolute;top:14px;left:14px;z-index:5;'''
new = ''' new = '''
.review-body > .reveal{position:absolute;top:14px;left:14px;z-index:-1;''' .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/view.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/view.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/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 (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/view.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/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 (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/view.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/view.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/view.html"
test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
old = '''
function settle() { place(); pannable(); }'''
new = '''
function settle() { place(); pannable(); 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"
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/view.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/view.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;'''
+226 -7
View File
@@ -522,7 +522,8 @@ def test_reveal_all_reveals_every_blurred_surface_and_survives_the_next_page(bro
ctx.close() ctx.close()
assert all("blur" in f for f in before), before assert all("blur" in f for f in before), before
assert after == ["none", "none"], after assert after == ["none", "none"], after
assert review[:2] == ["none", "none"] and "blur again" in review[2], review # revealed = no blur left; the review stage keeps Fit's drop shadow (r2c)
assert "blur(" not in review[0] and review[1] == "none" and "blur again" in review[2], review
assert "blur" in again, again assert "blur" in again, again
@@ -1100,7 +1101,8 @@ _GEOM = """() => {
const k = Math.min(box.w / nw, box.h / nh), dw = nw * k; const k = Math.min(box.w / nw, box.h / nh), dw = nw * k;
return {stage: r(st), inner: {w: st.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight), return {stage: r(st), inner: {w: st.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight),
h: st.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)}, h: st.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)},
img: box, nat: [nw, nh], fit: getComputedStyle(img).objectFit, scale: k, img: box, nat: [nw, nh], fit: getComputedStyle(img).objectFit, pos: getComputedStyle(img).objectPosition, scale: k,
filter: getComputedStyle(img).filter,
drawn: {l: box.l + (box.w - dw) / 2, r: box.l + (box.w + dw) / 2}, drawn: {l: box.l + (box.w - dw) / 2, r: box.l + (box.w + dw) / 2},
prev: document.querySelector('.vnav.vprev') && r(document.querySelector('.vnav.vprev')), prev: document.querySelector('.vnav.vprev') && r(document.querySelector('.vnav.vprev')),
next: document.querySelector('.vnav.vnext') && r(document.querySelector('.vnav.vnext')), next: document.querySelector('.vnav.vnext') && r(document.querySelector('.vnav.vnext')),
@@ -1134,7 +1136,8 @@ def test_fit_fills_the_stage_up_or_down(browser, live):
page.close() page.close()
for name, (fit, one) in got.items(): for name, (fit, one) in got.items():
assert abs(fit["img"]["w"] - fit["inner"]["w"]) <= 1 and abs(fit["img"]["h"] - fit["inner"]["h"]) <= 1, (name, fit) assert abs(fit["img"]["w"] - fit["inner"]["w"]) <= 1 and abs(fit["img"]["h"] - fit["inner"]["h"]) <= 1, (name, fit)
assert fit["fit"] == "contain", (name, fit["fit"]) assert fit["fit"] == "contain" and fit["pos"] == "50% 50%", (name, fit["fit"], fit["pos"])
assert fit["filter"].startswith("drop-shadow"), ("the shadow follows the picture's pixels", fit["filter"])
assert [round(one["img"]["w"]), round(one["img"]["h"])] == one["nat"], (name, one["img"], one["nat"]) assert [round(one["img"]["w"]), round(one["img"]["h"])] == one["nat"], (name, one["img"], one["nat"])
assert got["small.png"][0]["scale"] > 1.5 # enlarged assert got["small.png"][0]["scale"] > 1.5 # enlarged
assert got["big.png"][0]["scale"] < 1 # reduced assert got["big.png"][0]["scale"] < 1 # reduced
@@ -1144,11 +1147,13 @@ def test_the_toggle_shows_for_every_picture_and_never_without_js(browser, live):
"""r2c S2: the per-picture hide is gone — a picture that fits at natural """r2c S2: the per-picture hide is gone — a picture that fits at natural
size still gets Fit | 1:1. Audio gets none. Without JS it never shows.""" size still gets Fit | 1:1. Audio gets none. Without JS it never shows."""
base, root = live base, root = live
b = _stage_set(root, {"small.png": (200, 100)}) b = _stage_set(root, {"small.png": (200, 100), "large.png": (3000, 2000)})
(b / "t.mp3").write_bytes(b"ID3") (b / "t.mp3").write_bytes(b"ID3")
page = browser.new_page(viewport={"width": 1400, "height": 900}) page = browser.new_page(viewport={"width": 1400, "height": 900})
_load(page, f"{base}/b/g/view?f=small.png") _load(page, f"{base}/b/g/view?f=small.png")
pic = page.locator("#vtoggle").is_visible() pic = page.locator("#vtoggle").is_visible()
_load(page, f"{base}/b/g/view?f=large.png")
pic = pic and page.locator("#vtoggle").is_visible() # every picture, small or large
page.goto(f"{base}/b/g/view?f=t.mp3", wait_until="networkidle") page.goto(f"{base}/b/g/view?f=t.mp3", wait_until="networkidle")
audio = page.locator("#vtoggle").count() audio = page.locator("#vtoggle").count()
page.close() page.close()
@@ -1190,20 +1195,31 @@ def test_the_mode_persists_across_prev_next_and_never_flashes(browser, live):
_load(page, page.url) _load(page, page.url)
at_parse = page.evaluate("window.__stageOne") at_parse = page.evaluate("window.__stageOne")
pressed = page.locator("#btn-one").get_attribute("aria-pressed") pressed = page.locator("#btn-one").get_attribute("aria-pressed")
fit_pressed = page.locator("#btn-fit").get_attribute("aria-pressed")
# choosing Fit FORGETS 1:1 (removes the key; it does not store some other word)
page.locator("#btn-fit").click()
forgot = page.evaluate("localStorage.getItem('booth.fit')")
page.evaluate("localStorage.setItem('booth.fit', 'zoom')") page.evaluate("localStorage.setItem('booth.fit', 'zoom')")
page.reload(wait_until="networkidle") page.reload(wait_until="networkidle")
stray = page.evaluate("window.__stageOne") stray = page.evaluate("window.__stageOne")
ctx.close() ctx.close()
ctx = browser.new_context(viewport={"width": 1400, "height": 900}) ctx = browser.new_context(viewport={"width": 1400, "height": 900})
page = ctx.new_page() page = ctx.new_page()
page.add_init_script("Storage.prototype.setItem = function () { throw new Error('quota'); };") errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
page.add_init_script("""Storage.prototype.setItem = function () { throw new Error('quota'); };
Storage.prototype.getItem = function () { throw new Error('denied'); };""")
_load(page, f"{base}/b/g/view?f=a.png") _load(page, f"{base}/b/g/view?f=a.png")
unreadable = page.evaluate("document.documentElement.classList.contains('stage-one')")
page.locator("#btn-one").click() page.locator("#btn-one").click()
applied = page.evaluate("document.documentElement.classList.contains('stage-one')") applied = page.evaluate("document.documentElement.classList.contains('stage-one')")
applied_pressed = page.locator("#btn-one").get_attribute("aria-pressed")
ctx.close() ctx.close()
assert unreadable is False and errors == [], (unreadable, errors)
assert applied_pressed == "true", "a write that throws must not cut the click short"
assert first is False and stored == "one", (first, stored) assert first is False and stored == "one", (first, stored)
assert at_parse is True and pressed == "true", (at_parse, pressed) assert at_parse is True and pressed == "true" and fit_pressed == "false", (at_parse, pressed, fit_pressed)
assert stray is False and applied is True, (stray, applied) assert stray is False and applied is True and forgot is None, (stray, applied, forgot)
def test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage(browser, live): def test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage(browser, live):
@@ -1233,6 +1249,9 @@ def test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage(browser,
assert abs(g["next"]["l"] - (g["drawn"]["r"] + 8)) <= 2, (g["drawn"], g["next"]) assert abs(g["next"]["l"] - (g["drawn"]["r"] + 8)) <= 2, (g["drawn"], g["next"])
for g in (tall, wide, resized): for g in (tall, wide, resized):
assert g["prev"]["l"] >= g["stage"]["l"] and g["next"]["r"] <= g["stage"]["r"], g assert g["prev"]["l"] >= g["stage"]["l"] and g["next"]["r"] <= g["stage"]["r"], g
mid = (g["stage"]["t"] + g["stage"]["b"]) / 2
for a in (g["prev"], g["next"]):
assert abs((a["t"] + a["b"]) / 2 - mid) <= 2, ("not centred on the stage", a, mid)
assert g["next"]["r"] <= g["rail"]["l"], "an arrow over the rail" assert g["next"]["r"] <= g["rail"]["l"], "an arrow over the rail"
assert abs(wide["prev"]["l"] - (wide["stage"]["l"] + 8)) <= 2 and abs(wide["next"]["r"] - (wide["stage"]["r"] - 8)) <= 2, wide assert abs(wide["prev"]["l"] - (wide["stage"]["l"] + 8)) <= 2 and abs(wide["next"]["r"] - (wide["stage"]["r"] - 8)) <= 2, wide
@@ -1263,6 +1282,13 @@ def test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away(browse
panned = scroll() panned = scroll()
page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 2, cy + 1, steps=2); page.mouse.up() page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 2, cy + 1, steps=2); page.mouse.up()
jitter = scroll() jitter = scroll()
# the threshold is 4px of TOTAL movement: (3, 2) is 3.6px and pans nothing;
# a (3, 3) diagonal is 4.24px, so it pans
page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 3, cy + 2, steps=1); page.mouse.up()
under = scroll()
page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 3, cy + 3, steps=1); page.mouse.up()
diagonal = scroll()
page.evaluate("document.getElementById('vstage').scrollTo(420, 440)")
# the stage's reveal sits over the stage, not in its scrolled content: at any # the stage's reveal sits over the stage, not in its scrolled content: at any
# scroll it is in view, a click on it reveals, and a drag from it pans nothing # scroll it is in view, a click on it reveals, and a drag from it pans nothing
btn = page.locator("#vreveal").bounding_box() btn = page.locator("#vreveal").bounding_box()
@@ -1278,5 +1304,198 @@ def test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away(browse
assert cursor == "grab", cursor assert cursor == "grab", cursor
assert panned == [420, 440], panned assert panned == [420, 440], panned
assert jitter == [420, 440] and dragged_from_button == [420, 440], (jitter, dragged_from_button) assert jitter == [420, 440] and dragged_from_button == [420, 440], (jitter, dragged_from_button)
assert under == [420, 440] and diagonal == [417, 437], (under, diagonal)
assert revealed and after_button == [420, 440], (revealed, after_button) assert revealed and after_button == [420, 440], (revealed, after_button)
assert draggable is False assert draggable is False
def test_in_one_to_one_every_pixel_of_a_large_picture_is_reachable(browser, live):
"""heid code-review (kimi; confirmed by measurement): the 1:1 stage kept the
flex CENTRING while adding overflow — a picture larger than the stage
overflowed BOTH sides, and the start side cannot be scrolled to. A 3000px
picture hid its leftmost 980px for good. At scroll (0, 0) the picture's
top-left is the stage's; at the far scroll its bottom-right is. A picture
smaller than the stage is still centred."""
base, root = live
_stage_set(root, {"a-huge.png": (3000, 3000), "b-small.png": (200, 100)})
page = browser.new_page(viewport={"width": 1400, "height": 900})
_load(page, f"{base}/b/g/view?f=a-huge.png")
page.locator("#btn-one").click()
page.wait_for_timeout(150)
edges = """(to) => { const s = document.getElementById('vstage');
s.scrollTo(to === 'start' ? 0 : s.scrollWidth, to === 'start' ? 0 : s.scrollHeight);
const sr = s.getBoundingClientRect(), ir = document.getElementById('vimg').getBoundingClientRect();
return to === 'start' ? [Math.round(ir.left - sr.left), Math.round(ir.top - sr.top)]
: [Math.round(sr.left + s.clientWidth - ir.right), Math.round(sr.top + s.clientHeight - ir.bottom)]; }"""
start, end = page.evaluate(edges, "start"), page.evaluate(edges, "end")
_load(page, f"{base}/b/g/view?f=b-small.png")
small = page.evaluate("""() => { const s = document.getElementById('vstage'), sr = s.getBoundingClientRect(),
ir = document.getElementById('vimg').getBoundingClientRect();
return [Math.round((ir.left - sr.left) - (sr.left + s.clientWidth - ir.right)),
Math.round((ir.top - sr.top) - (sr.top + s.clientHeight - ir.bottom))]; }""")
page.close()
assert start == [0, 0] and end == [0, 0], (start, end)
assert all(abs(v) <= 1 for v in small), small # centred: equal margins both sides
def test_a_pan_holds_past_the_stage_edge_and_never_starts_on_a_hover(browser, live):
"""heid code-review (hulda, kimi): pointer capture was in no test — a drag
carried past the stage's edge must keep panning — and a press released
OUTSIDE the stage before the drag began left the drag armed, so a later
buttonless hover panned. Capture holds the gesture; no button, no pan."""
base, root = live
_stage_set(root, {"huge.png": (3000, 3000)})
page = browser.new_page(viewport={"width": 1400, "height": 900})
_load(page, f"{base}/b/g/view?f=huge.png")
page.locator("#btn-one").click()
page.evaluate("document.getElementById('vstage').scrollTo(1500, 1500)")
scroll = lambda: page.evaluate("[document.getElementById('vstage').scrollLeft, document.getElementById('vstage').scrollTop]")
st = page.locator("#vstage").bounding_box()
cx, cy = st["x"] + st["width"] / 2, st["y"] + st["height"] / 2
# 1. carried far past the stage's right edge (over the rail): still panning
page.mouse.move(cx, cy); page.mouse.down()
page.mouse.move(cx + 40, cy, steps=2)
page.mouse.move(cx + st["width"] / 2 + 250, cy, steps=12)
page.mouse.up()
past_edge = scroll()
# 2. pressed, then out of the stage in one jump, released outside, re-entered with no button
page.evaluate("document.getElementById('vstage').scrollTo(1500, 1500)")
page.mouse.move(cx, cy); page.mouse.down()
page.mouse.move(st["x"] + st["width"] + 150, cy, steps=1)
page.mouse.up()
page.mouse.move(cx + 60, cy + 40, steps=6)
hover = scroll()
page.close()
expected = 1500 - (st["width"] / 2 + 250)
assert abs(past_edge[0] - expected) <= 2 and past_edge[1] == 1500, (past_edge, expected)
assert hover == [1500, 1500], hover
def test_before_placement_the_arrows_never_sit_over_the_rail_on_a_narrow_screen(browser, live):
"""heid code-review (hulda): stacked (<=900px), the arrows' CSS spot —
their place with JS off, while loading, or after a failed load — centred
on the stage AND the rail below it. Now it is the stage's centre. And a
picture that fails to load leaves the arrows at that spot, unplaced."""
base, root = live
from booth.marks import write_note
b = _stage_set(root, {"a.png": (1600, 1200), "c.png": (1600, 1200)})
(b / "b-broken.png").write_bytes(b"\x89PNG\r\n\x1a\nnot really")
# a rail TALLER than the stage — the only case where centring on stage AND
# rail lands below the stage; a short rail hid the bug (this test's first
# draft was vacuous against removing the fix)
for i in range(14):
write_note(b, "a.png", f"note {i}: " + "a longer observation about this picture " * 3)
ctx = browser.new_context(java_script_enabled=False, viewport={"width": 390, "height": 844})
page = ctx.new_page()
page.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle")
nojs = page.evaluate(_GEOM.replace("img.naturalWidth", "(img.naturalWidth || 1)").replace("img.naturalHeight", "(img.naturalHeight || 1)"))
ctx.close()
page = browser.new_page(viewport={"width": 1400, "height": 900})
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
page.goto(f"{base}/b/g/view?f=b-broken.png", wait_until="networkidle")
page.wait_for_timeout(300)
broken = page.evaluate("[...document.querySelectorAll('.vnav')].map(a => a.classList.contains('is-placed'))")
page.close()
for a in (nojs["prev"], nojs["next"]):
assert a["b"] <= nojs["stage"]["b"] and a["t"] >= nojs["stage"]["t"], ("over the rail", a, nojs["stage"])
assert broken == [False, False] and errors == [], (broken, errors)
def test_the_stage_reveal_never_shows_without_js_and_keeps_the_fit_shadow(browser, live):
"""heid bug-hunt (hulda, regin): the stage's reveal rendered visible with
scripts off and did nothing; and a revealed picture lost Fit's shadow."""
from booth.app import set_blurred
base, root = live
b = _stage_set(root, {"a.png": (800, 600)})
set_blurred(b, "a.png", True)
ctx = browser.new_context(java_script_enabled=False)
page = ctx.new_page()
page.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle")
nojs = page.locator("#vreveal").is_visible()
ctx.close()
page = browser.new_page(viewport={"width": 1400, "height": 900})
_load(page, f"{base}/b/g/view?f=a.png")
page.locator("#vreveal").click()
page.wait_for_timeout(400)
shadow = page.evaluate("getComputedStyle(document.getElementById('vimg')).filter")
page.close()
assert not nojs
assert shadow.startswith("drop-shadow"), shadow
def test_a_stage_mode_chosen_in_one_tab_moves_the_others(browser, live):
"""heid bug-hunt (regin): the theme follows a choice made in another tab;
the stage mode did not."""
base, root = live
_stage_set(root, {"a.png": (1600, 1200)})
ctx = browser.new_context(viewport={"width": 1400, "height": 900})
a, b = ctx.new_page(), ctx.new_page()
_load(a, f"{base}/b/g/view?f=a.png")
_load(b, f"{base}/b/g/view?f=a.png")
a.locator("#btn-one").click()
b.wait_for_function("document.documentElement.classList.contains('stage-one')", timeout=5000)
pressed = b.locator("#btn-one").get_attribute("aria-pressed")
ctx.close()
assert pressed == "true"
def test_a_picture_that_overflows_one_axis_pans_along_it(browser, live):
"""heid code-review supplement (groa): 'overflows EITHER axis' was untested;
requiring both would pass everything else. A wide, short picture in 1:1 —
wider than the stage, shorter than it — pans horizontally."""
base, root = live
_stage_set(root, {"wide.png": (3000, 200)})
page = browser.new_page(viewport={"width": 1400, "height": 900})
_load(page, f"{base}/b/g/view?f=wide.png")
page.locator("#btn-one").click()
page.evaluate("document.getElementById('vstage').scrollTo(800, 0)")
st = page.locator("#vstage").bounding_box()
cx, cy = st["x"] + st["width"] / 2, st["y"] + st["height"] / 2
page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 100, cy, steps=6); page.mouse.up()
got = page.evaluate("[document.getElementById('vstage').scrollLeft, document.getElementById('vstage').classList.contains('can-pan')]")
page.close()
assert got == [700, True], got
_CLASSIC_SCROLLBARS = "#vstage::-webkit-scrollbar{width:15px;height:15px;background:#888}"
def test_a_classic_scrollbar_is_neither_under_an_arrow_nor_a_pan(browser, live):
"""heid bug-hunt supplement (groa): with classic scrollbars the next arrow
clamped against the stage's BORDER box and sat under the vertical bar, and
a press on the bar started a pan that fought the thumb backwards. Headless
Chromium draws overlay bars (no gutter), so this test forces a 15px classic
one — and asserts the gutter is real before it trusts a single measure."""
base, root = live
_stage_set(root, {"huge.png": (3000, 3000), "z.png": (3000, 3000)})
# Playwright launches headless Chromium with --hide-scrollbars, which hides
# even a styled bar: this test needs a browser without it
bars = browser.browser_type.launch(args=OFFLINE, ignore_default_args=["--hide-scrollbars"])
page = bars.new_page(viewport={"width": 1400, "height": 900})
page.add_init_script(f"document.addEventListener('DOMContentLoaded', () => {{ const st = document.createElement('style'); st.textContent = {_CLASSIC_SCROLLBARS!r}; document.head.appendChild(st); }});")
page.goto(f"{base}/b/g/view?f=huge.png", wait_until="networkidle")
page.wait_for_function("document.getElementById('vimg').naturalWidth > 0")
page.locator("#btn-one").click()
# `complete` can be true before the 3000px picture is LAID OUT; scrolling
# before then clamps to 0 (this test's first draft flaked 1 in 3 on it)
page.wait_for_function("(s => s.scrollWidth - s.clientWidth > 1500)(document.getElementById('vstage'))")
g = page.evaluate("""() => { const s = document.getElementById('vstage'), r = s.getBoundingClientRect(),
n = document.querySelector('.vnext').getBoundingClientRect();
return {gutter: s.offsetWidth - s.clientWidth, client_right: r.left + s.clientLeft + s.clientWidth,
next_right: n.right, r: {x: r.left, y: r.top, w: r.width, h: r.height}}; }""")
page.evaluate("document.getElementById('vstage').scrollTo(800, 800)")
# A press ON the vertical scrollbar, dragged sideways, dispatched as pointer
# events so the test measures OUR handler and not Chromium's native bar
# (real-mouse drags on the bar flaked 1 in 6 on native track behaviour that
# never reproduced standalone in 14 tries): no pan may move the picture.
left = page.evaluate("""() => { const s = document.getElementById('vstage'), r = s.getBoundingClientRect();
const x = r.left + r.width - 6, y = r.top + r.height / 2;
const ev = (t, dx, b) => s.dispatchEvent(new PointerEvent(t, {bubbles: true, pointerId: 7, button: 0,
buttons: b, clientX: x + dx, clientY: y, isPrimary: true}));
ev('pointerdown', 0, 1); ev('pointermove', -40, 1); ev('pointermove', -80, 1); ev('pointerup', -80, 0);
return s.scrollLeft; }""")
bars.close()
assert g["gutter"] >= 15, ("the forced classic scrollbar is not in effect", g)
assert g["next_right"] <= g["client_right"] - 8 + 1, g
assert left == 800, left