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:
@@ -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
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user