merge(r2c): the review stage fills, its arrows sit at the picture, 1:1 pans
design-dev's r2c round, merged on the operator's direct approval. It answers his ask from 2026-09-23: fit and 1:1 modes, arrows at the image's edge rather than the stage's, and click-and-pan in 1:1 with native image drag defeated. - Fit fills the stage, up or down, with or without JS; 1:1 is natural pixels, and every pixel is reachable. The operator ruled that Fit may enlarge. - The toggle shows for every picture. The mode lives on <html> as `stage-one`, set by the head script before the stage exists, so a 1:1 reel never flashes Fit. It persists per viewer in localStorage (inside a try) and follows other tabs. - The arrows sit 8px outside the drawn picture, clamped inside the stage. - 1:1 drag-to-pan: grab convention, a 4px threshold, pointer capture, and the picture is not draggable. Templates only (view.html, base.html); no server change. The two test changes are declared in r2c_review_stage.contract.md: the r2b reveal test asserts "no blur" (Fit keeps a drop shadow), and the r2_flow 360px-arrow row is retired with successors in r2c.toml. Contract panel and both code panels 4/4.
This commit is contained in:
@@ -21,6 +21,11 @@
|
|||||||
try {
|
try {
|
||||||
if (b !== null && sessionStorage.getItem('booth.reveal:' + b) === '1') d.classList.add('reveal-all');
|
if (b !== null && sessionStorage.getItem('booth.reveal:' + b) === '1') d.classList.add('reveal-all');
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
/* r2c S2: the review stage's 1:1, before the stage exists, so a 1:1 reel
|
||||||
|
never shows a stage in Fit. Anything but 'one' (or unreadable) = Fit. */
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem('booth.fit') === 'one') d.classList.add('stage-one');
|
||||||
|
} catch (e) {}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<title>{% block title %}The Booth{% endblock %}</title>
|
<title>{% block title %}The Booth{% endblock %}</title>
|
||||||
@@ -655,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}
|
||||||
@@ -735,16 +744,32 @@
|
|||||||
.vbtn:hover{border-color:var(--text-faint);color:var(--text-heading);background:var(--surface-overlay);text-decoration:none}
|
.vbtn:hover{border-color:var(--text-faint);color:var(--text-heading);background:var(--surface-overlay);text-decoration:none}
|
||||||
.vbtn.is-flagged{color:var(--success-text);border-color:var(--success);background:var(--success-soft);font-weight:600}
|
.vbtn.is-flagged{color:var(--success-text);border-color:var(--success);background:var(--success-soft);font-weight:600}
|
||||||
.vtoggle{display:inline-flex;border:1px solid var(--border-strong);border-radius:var(--radius-md);overflow:hidden}
|
.vtoggle{display:inline-flex;border:1px solid var(--border-strong);border-radius:var(--radius-md);overflow:hidden}
|
||||||
|
.vtoggle[hidden]{display:none} /* an author display beats the UA's [hidden] */
|
||||||
.vseg{cursor:pointer;border:0;background:transparent;color:var(--text-muted);font-family:var(--font-mono);
|
.vseg{cursor:pointer;border:0;background:transparent;color:var(--text-muted);font-family:var(--font-mono);
|
||||||
font-size:var(--size-caption);padding:7px 12px;transition:background var(--dur-1) var(--ease-out),color var(--dur-1) var(--ease-out)}
|
font-size:var(--size-caption);padding:7px 12px;transition:background var(--dur-1) var(--ease-out),color var(--dur-1) var(--ease-out)}
|
||||||
.vseg+.vseg{border-left:1px solid var(--border-strong)}
|
.vseg+.vseg{border-left:1px solid var(--border-strong)}
|
||||||
.vseg:hover{color:var(--text-heading)}
|
.vseg:hover{color:var(--text-heading)}
|
||||||
.vseg.on{background:var(--surface-overlay);color:var(--text-heading)}
|
.vseg.on{background:var(--surface-overlay);color:var(--text-heading)}
|
||||||
.vstage{flex:1;min-height:0;background:var(--surface-sunken)}
|
.vstage{flex:1;min-height:0;background:var(--surface-sunken)}
|
||||||
.vstage.fit{display:flex;align-items:center;justify-content:center;overflow:hidden;padding:16px}
|
/* r2c S1 — FIT, the default, with or without JS: the picture's box IS the
|
||||||
.vstage.fit img{max-width:100%;max-height:100%;width:auto;height:auto;box-shadow:var(--shadow-lg)}
|
stage's inner box and `object-fit: contain` draws it whole at the largest
|
||||||
.vstage.one{overflow:auto;text-align:center}
|
size that fits — UP or down (operator: "Fit may enlarge"), never cropped.
|
||||||
.vstage.one img{max-width:none;max-height:none;margin:auto}
|
The shadow follows the picture's pixels (drop-shadow), not the letterbox. */
|
||||||
|
.vstage{display:flex;align-items:center;justify-content:center;overflow:hidden;padding:16px}
|
||||||
|
.vstage.is-img img{width:100%;height:100%;object-fit:contain;
|
||||||
|
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
|
||||||
|
stage exists). START alignment, never centring: 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). 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}
|
||||||
|
/* 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.is-grabbing{cursor:grabbing}
|
||||||
|
/* r2c S3: an arrow the script has placed at the picture drops its CSS spot */
|
||||||
|
.vnav.is-placed{margin:0;right:auto}
|
||||||
.vmarks{margin:0 auto;padding:10px 12px 12px;width:min(92vw,900px);display:flex;flex-direction:column;gap:8px}
|
.vmarks{margin:0 auto;padding:10px 12px 12px;width:min(92vw,900px);display:flex;flex-direction:column;gap:8px}
|
||||||
.vflag{display:flex}
|
.vflag{display:flex}
|
||||||
.vnote{display:flex;align-items:flex-start;gap:4px}
|
.vnote{display:flex;align-items:flex-start;gap:4px}
|
||||||
@@ -760,8 +785,11 @@
|
|||||||
.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 .vstage .reveal{position:absolute;top:14px;left:14px;z-index:3;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);
|
||||||
background:oklch(0.17 0.01 250 / .86);color:oklch(0.91 0.008 216)}
|
background:oklch(0.17 0.01 250 / .86);color:oklch(0.91 0.008 216)}
|
||||||
.vname .ord{font-weight:600;color:var(--text-heading)}
|
.vname .ord{font-weight:600;color:var(--text-heading)}
|
||||||
@@ -809,7 +837,7 @@
|
|||||||
@media (max-width:900px){
|
@media (max-width:900px){
|
||||||
.viewer.review{position:static;display:block;min-height:100vh}
|
.viewer.review{position:static;display:block;min-height:100vh}
|
||||||
.review-body{grid-template-columns:1fr}
|
.review-body{grid-template-columns:1fr}
|
||||||
.review .vstage.fit{height:60vh}
|
.review .vstage{height:60vh}
|
||||||
.vrail{border-left:0;border-top:1px solid var(--border-subtle)}
|
.vrail{border-left:0;border-top:1px solid var(--border-subtle)}
|
||||||
.vnext{right:0}
|
.vnext{right:0}
|
||||||
}
|
}
|
||||||
|
|||||||
+154
-32
@@ -17,8 +17,8 @@
|
|||||||
{# A JS-only VIEWING convenience (INV-3): hidden until the script shows it,
|
{# A JS-only VIEWING convenience (INV-3): hidden until the script shows it,
|
||||||
and only ever rendered for a picture. With scripts off the image shows at
|
and only ever rendered for a picture. With scripts off the image shows at
|
||||||
fit size and no judgment depends on this. #}
|
fit size and no judgment depends on this. #}
|
||||||
<span class="vtoggle" id="vtoggle" style="display:none">
|
<span class="vtoggle" id="vtoggle" hidden>
|
||||||
<button type="button" class="vseg on" id="btn-fit">Fit</button><button type="button" class="vseg" id="btn-one">1:1</button>
|
<button type="button" class="vseg on" id="btn-fit" aria-pressed="true" title="the whole picture, as large as the stage allows">Fit</button><button type="button" class="vseg" id="btn-one" aria-pressed="false" title="natural pixels — drag to pan a large picture">1:1</button>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{# r2b D2b + D2, in the top bar: outside every data-region, so no swap
|
{# r2b D2b + D2, in the top bar: outside every data-region, so no swap
|
||||||
@@ -46,13 +46,18 @@
|
|||||||
|
|
||||||
<div class="review-body">
|
<div class="review-body">
|
||||||
{% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous">‹</a>{% endif %}
|
{% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous">‹</a>{% endif %}
|
||||||
<div class="vstage fit{% if blurred %} is-blurred{% endif %}" id="vstage">
|
<div class="vstage{% if kind == 'image' %} is-img{% endif %}{% if blurred %} is-blurred{% endif %}" id="vstage">
|
||||||
{% if kind == 'image' %}<img id="vimg" src="{{ file_url }}" alt="{{ file }}">
|
{% if kind == 'image' %}<img id="vimg" src="{{ file_url }}" alt="{{ file }}" draggable="false">
|
||||||
{% elif kind == 'video' %}<video id="vmedia" controls preload="metadata" src="{{ file_url }}"></video>
|
{% elif kind == 'video' %}<video id="vmedia" controls preload="metadata" src="{{ file_url }}"></video>
|
||||||
{% else %}<audio id="vmedia" controls preload="metadata" src="{{ file_url }}"></audio>
|
{% else %}<audio id="vmedia" controls preload="metadata" src="{{ file_url }}"></audio>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if blurred %}<button type="button" class="reveal" id="vreveal" aria-label="reveal {{ file }}">👁 reveal — blur is cosmetic</button>{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{# 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
|
||||||
|
outside the stage it can never start a pan. #}
|
||||||
|
{# 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">
|
||||||
@@ -153,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}}
|
||||||
@@ -163,38 +173,149 @@
|
|||||||
var PREV = {{ (('?f=' ~ prev_url) if prev_url else '')|tojson }};
|
var PREV = {{ (('?f=' ~ prev_url) if prev_url else '')|tojson }};
|
||||||
var NEXT = {{ (('?f=' ~ next_url) if next_url else '')|tojson }};
|
var NEXT = {{ (('?f=' ~ next_url) if next_url else '')|tojson }};
|
||||||
|
|
||||||
/* Fit / 1:1 — bound only when the stage is a picture (R2 C6). */
|
/* THE STAGE (r2c). The mode is ONE class on <html>, `stage-one` (absent =
|
||||||
var img = document.getElementById('vimg');
|
Fit), set by the head script before the stage existed; this binds the
|
||||||
if (img) {
|
toggle, places the arrows at the drawn picture, and pans a 1:1 picture. */
|
||||||
|
var d = document.documentElement;
|
||||||
var stage = document.getElementById('vstage');
|
var stage = document.getElementById('vstage');
|
||||||
|
var img = document.getElementById('vimg');
|
||||||
|
var video = stage.querySelector('video');
|
||||||
|
var rbody = stage.parentElement; /* .review-body */
|
||||||
|
var prevA = rbody.querySelector('.vnav.vprev'), nextA = rbody.querySelector('.vnav.vnext');
|
||||||
|
|
||||||
|
/* 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
|
||||||
|
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() {
|
||||||
|
if (img) {
|
||||||
|
if (!img.naturalWidth) return null;
|
||||||
|
var b = img.getBoundingClientRect();
|
||||||
|
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};
|
||||||
|
}
|
||||||
|
if (video && video.videoWidth) {
|
||||||
|
var v = video.getBoundingClientRect();
|
||||||
|
return {l: v.left, r: v.right};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
/* Each arrow wholly outside the drawn edge, its near edge 8px away, clamped
|
||||||
|
8px inside the stage — so over the picture only when the picture spans
|
||||||
|
the stage (operator: "unless the image spans the entire width"). */
|
||||||
|
function place() {
|
||||||
|
var p = drawn();
|
||||||
|
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();
|
||||||
|
/* 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) {
|
||||||
|
var a = pair[0];
|
||||||
|
if (!a) return;
|
||||||
|
var w = a.offsetWidth, lo = cl + 8, hi = cr - 8 - w;
|
||||||
|
var x = pair[1] < 0 ? p.l - 8 - w : p.r + 8;
|
||||||
|
x = Math.max(lo, Math.min(hi, x));
|
||||||
|
a.classList.add('is-placed');
|
||||||
|
a.style.left = (x - o.left) + 'px';
|
||||||
|
a.style.top = (s.top - o.top + s.height / 2) + 'px';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function pannable() {
|
||||||
|
var can = !!img && d.classList.contains('stage-one') &&
|
||||||
|
(stage.scrollWidth > stage.clientWidth || stage.scrollHeight > stage.clientHeight);
|
||||||
|
stage.classList.toggle('can-pan', can);
|
||||||
|
return can;
|
||||||
|
}
|
||||||
|
function settle() { place(); pannable(); }
|
||||||
|
|
||||||
|
if (img) {
|
||||||
var toggle = document.getElementById('vtoggle');
|
var toggle = document.getElementById('vtoggle');
|
||||||
var bFit = document.getElementById('btn-fit');
|
var bFit = document.getElementById('btn-fit'), bOne = document.getElementById('btn-one');
|
||||||
var bOne = document.getElementById('btn-one');
|
var show = function () {
|
||||||
var setMode = function (mode) {
|
var one = d.classList.contains('stage-one');
|
||||||
var fit = mode === 'fit';
|
bFit.classList.toggle('on', !one);
|
||||||
stage.classList.toggle('fit', fit);
|
bOne.classList.toggle('on', one);
|
||||||
stage.classList.toggle('one', !fit);
|
bFit.setAttribute('aria-pressed', one ? 'false' : 'true');
|
||||||
bFit.classList.toggle('on', fit);
|
bOne.setAttribute('aria-pressed', one ? 'true' : 'false');
|
||||||
bOne.classList.toggle('on', !fit);
|
|
||||||
};
|
};
|
||||||
/* "fits" == the image at natural size already sits inside the stage, so
|
/* The click applies to the page first and is remembered second: storage
|
||||||
Fit and 1:1 would render identically — then the toggle is hidden. */
|
that throws costs the memory, never the click. */
|
||||||
var evaluate = function () {
|
var setMode = function (one) {
|
||||||
if (!img.naturalWidth) return;
|
d.classList.toggle('stage-one', one);
|
||||||
if (img.naturalWidth <= stage.clientWidth && img.naturalHeight <= stage.clientHeight) {
|
try {
|
||||||
toggle.style.display = 'none';
|
if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.removeItem('booth.fit');
|
||||||
setMode('fit');
|
} catch (e) {}
|
||||||
} else {
|
show();
|
||||||
toggle.style.display = 'inline-flex';
|
settle();
|
||||||
if (!stage.classList.contains('one')) setMode('fit');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
bFit.addEventListener('click', function () { setMode('fit'); });
|
toggle.hidden = false;
|
||||||
bOne.addEventListener('click', function () { setMode('one'); });
|
show();
|
||||||
img.addEventListener('load', evaluate);
|
/* A mode chosen in another tab moves this one (the theme's rule). */
|
||||||
window.addEventListener('resize', evaluate);
|
window.addEventListener('storage', function (e) {
|
||||||
if (img.complete) evaluate();
|
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); });
|
||||||
|
bOne.addEventListener('click', function () { setMode(true); });
|
||||||
|
img.addEventListener('load', settle);
|
||||||
|
if (img.complete) settle();
|
||||||
|
|
||||||
|
/* DRAG TO PAN (r2c S4), on the stage only: the picture follows the
|
||||||
|
pointer, a press that moves under 4px is not a drag, and a press on a
|
||||||
|
control inside the stage (its reveal) keeps its click. The picture
|
||||||
|
cannot be dragged away. */
|
||||||
|
stage.addEventListener('dragstart', function (e) { e.preventDefault(); });
|
||||||
|
var drag = null;
|
||||||
|
stage.addEventListener('pointerdown', function (e) {
|
||||||
|
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;
|
||||||
|
drag = {x: e.clientX, y: e.clientY, l: stage.scrollLeft, t: stage.scrollTop, on: false, id: e.pointerId};
|
||||||
|
});
|
||||||
|
stage.addEventListener('pointermove', function (e) {
|
||||||
|
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;
|
||||||
|
if (!drag.on) {
|
||||||
|
if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */
|
||||||
|
drag.on = true;
|
||||||
|
stage.classList.add('is-grabbing');
|
||||||
|
try { stage.setPointerCapture(drag.id); } catch (x) {}
|
||||||
}
|
}
|
||||||
|
stage.scrollLeft = drag.l - dx;
|
||||||
|
stage.scrollTop = drag.t - dy;
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
function endDrag() {
|
||||||
|
if (drag && drag.on) stage.classList.remove('is-grabbing');
|
||||||
|
drag = null;
|
||||||
|
}
|
||||||
|
stage.addEventListener('pointerup', endDrag);
|
||||||
|
stage.addEventListener('pointercancel', endDrag);
|
||||||
|
}
|
||||||
|
if (video) video.addEventListener('loadedmetadata', settle);
|
||||||
|
if (window.ResizeObserver) new ResizeObserver(settle).observe(stage);
|
||||||
|
else window.addEventListener('resize', settle);
|
||||||
|
|
||||||
/* 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
|
||||||
@@ -210,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';
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
---
|
||||||
|
contract_version: "0.1"
|
||||||
|
status: "PROPOSED 2026-09-23 by design-dev; heid contract panel (4/4) folded, from the operator's ask relayed by booth-dev (thread 01M38FPYAY5RSMSB9BGQ23CFM7) and his ruling 'Fit may enlarge' (thread 01M38EESKR8T7A8XMCQPHRNP0E). Sequenced after r2b and before r3 (compare), so compare reuses this machinery rather than growing a second copy."
|
||||||
|
module: "templates/view.html + base.html CSS (the review stage: fit / 1:1, the prev/next arrows, drag-pan)"
|
||||||
|
purpose: "The operator, verbatim: 'fit and 1:1 modes as well as moving the forward and back arrows closer to the edge of the image instead of out at the edges unless the image spans the entire width. mouse click and pan for 1:1 mode if it exceeds page width (defeat drag drop of image)'. Fit/1:1 exists but hides whenever a picture fits at natural size, and Fit never enlarges, so the two modes often look identical and the toggle comes and goes from picture to picture. The arrows sit at the stage's edges, hundreds of pixels from a portrait picture. 1:1 pans only by scrollbars, and a drag picks the picture up."
|
||||||
|
depends_on:
|
||||||
|
- "view.html (R2 C6): the stage `#vstage` (server-rendered `vstage fit`), `#vimg`, `#vtoggle` with `#btn-fit`/`#btn-one`, the `.vnav.vprev`/`.vnav.vnext` anchors inside `.review-body`, the review keys and their `isEditable` guard."
|
||||||
|
- "base.html: `.vstage`, `.vstage.fit`, `.vstage.one`, `.review-body` (grid: stage + 360px rail; stacked at <=900px)."
|
||||||
|
- "r2b D2: Reveal all and the stage's own reveal (`#vreveal`) — untouched; they read the blur classes, not the fit classes."
|
||||||
|
language: "jinja + css + a little javascript"
|
||||||
|
complexity: "medium"
|
||||||
|
estimated_loc: 220
|
||||||
|
confidence: 0.7
|
||||||
|
touches:
|
||||||
|
- "booth/templates/view.html (the toggle markup; the stage-mode script; the arrow placement; drag-pan)"
|
||||||
|
- "booth/templates/base.html (Fit-fills CSS; 1:1 cursor; the stage reveal's position; `stage-one` in the head script)"
|
||||||
|
- "tests/test_flow.py, tests/test_flow_browser.py; tests/mutations/r2c.toml (new)"
|
||||||
|
assumptions:
|
||||||
|
- "ONE VIEWER, per r2: the stage mode is a per-browser preference."
|
||||||
|
- "No server change: every part of this is markup, CSS and page script."
|
||||||
|
---
|
||||||
|
|
||||||
|
# R2c — the review stage
|
||||||
|
|
||||||
|
## S1 — Fit fills; 1:1 is the pixel truth
|
||||||
|
|
||||||
|
- **Fit scales the picture to the largest size at which it is WHOLE inside the
|
||||||
|
stage, UP or down, undistorted** (ruling "Fit may enlarge"): contain, never
|
||||||
|
cover — nothing is ever cropped in Fit. It is CSS: the image box fills the
|
||||||
|
stage and `object-fit: contain` places the picture in it. So the no-JS render
|
||||||
|
is also Fit-fills — a declared change to R2's INV-3 note ("with scripts off
|
||||||
|
the image shows at fit size"): the size changes, the promise (one picture at a
|
||||||
|
readable size, no judgment behind a script) holds.
|
||||||
|
- The drop shadow follows the picture's own pixels (`drop-shadow`), not the
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
|
||||||
|
## S2 — the toggle is always there for a picture
|
||||||
|
|
||||||
|
- **Fit | 1:1 shows for EVERY picture**, never hidden because a picture happens
|
||||||
|
to fit — that per-picture hide is why the operator could not find the
|
||||||
|
feature. Video and audio still get no toggle.
|
||||||
|
- It is in the markup with `hidden` for pictures only; the script removes
|
||||||
|
`hidden`. Without JS it never shows (Fit-fills needs no toggle).
|
||||||
|
- **The mode persists across prev/next, per browser**: every click writes
|
||||||
|
`localStorage["booth.fit"]` = `one` or removes it (Fit); arrowing through a
|
||||||
|
set in 1:1 is how detail gets compared.
|
||||||
|
- **The mode is ONE class on `<html>`, `stage-one`** (absent = Fit), set by
|
||||||
|
the early `<head>` script — the one r2b uses for the theme and Reveal all —
|
||||||
|
BEFORE THE STAGE EXISTS in the document. So no paint can ever show a 1:1
|
||||||
|
reel's stage in Fit: the class is there before the stage is parsed. 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).
|
||||||
|
- A stored value other than `one` reads as Fit. A read that throws reads as
|
||||||
|
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
|
||||||
|
record of the mode on the page (storage is its memory, off the page).
|
||||||
|
|
||||||
|
## S3 — the arrows sit at the picture
|
||||||
|
|
||||||
|
- **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
|
||||||
|
the DRAWN picture, never the file's natural size: the `object-fit: contain`
|
||||||
|
content box (from the natural size and the box). In 1:1 (scale 1) that IS the
|
||||||
|
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.
|
||||||
|
- **Clamped to the stage's CLIENT box**: an arrow never goes past the stage's
|
||||||
|
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
|
||||||
|
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
|
||||||
|
of the image instead of out at the edges unless the image spans the entire
|
||||||
|
width").
|
||||||
|
- Re-placed on picture load (or at once when it is already loaded), stage
|
||||||
|
resize (a `ResizeObserver`, which covers window resizes and the rail
|
||||||
|
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
|
||||||
|
it fails to load, the arrows stay at their CSS spot; they move once the drawn
|
||||||
|
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,
|
||||||
|
test_navigation, test_flow pin them).
|
||||||
|
|
||||||
|
## S4 — drag to pan in 1:1
|
||||||
|
|
||||||
|
- **In 1:1, when the picture overflows the stage on EITHER axis,
|
||||||
|
press-and-drag pans it**, along whichever axes overflow. `grab` cursor at rest,
|
||||||
|
`grabbing` while dragging, pointer capture.
|
||||||
|
- **The picture follows the pointer** (the grab convention): a drag of +dx,
|
||||||
|
+dy changes the stage's scroll by −dx, −dy.
|
||||||
|
- 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
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
- 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
|
||||||
|
`dragstart` `preventDefault` on the stage.
|
||||||
|
- Fit, or a 1:1 picture that fits: no pan, no grab cursor.
|
||||||
|
- Keys, the stage reveal, Reveal all, the rail and the filmstrip are
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- **INV-1 — no server change.** Markup, CSS, page script.
|
||||||
|
- **INV-2 — JS-off parity.** Without JS: Fit-fills, stage-edge arrows, no
|
||||||
|
toggle, no pan, and every judgment (the rail's flag, note and pick forms) and
|
||||||
|
navigation (the arrows and the filmstrip) intact.
|
||||||
|
- **INV-3 — one record of the mode** on the page: `stage-one` on `<html>`.
|
||||||
|
- **INV-4 — the arrows never cover the rail and never leave the stage.**
|
||||||
|
- **INV-5 — storage never raises**, read or write.
|
||||||
|
|
||||||
|
## TESTS
|
||||||
|
|
||||||
|
- `fit_fills_the_stage_up_or_down` [tracer]: a picture smaller than the stage
|
||||||
|
and one larger both draw at the scale `min(W/w, H/h)` in Fit — the contain
|
||||||
|
content box, never cropped — and at natural size in 1:1.
|
||||||
|
- `the_toggle_shows_for_every_picture_and_never_without_js`: a picture that
|
||||||
|
fits at natural size still gets the toggle; video and audio do not; with JS
|
||||||
|
off it never shows.
|
||||||
|
- `the_mode_persists_across_prev_next_and_never_flashes`: choose 1:1, press
|
||||||
|
→ ; an observer installed before any page script records `<html>`'s class at
|
||||||
|
the moment the stage ELEMENT is inserted by the parser — it is already
|
||||||
|
`stage-one` (so no paint can show that stage in Fit); storage throwing still
|
||||||
|
applies the click; a stray stored value reads as Fit.
|
||||||
|
- `the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage`: a portrait
|
||||||
|
picture whose natural width exceeds the stage but which is DRAWN narrower
|
||||||
|
(height-bound in Fit) — each arrow wholly outside the drawn picture, its near
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
reveals and does not pan; `#vimg` is `draggable=false`; in Fit a drag does not
|
||||||
|
scroll.
|
||||||
|
|
||||||
|
## Assertions that change (declared before the code)
|
||||||
|
|
||||||
|
| 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_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
|
||||||
|
|
||||||
|
- Synced pan / the same crop across items, and two panes: r3 (compare).
|
||||||
|
- A zoom level between Fit and 1:1, wheel zoom, pinch.
|
||||||
|
- A key for the mode toggle.
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
# R2c — the review stage: every falsifier the contract claims
|
||||||
|
# (docs/contracts/r2c_review_stage.contract.md), and the change each forbids.
|
||||||
|
|
||||||
|
unit = "the review stage: fit / 1:1, the arrows at the picture, drag-pan"
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S1 Fit never enlarges (the old max-width/max-height cap)"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_fit_fills_the_stage_up_or_down"
|
||||||
|
old = '''
|
||||||
|
.vstage.is-img img{width:100%;height:100%;object-fit:contain;'''
|
||||||
|
new = '''
|
||||||
|
.vstage.is-img img{width:auto;height:auto;max-width:100%;max-height:100%;object-fit:contain;'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S1 Fit crops (cover, not contain)"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_fit_fills_the_stage_up_or_down"
|
||||||
|
old = '''
|
||||||
|
.vstage.is-img img{width:100%;height:100%;object-fit:contain;'''
|
||||||
|
new = '''
|
||||||
|
.vstage.is-img img{width:100%;height:100%;object-fit:cover;'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S2 the toggle stays hidden (the per-picture hide is back)"
|
||||||
|
file = "booth/templates/view.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
|
||||||
|
old = '''
|
||||||
|
toggle.hidden = false;'''
|
||||||
|
new = '''
|
||||||
|
toggle.hidden = img.naturalWidth <= stage.clientWidth;'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S2 the toggle shows without JS (display beats [hidden])"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
|
||||||
|
old = '''
|
||||||
|
.vtoggle[hidden]{display:none}'''
|
||||||
|
new = '''
|
||||||
|
'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S2 1:1 applied late (after the stage exists: a Fit flash)"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
|
||||||
|
old = '''
|
||||||
|
if (localStorage.getItem('booth.fit') === 'one') d.classList.add('stage-one');'''
|
||||||
|
new = '''
|
||||||
|
if (localStorage.getItem('booth.fit') === 'one') document.addEventListener('DOMContentLoaded', function () { d.classList.add('stage-one'); });'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S2 a stray stored value is taken as 1:1"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
|
||||||
|
old = '''
|
||||||
|
if (localStorage.getItem('booth.fit') === 'one') d.classList.add('stage-one');'''
|
||||||
|
new = '''
|
||||||
|
if (localStorage.getItem('booth.fit')) d.classList.add('stage-one');'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S2 a storage write that throws swallows the click"
|
||||||
|
file = "booth/templates/view.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
|
||||||
|
old = '''
|
||||||
|
var setMode = function (one) {
|
||||||
|
d.classList.toggle('stage-one', one);'''
|
||||||
|
new = '''
|
||||||
|
var setMode = function (one) {
|
||||||
|
localStorage.setItem('booth.fit', 'x');
|
||||||
|
d.classList.toggle('stage-one', one);'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S3 the arrows stay at the stage edges (never placed)"
|
||||||
|
file = "booth/templates/view.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
|
||||||
|
old = '''
|
||||||
|
var p = drawn();
|
||||||
|
if (!p) {'''
|
||||||
|
new = '''
|
||||||
|
var p = null;
|
||||||
|
if (!p) {'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S3 the arrows track the file's natural width, not the drawn picture"
|
||||||
|
file = "booth/templates/view.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
|
||||||
|
old = '''
|
||||||
|
var k = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight), w = img.naturalWidth * k;'''
|
||||||
|
new = '''
|
||||||
|
var k = 1, w = img.naturalWidth * k;'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S3 an arrow is not clamped inside the stage"
|
||||||
|
file = "booth/templates/view.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
|
||||||
|
old = '''
|
||||||
|
x = Math.max(lo, Math.min(hi, x));'''
|
||||||
|
new = '''
|
||||||
|
'''
|
||||||
|
|
||||||
|
# NEITHER path: the first draft of this row disabled only the ResizeObserver and
|
||||||
|
# fell through to the window listener, so it stayed green — vacuous.
|
||||||
|
[[mutation]]
|
||||||
|
label = "S3 the arrows do not follow a resize"
|
||||||
|
file = "booth/templates/view.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
|
||||||
|
old = '''
|
||||||
|
if (window.ResizeObserver) new ResizeObserver(settle).observe(stage);
|
||||||
|
else window.addEventListener('resize', settle);'''
|
||||||
|
new = '''
|
||||||
|
'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S4 the pan runs backwards (the picture flees the pointer)"
|
||||||
|
file = "booth/templates/view.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
|
||||||
|
old = '''
|
||||||
|
stage.scrollLeft = drag.l - dx;'''
|
||||||
|
new = '''
|
||||||
|
stage.scrollLeft = drag.l + dx;'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S4 no drag threshold (a jittery click pans)"
|
||||||
|
file = "booth/templates/view.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
|
||||||
|
old = '''
|
||||||
|
if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */'''
|
||||||
|
new = '''
|
||||||
|
'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S4 the picture is draggable again"
|
||||||
|
file = "booth/templates/view.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
|
||||||
|
old = '''alt="{{ file }}" draggable="false">'''
|
||||||
|
new = '''alt="{{ file }}">'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S4 no grab cursor on a pannable 1:1 picture"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
|
||||||
|
old = '''
|
||||||
|
.stage-one .vstage.can-pan{cursor:grab;user-select:none}'''
|
||||||
|
new = '''
|
||||||
|
.stage-one .vstage.can-pan{user-select:none}'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S4 the stage reveal back inside the scrolled content (a pan carries it off)"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
|
||||||
|
old = '''
|
||||||
|
.review-body > .reveal{position:absolute;top:14px;left:14px;z-index:5;'''
|
||||||
|
new = '''
|
||||||
|
.review-body > .reveal{position:absolute;top:14px;left:14px;z-index:-1;'''
|
||||||
|
|
||||||
|
# ---- the heid code-review fold ("VÍGUNDR")
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "1:1 centres a large picture (its start side can never be scrolled to)"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_in_one_to_one_every_pixel_of_a_large_picture_is_reachable"
|
||||||
|
old = '''
|
||||||
|
.stage-one .vstage.is-img{overflow:auto;padding:0;justify-content:flex-start;align-items:flex-start}'''
|
||||||
|
new = '''
|
||||||
|
.stage-one .vstage.is-img{overflow:auto;padding:0}'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "a buttonless hover continues a press released outside the stage"
|
||||||
|
file = "booth/templates/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;'''
|
||||||
+3
-1
@@ -586,7 +586,9 @@ def test_only_a_picture_gets_the_fit_toggle_and_blur_stays_honest(tmp_path):
|
|||||||
c = _client(tmp_path)
|
c = _client(tmp_path)
|
||||||
pic = c.get("/b/g/view?f=a.png").text
|
pic = c.get("/b/g/view?f=a.png").text
|
||||||
assert 'id="vtoggle"' in pic and ">Fit<" in pic and ">1:1<" in pic
|
assert 'id="vtoggle"' in pic and ">Fit<" in pic and ">1:1<" in pic
|
||||||
assert 'class="vstage fit is-blurred"' in pic and "blur is cosmetic" in pic
|
# r2c: the mode moved to <html> (never a Fit flash); the stage says only what it holds
|
||||||
|
assert 'class="vstage is-img is-blurred"' in pic and "blur is cosmetic" in pic
|
||||||
|
assert re.search(r'<span class="vtoggle" id="vtoggle" hidden', pic)
|
||||||
vid = c.get("/b/g/view?f=v.webm").text
|
vid = c.get("/b/g/view?f=v.webm").text
|
||||||
assert 'id="vtoggle"' not in vid and re.search(r"<video\b[^>]*\bcontrols\b", vid)
|
assert 'id="vtoggle"' not in vid and re.search(r"<video\b[^>]*\bcontrols\b", vid)
|
||||||
|
|
||||||
|
|||||||
+433
-17
@@ -394,22 +394,6 @@ def test_the_standalone_marks_page_updates_in_place(browser, live):
|
|||||||
assert same
|
assert same
|
||||||
|
|
||||||
|
|
||||||
def test_the_next_arrow_clears_the_rail_only_beside_it(browser, live):
|
|
||||||
"""Nyx: view.html's bare `.vnext{right:360px}` came later in the page than
|
|
||||||
base.html's narrow override and won it, parking the arrow 360px in from
|
|
||||||
the edge of a phone. Wide: it clears the rail. Narrow: it sits at the edge."""
|
|
||||||
base, root = live
|
|
||||||
_set(root, 3)
|
|
||||||
rights = {}
|
|
||||||
for w in (1400, 390):
|
|
||||||
page = browser.new_page(viewport={"width": w, "height": 900})
|
|
||||||
page.goto(f"{base}/b/g/view?f=01.png", wait_until="networkidle")
|
|
||||||
rights[w] = page.evaluate(
|
|
||||||
"getComputedStyle(document.querySelector('.vnav.vnext')).right")
|
|
||||||
page.close()
|
|
||||||
assert rights == {1400: "360px", 390: "0px"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_desk_never_scrolls_sideways_at_any_width(browser, tmp_path):
|
def test_the_desk_never_scrolls_sideways_at_any_width(browser, tmp_path):
|
||||||
"""The row-controls change made a Desk row a flex container, and a long
|
"""The row-controls change made a Desk row a flex container, and a long
|
||||||
provenance line (nowrap, ellipsised) then set the Desk column's MINIMUM
|
provenance line (nowrap, ellipsised) then set the Desk column's MINIMUM
|
||||||
@@ -538,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
|
||||||
|
|
||||||
|
|
||||||
@@ -1083,3 +1068,434 @@ def test_reveal_all_lifts_the_doc_page_it_reaches(browser, live):
|
|||||||
page.evaluate("getComputedStyle(document.getElementById('docreveal')).display")]
|
page.evaluate("getComputedStyle(document.getElementById('docreveal')).display")]
|
||||||
page.close()
|
page.close()
|
||||||
assert got == ["none", "none"], got
|
assert got == ["none", "none"], got
|
||||||
|
|
||||||
|
|
||||||
|
# ---- r2c: the review stage ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _png(w: int, h: int, rgb=(90, 120, 160)) -> bytes:
|
||||||
|
"""A real, decodable PNG of a given size (no Pillow needed): the stage's
|
||||||
|
geometry depends on NATURAL sizes, which the 8-byte stub has none of."""
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
raw = b"".join(b"\x00" + bytes(rgb) * w for _ in range(h))
|
||||||
|
def chunk(t, d):
|
||||||
|
return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xffffffff)
|
||||||
|
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
||||||
|
+ chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b""))
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_set(root: pathlib.Path, pics: dict) -> pathlib.Path:
|
||||||
|
b = root / "g"
|
||||||
|
b.mkdir()
|
||||||
|
for name, (w, h) in pics.items():
|
||||||
|
(b / name).write_bytes(_png(w, h))
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
_GEOM = """() => {
|
||||||
|
const st = document.getElementById('vstage'), img = document.getElementById('vimg');
|
||||||
|
const r = e => { const b = e.getBoundingClientRect(); return {l: b.left, r: b.right, t: b.top, b: b.bottom, w: b.width, h: b.height}; };
|
||||||
|
const cs = getComputedStyle(st);
|
||||||
|
const box = r(img), nw = img.naturalWidth, nh = img.naturalHeight;
|
||||||
|
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),
|
||||||
|
h: st.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)},
|
||||||
|
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},
|
||||||
|
prev: document.querySelector('.vnav.vprev') && r(document.querySelector('.vnav.vprev')),
|
||||||
|
next: document.querySelector('.vnav.vnext') && r(document.querySelector('.vnav.vnext')),
|
||||||
|
rail: r(document.getElementById('rail'))};
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
def _load(page, url):
|
||||||
|
page.goto(url, wait_until="networkidle")
|
||||||
|
page.wait_for_function("document.getElementById('vimg').complete && document.getElementById('vimg').naturalWidth > 0")
|
||||||
|
page.wait_for_timeout(150)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_fills_the_stage_up_or_down(browser, live):
|
||||||
|
"""r2c S1 (operator ruling: 'Fit may enlarge'). In Fit the picture's box IS
|
||||||
|
the stage's inner box and `object-fit: contain` draws the whole picture at
|
||||||
|
min(W/w, H/h) — UP for a small one, down for a large one, never cropped.
|
||||||
|
In 1:1 it is its natural size."""
|
||||||
|
base, root = live
|
||||||
|
_stage_set(root, {"small.png": (200, 100), "big.png": (3000, 1500)})
|
||||||
|
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||||
|
got = {}
|
||||||
|
for name in ("small.png", "big.png"):
|
||||||
|
_load(page, f"{base}/b/g/view?f={name}")
|
||||||
|
fit = page.evaluate(_GEOM)
|
||||||
|
page.locator("#btn-one").click()
|
||||||
|
page.wait_for_timeout(150)
|
||||||
|
one = page.evaluate(_GEOM)
|
||||||
|
page.locator("#btn-fit").click()
|
||||||
|
got[name] = (fit, one)
|
||||||
|
page.close()
|
||||||
|
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 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 got["small.png"][0]["scale"] > 1.5 # enlarged
|
||||||
|
assert got["big.png"][0]["scale"] < 1 # reduced
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
size still gets Fit | 1:1. Audio gets none. Without JS it never shows."""
|
||||||
|
base, root = live
|
||||||
|
b = _stage_set(root, {"small.png": (200, 100), "large.png": (3000, 2000)})
|
||||||
|
(b / "t.mp3").write_bytes(b"ID3")
|
||||||
|
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||||
|
_load(page, f"{base}/b/g/view?f=small.png")
|
||||||
|
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")
|
||||||
|
audio = page.locator("#vtoggle").count()
|
||||||
|
page.close()
|
||||||
|
ctx = browser.new_context(java_script_enabled=False)
|
||||||
|
nojs_page = ctx.new_page()
|
||||||
|
nojs_page.goto(f"{base}/b/g/view?f=small.png", wait_until="networkidle")
|
||||||
|
nojs = nojs_page.locator("#vtoggle").is_visible()
|
||||||
|
ctx.close()
|
||||||
|
assert pic and audio == 0 and not nojs, (pic, audio, nojs)
|
||||||
|
|
||||||
|
|
||||||
|
_STAGE_WATCH = """
|
||||||
|
window.__stageOne = null;
|
||||||
|
new MutationObserver(function (m, obs) {
|
||||||
|
if (document.getElementById('vstage')) {
|
||||||
|
window.__stageOne = document.documentElement.classList.contains('stage-one');
|
||||||
|
obs.disconnect();
|
||||||
|
}
|
||||||
|
}).observe(document, {childList: true, subtree: true});
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_mode_persists_across_prev_next_and_never_flashes(browser, live):
|
||||||
|
"""r2c S2: 1:1 chosen on one picture holds on the next, and it is in force
|
||||||
|
BEFORE the stage exists — an observer set before any page script records
|
||||||
|
<html>'s class as the parser inserts the stage. A stray stored value reads
|
||||||
|
as Fit; a storage write that throws still applies the click."""
|
||||||
|
base, root = live
|
||||||
|
_stage_set(root, {"a.png": (1600, 1200), "b.png": (1600, 1200)})
|
||||||
|
ctx = browser.new_context(viewport={"width": 1400, "height": 900})
|
||||||
|
page = ctx.new_page()
|
||||||
|
page.add_init_script(_STAGE_WATCH)
|
||||||
|
_load(page, f"{base}/b/g/view?f=a.png")
|
||||||
|
first = page.evaluate("window.__stageOne")
|
||||||
|
page.locator("#btn-one").click()
|
||||||
|
stored = page.evaluate("localStorage.getItem('booth.fit')")
|
||||||
|
page.keyboard.press("ArrowRight")
|
||||||
|
page.wait_for_url("**/view?f=b.png")
|
||||||
|
_load(page, page.url)
|
||||||
|
at_parse = page.evaluate("window.__stageOne")
|
||||||
|
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.reload(wait_until="networkidle")
|
||||||
|
stray = page.evaluate("window.__stageOne")
|
||||||
|
ctx.close()
|
||||||
|
ctx = browser.new_context(viewport={"width": 1400, "height": 900})
|
||||||
|
page = ctx.new_page()
|
||||||
|
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")
|
||||||
|
unreadable = page.evaluate("document.documentElement.classList.contains('stage-one')")
|
||||||
|
page.locator("#btn-one").click()
|
||||||
|
applied = page.evaluate("document.documentElement.classList.contains('stage-one')")
|
||||||
|
applied_pressed = page.locator("#btn-one").get_attribute("aria-pressed")
|
||||||
|
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 at_parse is True and pressed == "true" and fit_pressed == "false", (at_parse, pressed, fit_pressed)
|
||||||
|
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):
|
||||||
|
"""r2c S3 (operator: 'arrows closer to the edge of the image instead of out
|
||||||
|
at the edges unless the image spans the entire width'). A portrait whose
|
||||||
|
NATURAL width exceeds the stage but which is DRAWN narrower (height-bound in
|
||||||
|
Fit): each arrow wholly outside the drawn picture, near edge 8px from it.
|
||||||
|
A landscape drawn as wide as the stage: arrows at the stage's edges, over
|
||||||
|
the picture. Never over the rail; after a resize they follow."""
|
||||||
|
base, root = live
|
||||||
|
_stage_set(root, {"a-tall.png": (1400, 2800), "b-wide.png": (3000, 900)})
|
||||||
|
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||||
|
_load(page, f"{base}/b/g/view?f=a-tall.png")
|
||||||
|
tall = page.evaluate(_GEOM)
|
||||||
|
_load(page, f"{base}/b/g/view?f=b-wide.png")
|
||||||
|
wide = page.evaluate(_GEOM)
|
||||||
|
page.set_viewport_size({"width": 1200, "height": 800})
|
||||||
|
page.goto(f"{base}/b/g/view?f=a-tall.png", wait_until="networkidle")
|
||||||
|
_load(page, page.url)
|
||||||
|
page.set_viewport_size({"width": 1300, "height": 850})
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
resized = page.evaluate(_GEOM)
|
||||||
|
page.close()
|
||||||
|
assert tall["nat"][0] > tall["stage"]["w"], "the fixture must be naturally wider than the stage"
|
||||||
|
for g in (tall, resized):
|
||||||
|
assert abs((g["drawn"]["l"] - 8) - g["prev"]["r"]) <= 2, (g["drawn"], g["prev"])
|
||||||
|
assert abs(g["next"]["l"] - (g["drawn"]["r"] + 8)) <= 2, (g["drawn"], g["next"])
|
||||||
|
for g in (tall, wide, resized):
|
||||||
|
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 abs(wide["prev"]["l"] - (wide["stage"]["l"] + 8)) <= 2 and abs(wide["next"]["r"] - (wide["stage"]["r"] - 8)) <= 2, wide
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away(browser, live):
|
||||||
|
"""r2c S4 (operator: 'mouse click and pan for 1:1 mode ... defeat drag drop
|
||||||
|
of image'). The picture follows the pointer: a drag of (+80, +60) scrolls
|
||||||
|
the stage by (-80, -60). A 2px press pans nothing; a press on the stage's
|
||||||
|
own reveal button reveals and does not pan; the picture is not draggable;
|
||||||
|
in Fit a drag scrolls nothing."""
|
||||||
|
from booth.app import set_blurred
|
||||||
|
base, root = live
|
||||||
|
b = _stage_set(root, {"huge.png": (3000, 3000)})
|
||||||
|
set_blurred(b, "huge.png", True)
|
||||||
|
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||||
|
_load(page, f"{base}/b/g/view?f=huge.png")
|
||||||
|
page.evaluate("document.getElementById('vimg').closest('.vstage').scrollTo(0, 0)")
|
||||||
|
fit_before = 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
|
||||||
|
page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 80, cy + 60, steps=6); page.mouse.up()
|
||||||
|
fit_after = page.evaluate("[document.getElementById('vstage').scrollLeft, document.getElementById('vstage').scrollTop]")
|
||||||
|
page.locator("#btn-one").click()
|
||||||
|
page.evaluate("document.getElementById('vstage').scrollTo(500, 500)")
|
||||||
|
scroll = lambda: page.evaluate("[document.getElementById('vstage').scrollLeft, document.getElementById('vstage').scrollTop]")
|
||||||
|
cursor = page.evaluate("getComputedStyle(document.getElementById('vstage')).cursor")
|
||||||
|
page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 80, cy + 60, steps=6); page.mouse.up()
|
||||||
|
panned = scroll()
|
||||||
|
page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 2, cy + 1, steps=2); page.mouse.up()
|
||||||
|
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
|
||||||
|
# scroll it is in view, a click on it reveals, and a drag from it pans nothing
|
||||||
|
btn = page.locator("#vreveal").bounding_box()
|
||||||
|
bx, by = btn["x"] + btn["width"] / 2, btn["y"] + btn["height"] / 2
|
||||||
|
page.mouse.move(bx, by); page.mouse.down(); page.mouse.move(bx + 80, by + 60, steps=6); page.mouse.up()
|
||||||
|
dragged_from_button = scroll()
|
||||||
|
page.locator("#vreveal").click()
|
||||||
|
revealed = page.evaluate("document.getElementById('vstage').classList.contains('revealed')")
|
||||||
|
after_button = scroll()
|
||||||
|
draggable = page.evaluate("document.getElementById('vimg').draggable")
|
||||||
|
page.close()
|
||||||
|
assert fit_before == fit_after, (fit_before, fit_after)
|
||||||
|
assert cursor == "grab", cursor
|
||||||
|
assert panned == [420, 440], panned
|
||||||
|
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 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