From 7151a45ec29565d00810965c7c419f459b1de8dc Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 22:28:23 -0700 Subject: [PATCH] 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 , 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. --- booth/templates/base.html | 31 ++- booth/templates/view.html | 157 +++++++++++--- docs/contracts/r2c_review_stage.contract.md | 150 +++++++++++++ tests/mutations/r2c.toml | 154 +++++++++++++ tests/test_flow.py | 4 +- tests/test_flow_browser.py | 229 ++++++++++++++++++-- 6 files changed, 667 insertions(+), 58 deletions(-) create mode 100644 docs/contracts/r2c_review_stage.contract.md create mode 100644 tests/mutations/r2c.toml diff --git a/booth/templates/base.html b/booth/templates/base.html index 50c76a9..754653c 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -21,6 +21,11 @@ try { if (b !== null && sessionStorage.getItem('booth.reveal:' + b) === '1') d.classList.add('reveal-all'); } 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) {} })(); {% block title %}The Booth{% endblock %} @@ -735,16 +740,30 @@ .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} .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); 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:hover{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.fit{display:flex;align-items:center;justify-content:center;overflow:hidden;padding:16px} - .vstage.fit img{max-width:100%;max-height:100%;width:auto;height:auto;box-shadow:var(--shadow-lg)} - .vstage.one{overflow:auto;text-align:center} - .vstage.one img{max-width:none;max-height:none;margin:auto} + /* r2c S1 — FIT, the default, with or without JS: 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 (operator: "Fit may enlarge"), never cropped. + 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 (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} .vflag{display:flex} .vnote{display:flex;align-items:flex-start;gap:4px} @@ -761,7 +780,7 @@ .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.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); background:oklch(0.17 0.01 250 / .86);color:oklch(0.91 0.008 216)} .vname .ord{font-weight:600;color:var(--text-heading)} @@ -809,7 +828,7 @@ @media (max-width:900px){ .viewer.review{position:static;display:block;min-height:100vh} .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)} .vnext{right:0} } diff --git a/booth/templates/view.html b/booth/templates/view.html index 354e78e..aeb21b2 100644 --- a/booth/templates/view.html +++ b/booth/templates/view.html @@ -17,8 +17,8 @@ {# 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 fit size and no judgment depends on this. #} -