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

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

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

Contract docs/contracts/r2c_review_stage.contract.md (heid contract
panel 4/4 folded; it changed the no-flash mechanism). Declared test
changes: the Nyx stage-edge arrow test is replaced; the stage class and
the toggle's `hidden` are updated. tests/mutations/r2c.toml 16/16. 803
passed.
This commit is contained in:
vh
2026-09-24 00:20:15 -07:00
parent 0781aa5ee5
commit 7151a45ec2
6 changed files with 667 additions and 58 deletions
+25 -6
View File
@@ -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>
@@ -735,16 +740,30 @@
.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). Auto margins centre a small picture and still let a large
one scroll from its true edge. */
.stage-one .vstage.is-img{overflow:auto;padding:0}
.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}
@@ -761,7 +780,7 @@
.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)}
.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 +828,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}
} }
+122 -35
View File
@@ -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,16 @@
<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. #}
{% if blurred %}<button type="button" class="reveal" id="vreveal" aria-label="reveal {{ file }}">👁 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">
@@ -163,38 +166,122 @@
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 =
Fit), set by the head script before the stage existed; this binds the
toggle, places the arrows at the drawn picture, and pans a 1:1 picture. */
var d = document.documentElement;
var stage = document.getElementById('vstage');
var img = document.getElementById('vimg'); var img = document.getElementById('vimg');
if (img) { var video = stage.querySelector('video');
var stage = document.getElementById('vstage'); var rbody = stage.parentElement; /* .review-body */
var toggle = document.getElementById('vtoggle'); var prevA = rbody.querySelector('.vnav.vprev'), nextA = rbody.querySelector('.vnav.vnext');
var bFit = document.getElementById('btn-fit');
var bOne = document.getElementById('btn-one'); /* The DRAWN picture's left and right edges, in viewport px, or null until
var setMode = function (mode) { they are known (still loading, or failed): the arrows then keep their CSS
var fit = mode === 'fit'; spot. Fit: the object-fit: contain box. 1:1: what shows in the stage. */
stage.classList.toggle('fit', fit); function drawn() {
stage.classList.toggle('one', !fit); var s = stage.getBoundingClientRect();
bFit.classList.toggle('on', fit); if (img) {
bOne.classList.toggle('on', !fit); if (!img.naturalWidth) return null;
}; var b = img.getBoundingClientRect();
/* "fits" == the image at natural size already sits inside the stage, so if (d.classList.contains('stage-one')) return {l: Math.max(b.left, s.left), r: Math.min(b.right, s.right)};
Fit and 1:1 would render identically — then the toggle is hidden. */ var k = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight), w = img.naturalWidth * k;
var evaluate = function () { return {l: b.left + (b.width - w) / 2, r: b.left + (b.width + w) / 2};
if (!img.naturalWidth) return; }
if (img.naturalWidth <= stage.clientWidth && img.naturalHeight <= stage.clientHeight) { if (video && video.videoWidth) {
toggle.style.display = 'none'; var v = video.getBoundingClientRect();
setMode('fit'); return {l: v.left, r: v.right};
} else { }
toggle.style.display = 'inline-flex'; return null;
if (!stage.classList.contains('one')) setMode('fit');
}
};
bFit.addEventListener('click', function () { setMode('fit'); });
bOne.addEventListener('click', function () { setMode('one'); });
img.addEventListener('load', evaluate);
window.addEventListener('resize', evaluate);
if (img.complete) evaluate();
} }
/* 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) return;
var s = stage.getBoundingClientRect(), o = rbody.getBoundingClientRect();
[[prevA, -1], [nextA, 1]].forEach(function (pair) {
var a = pair[0];
if (!a) return;
var w = a.offsetWidth, lo = s.left + 8, hi = s.right - 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 bFit = document.getElementById('btn-fit'), bOne = document.getElementById('btn-one');
var show = function () {
var one = d.classList.contains('stage-one');
bFit.classList.toggle('on', !one);
bOne.classList.toggle('on', one);
bFit.setAttribute('aria-pressed', one ? 'false' : 'true');
bOne.setAttribute('aria-pressed', one ? 'true' : 'false');
};
/* The click applies to the page first and is remembered second: storage
that throws costs the memory, never the click. */
var setMode = function (one) {
d.classList.toggle('stage-one', one);
try {
if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.removeItem('booth.fit');
} catch (e) {}
show();
settle();
};
toggle.hidden = false;
show();
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;
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;
var dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if (!drag.on) {
if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return;
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();
});
var endDrag = function () {
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);
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
+150
View File
@@ -0,0 +1,150 @@
---
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.
- **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
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. Never raises.
- 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: in Fit, the `object-fit:
contain` content box (from the natural size and the box); in 1:1, the part of
the picture visible in the stage (its box intersected with the stage's). A
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
inset), never over the rail, never off the stage. 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), mode switch and 1:1 scroll.
- **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
the drawn box is known. Without JS they stay there.
- 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 is not a drag: nothing pans.
- **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.
- **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_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 `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) |
## 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.
+154
View File
@@ -0,0 +1,154 @@
# R2c — the review stage: every falsifier the contract claims
# (docs/contracts/r2c_review_stage.contract.md), and the change each forbids.
unit = "the review stage: fit / 1:1, the arrows at the picture, drag-pan"
[[mutation]]
label = "S1 Fit never enlarges (the old max-width/max-height cap)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_fit_fills_the_stage_up_or_down"
old = '''
.vstage.is-img img{width:100%;height:100%;object-fit:contain;'''
new = '''
.vstage.is-img img{width:auto;height:auto;max-width:100%;max-height:100%;object-fit:contain;'''
[[mutation]]
label = "S1 Fit crops (cover, not contain)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_fit_fills_the_stage_up_or_down"
old = '''
.vstage.is-img img{width:100%;height:100%;object-fit:contain;'''
new = '''
.vstage.is-img img{width:100%;height:100%;object-fit:cover;'''
[[mutation]]
label = "S2 the toggle stays hidden (the per-picture hide is back)"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
old = '''
toggle.hidden = false;'''
new = '''
toggle.hidden = img.naturalWidth <= stage.clientWidth;'''
[[mutation]]
label = "S2 the toggle shows without JS (display beats [hidden])"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
old = '''
.vtoggle[hidden]{display:none}'''
new = '''
'''
[[mutation]]
label = "S2 1:1 applied late (after the stage exists: a Fit flash)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
if (localStorage.getItem('booth.fit') === 'one') d.classList.add('stage-one');'''
new = '''
if (localStorage.getItem('booth.fit') === 'one') document.addEventListener('DOMContentLoaded', function () { d.classList.add('stage-one'); });'''
[[mutation]]
label = "S2 a stray stored value is taken as 1:1"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
if (localStorage.getItem('booth.fit') === 'one') d.classList.add('stage-one');'''
new = '''
if (localStorage.getItem('booth.fit')) d.classList.add('stage-one');'''
[[mutation]]
label = "S2 a storage write that throws swallows the click"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
var setMode = function (one) {
d.classList.toggle('stage-one', one);'''
new = '''
var setMode = function (one) {
localStorage.setItem('booth.fit', 'x');
d.classList.toggle('stage-one', one);'''
[[mutation]]
label = "S3 the arrows stay at the stage edges (never placed)"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
old = '''
var p = drawn();
if (!p) return;'''
new = '''
var p = null;
if (!p) return;'''
[[mutation]]
label = "S3 the arrows track the file's natural width, not the drawn picture"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
old = '''
var k = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight), w = img.naturalWidth * k;'''
new = '''
var k = 1, w = img.naturalWidth * k;'''
[[mutation]]
label = "S3 an arrow is not clamped inside the stage"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
old = '''
x = Math.max(lo, Math.min(hi, x));'''
new = '''
'''
# NEITHER path: the first draft of this row disabled only the ResizeObserver and
# fell through to the window listener, so it stayed green — vacuous.
[[mutation]]
label = "S3 the arrows do not follow a resize"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
old = '''
if (window.ResizeObserver) new ResizeObserver(settle).observe(stage);
else window.addEventListener('resize', settle);'''
new = '''
'''
[[mutation]]
label = "S4 the pan runs backwards (the picture flees the pointer)"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
stage.scrollLeft = drag.l - dx;'''
new = '''
stage.scrollLeft = drag.l + dx;'''
[[mutation]]
label = "S4 no drag threshold (a jittery click pans)"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return;'''
new = '''
'''
[[mutation]]
label = "S4 the picture is draggable again"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''alt="{{ file }}" draggable="false">'''
new = '''alt="{{ file }}">'''
[[mutation]]
label = "S4 no grab cursor on a pannable 1:1 picture"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
.stage-one .vstage.can-pan{cursor:grab;user-select:none}'''
new = '''
.stage-one .vstage.can-pan{user-select:none}'''
[[mutation]]
label = "S4 the stage reveal back inside the scrolled content (a pan carries it off)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
.review-body > .reveal{position:absolute;top:14px;left:14px;z-index:5;'''
new = '''
.review-body > .reveal{position:absolute;top:14px;left:14px;z-index:-1;'''
+3 -1
View File
@@ -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)
+213 -16
View File
@@ -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
@@ -1083,3 +1067,216 @@ 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, scale: k,
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", (name, fit["fit"])
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)})
(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()
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")
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()
page.add_init_script("Storage.prototype.setItem = function () { throw new Error('quota'); };")
_load(page, f"{base}/b/g/view?f=a.png")
page.locator("#btn-one").click()
applied = page.evaluate("document.documentElement.classList.contains('stage-one')")
ctx.close()
assert first is False and stored == "one", (first, stored)
assert at_parse is True and pressed == "true", (at_parse, pressed)
assert stray is False and applied is True, (stray, applied)
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
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 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 revealed and after_button == [420, 440], (revealed, after_button)
assert draggable is False