Files
booth/booth/templates/view.html
T
vh 7151a45ec2 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.
2026-09-24 00:20:15 -07:00

336 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
{# THE REVIEW (R2 C6). One media item at full size — image, video or audio —
with the judgment on screen beside it, the whole set as a filmstrip below and
the tape above. Docs keep doc.html. Everything a mark can change is a
`data-region` the in-place script swaps (the rail, the filmstrip, the tape);
THE STAGE NEVER IS — swapping it would restart a playing track. #}
{% macro num(n) -%}#{{ "%0*d"|format(ord_width, n) }}{%- endmacro %}
{% block content %}
<div class="viewer review">
<div class="vbar">
<a class="vbtn vx" href="{{ back_url }}" title="back to the grid (Esc)">✕</a>
<span class="vname"><span class="ord">{{ num(ordinal) }}</span> {{ file }}</span>
<span class="vspacer"></span>
{% if kind == 'image' %}
{# 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. #}
<span class="vtoggle" id="vtoggle" hidden>
<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>
{% endif %}
{# r2b D2b + D2, in the top bar: outside every data-region, so no swap
replaces them. The fog form carries `back` and lands on this item. #}
<span class="region-wrap" data-region="blur-booth"><form class="blur-all{% if booth_blurred %} is-on{% endif %}" method="post" action="/b/{{ name_url }}/blurbooth">
<input type="hidden" name="on" value="{{ '0' if booth_blurred else '1' }}">
<input type="hidden" name="back" value="{{ file }}">
<button title="{{ 'un-blur the whole booth' if booth_blurred else 'blur every image and video in this booth — cosmetic only' }}">{{ '◉ booth blurred' if booth_blurred else '◌ blur booth' }}</button>
</form></span>
{% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a>
</div>
{# THE TAPE (B's device): one segment per item in the review ring — seen,
flagged, current — so how far through the set you are is always in view. #}
<div class="tape" data-region="tape" aria-label="{{ seen_n }} of {{ ring_m }} seen">
<div class="tape-segs">
{% for x in film %}
<a class="tape-s{% if x.current %} is-current{% elif x.flagged %} is-flagged{% elif x.seen %} is-seen{% endif %}"
href="?f={{ x.url }}" title="{{ num(x.ordinal) }} {{ x.name }}"></a>
{% endfor %}
</div>
<span class="tape-count">{{ seen_n }} of {{ ring_m }} seen</span>
</div>
<div class="review-body">
{% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous">‹</a>{% endif %}
<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 }}" draggable="false">
{% elif kind == 'video' %}<video id="vmedia" controls preload="metadata" src="{{ file_url }}"></video>
{% else %}<audio id="vmedia" controls preload="metadata" src="{{ file_url }}"></audio>
{% endif %}
</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 %}
<aside class="vrail" id="rail" data-region="rail" aria-label="your judgment">
<div class="vr-sec">
<div class="vr-where"><span class="ord">{{ num(ordinal) }}</span> · {{ ring_k }} of {{ ring_m }}
{%- if group %} · {{ group.k }} of {{ group.n }} in {{ group.key }}{% endif %}</div>
{# THE ANNOTATION, at full size — the size where it is most readable. #}
{% if caption %}<div class="vcap">{{ caption }}</div>{% endif %}
</div>
<div class="vr-sec vr-judge">
<form class="vflag" method="post" action="/b/{{ name_url }}/flag" data-inplace>
<input type="hidden" name="target" value="{{ file }}">
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
<input type="hidden" name="back" value="view">
<input type="hidden" name="f" value="{{ file }}">
<button class="vbtn vflag-btn{% if flagged %} is-flagged{% endif %}" id="vflag-btn"
title="{{ 'un-flag this item' if flagged else 'flag this one' }} (F)"
>{{ '✔ flagged' if flagged else '○ flag' }} <kbd>F</kbd></button>
</form>
{% for m in marks if m.shape == 'note' %}
<div class="vnote"><pre>{{ m.text }}</pre>
<form method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ m.id }}">
<input type="hidden" name="back" value="view">
<input type="hidden" name="f" value="{{ file }}">
<button class="mark-x" title="withdraw this note">×</button>
</form>
</div>
{% endfor %}
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note" data-inplace>
<input type="hidden" name="target" value="{{ file }}">
<input type="hidden" name="back" value="view">
<input type="hidden" name="f" value="{{ file }}">
<textarea name="text" id="vnote-text" rows="2" placeholder="a note on this item (N)"></textarea>
<button type="submit">Add note</button>
</form>
</div>
{# A question ABOUT this item is answerable here. #}
{% if item_picks %}
<div class="vr-sec">
{% with marks=item_picks, picks_only=true, back_view=file, marks_page=false %}{% include "_marks.html" %}{% endwith %}
</div>
{% endif %}
{% if is_last %}
{# THE END OF THE SET — not a separate page: on the last item the rail
adds the summary and every question still open on the booth. #}
<div class="vr-sec vr-end">
<p class="vr-end-head">End of the set · {{ seen_n }} of {{ ring_m }} seen · {{ tray|length }} flagged</p>
{% if tray %}
<div class="tray">
{% for x in tray %}
<a class="tray-item{% if x.blurred %} is-blurred{% endif %}" href="?f={{ x.url }}" title="{{ x.name }}">
{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="tray-kind">{{ x.kind }}</span>{% endif -%}
<span class="tray-ord">{{ num(x.ordinal) }}</span></a>
{% endfor %}
</div>
{% endif %}
{% if other_picks %}
{% with marks=other_picks, picks_only=true, back_view=file, marks_page=false %}{% include "_marks.html" %}{% endwith %}
{% endif %}
</div>
{% elif other_picks %}
<div class="vr-sec vr-more">
<a href="/b/{{ name_url }}/">{{ other_picks|length }} more open question{{ '' if other_picks|length == 1 else 's' }} on this booth →</a>
</div>
{% endif %}
<div class="vr-keys"><kbd>←</kbd> <kbd>→</kbd> <kbd>Space</kbd> move · <kbd>F</kbd> flag · <kbd>N</kbd> note · <kbd>Esc</kbd> grid</div>
</aside>
</div>
{# THE FILMSTRIP: the review ring in set order, numbered like the tiles,
flagged frames underlined, the current one in the reticle. #}
<nav class="film" data-region="film" aria-label="the set">
{% for x in film %}
<a class="film-f{% if x.flagged %} is-flagged{% endif %}{% if x.current %} is-current{% endif %}{% if x.blurred %} is-blurred{% endif %}"
href="?f={{ x.url }}" title="{{ x.name }}"{% if x.current %} aria-current="true"{% endif %}>
{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="film-kind">{{ '♪' if x.kind == 'audio' else '▶' }}</span>{% endif -%}
<span class="film-ord">{{ num(x.ordinal) }}</span></a>
{% endfor %}
</nav>
</div>
<style>
.vnav{position:absolute;top:50%;transform:translateY(-50%);z-index:4;display:flex;
align-items:center;justify-content:center;width:40px;height:56px;font-size:28px;
line-height:1;text-decoration:none;color:oklch(0.91 0.008 216);background:oklch(0.17 0.01 250 / .6);
border:1px solid rgb(255 255 255 / .12);border-radius:var(--radius-lg);margin:0 8px;user-select:none;
-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);
transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)}
.vnav:hover{background:oklch(0.21 0.01 248 / .92);border-color:rgb(255 255 255 / .3);text-decoration:none;
color:oklch(0.91 0.008 216)}
/* The next arrow clears the 360px verdict rail only while the rail sits
beside the stage. Scoped to the wide layout: stated bare, this rule came
later in the page than base.html's narrow override and silently won it,
parking the arrow 360px in from the edge of a phone. */
.vprev{left:0}.vnext{right:0}
@media (min-width:901px){.vnext{right:360px}}
.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}
@media print{.vcap{max-height:none;overflow:visible}.vnav{display:none}}
</style>
<script>
(function () {
var BACK = {{ back_url|tojson }};
var PREV = {{ (('?f=' ~ prev_url) if prev_url else '')|tojson }};
var NEXT = {{ (('?f=' ~ next_url) if next_url else '')|tojson }};
/* 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 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. Fit: the object-fit: contain box. 1:1: what shows in the stage. */
function drawn() {
var s = stage.getBoundingClientRect();
if (img) {
if (!img.naturalWidth) return null;
var b = img.getBoundingClientRect();
if (d.classList.contains('stage-one')) return {l: Math.max(b.left, s.left), r: Math.min(b.right, s.right)};
var k = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight), w = img.naturalWidth * k;
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) 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
in-place save swaps the strip for a fresh one. Additive: without it the
strip is still a row of links. */
function centreFilm() {
var cur = document.querySelector('.film-f.is-current');
var film = document.querySelector('.film');
if (cur && film) film.scrollLeft = cur.offsetLeft - (film.clientWidth - cur.offsetWidth) / 2;
}
centreFilm();
document.addEventListener('booth:swapped', centreFilm);
/* Blur reveal on the stage — per-viewer, never persisted. Cosmetic, and
the button says so. */
var rv = document.getElementById('vreveal');
if (rv) rv.addEventListener('click', function () {
var on = document.getElementById('vstage').classList.toggle('revealed');
rv.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic';
});
/* EVERY key here, new and old, is ignored while focus is in something
editable: an arrow key in the note field is a caret move, and F typed
into a note is a letter, not a flag. */
function isEditable(el) {
return !!(el && (el.isContentEditable ||
/^(input|textarea|select)$/i.test(el.tagName || '')));
}
document.addEventListener('keydown', function (e) {
if (isEditable(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
/* Space moves only when the stage is not a player that wants it. */
var player = document.getElementById('vmedia');
if (e.key === 'Escape') window.location.href = BACK;
else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV;
else if (e.key === 'ArrowRight' && NEXT) window.location.href = NEXT;
/* ...and never from a focused control: Space is how a keyboard presses a
button or follows a link (r2b, heid bug-hunt — Reveal all and the fog
control could not be pressed). */
else if (e.key === ' ' && NEXT && e.target !== player && !(e.target.closest && e.target.closest('button, a, summary'))) { e.preventDefault(); window.location.href = e.shiftKey && PREV ? PREV : NEXT; }
else if (e.key === 'f' || e.key === 'F') {
var b = document.getElementById('vflag-btn'); /* re-read: the rail may have been swapped */
if (b) { e.preventDefault(); b.click(); }
}
else if (e.key === 'n' || e.key === 'N') {
var t = document.getElementById('vnote-text');
if (t) { e.preventDefault(); t.focus(); }
}
});
})();
</script>
{% endblock %}