feat(r2): C6 the review, and C7

- The zoom route becomes the review for image, video AND audio: the native
  player on the stage for sound and video, the Fit/1:1 toggle for pictures
  only. The judgment rail, the tape and the filmstrip are each a data-region.
  The stage never is, so a playing track survives an in-place save.
- The rail shows the whole-set number, K of M in the review ring and the
  position in the group; then the caption, and the flag and notes, landing
  back here (back=view). A pick targeting this item is answerable in place.
  On the last item the end-of-set block lists what was seen, the flags, and
  every other open question.
- The keys are ← → Space F N Esc. Every one is ignored in an editable field,
  and Esc returns to the grid at the tile you were on.
- _marks.html gains picks_only/back_view, so a pick form has one renderer
  wherever it sits.
- In-place swaps now carry an unsaved draft across. A half-typed note
  survives a flag, except in the form that was just sent.
- The filmstrip keeps the current frame in view.
- C7: no emblem in the chrome, pinned.

Browser tests cover: F typed into the note stays a letter and does not
flag; F outside the note flags in place and the draft survives; Space
moves; Esc lands on the grid tile. 706 passed.
This commit is contained in:
vh
2026-09-23 09:06:15 -07:00
parent 50f88a3e5e
commit f8cb1b29af
6 changed files with 442 additions and 94 deletions
+188 -78
View File
@@ -1,52 +1,136 @@
{% extends "base.html" %}
{% block title %}{{ file }} · {{ name }} · The Booth{% 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">
<div class="viewer review">
<div class="vbar">
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)">✕</a>
<span class="vname">{{ file }}</span>
<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" style="display:none">
<button type="button" class="vseg on" id="btn-fit">Fit</button><button type="button" class="vseg" id="btn-one">1:1</button>
</span>
{% endif %}
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a>
</div>
{% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous image">‹</a>{% endif %}
{% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next image">›</a>{% endif %}
<div class="vstage fit" id="vstage"><img id="vimg" src="{{ file_url }}" alt="{{ file }}"></div>
{# THE ANNOTATION, at full size. It was never rendered here before U1 — not
because the template dropped it, but because the route never resolved it.
A caption is most useful at the size where you are actually judging the
thing, so it belongs here at least as much as in the grid. #}
{% if caption %}<div class="vcap">{{ caption }}</div>{% endif %}
{# INV-3: the JUDGMENT travels to full size too, not just the caption. This is
the size at which the operator is actually deciding, so the flag toggle and
the notes belong here at least as much as on the tile. #}
<div class="vmarks">
<form class="vflag" method="post" action="/b/{{ name_url }}/flag">
<input type="hidden" name="target" value="{{ file }}">
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
<button class="vbtn{% if flagged %} is-flagged{% endif %}"
title="{{ 'un-flag this item' if flagged else 'flag this one' }}"
>{{ '✔ flagged' if flagged else '○ flag' }}</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">
<input type="hidden" name="mark" value="{{ m.id }}">
<button class="mark-x" title="withdraw this note">×</button>
{# 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 fit{% if blurred %} is-blurred{% endif %}" id="vstage">
{% if kind == 'image' %}<img id="vimg" src="{{ file_url }}" alt="{{ file }}">
{% 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 %}
{% if blurred %}<button type="button" class="reveal" id="vreveal" aria-label="reveal {{ file }}">👁 reveal — blur is cosmetic</button>{% endif %}
</div>
{% 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>
{% endfor %}
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note">
<input type="hidden" name="target" value="{{ file }}">
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
<button type="submit">Add note</button>
</form>
{# 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" src="{{ 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" src="{{ 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:fixed;top:50%;transform:translateY(-50%);z-index:40;display:flex;
.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;
@@ -54,67 +138,93 @@
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)}
.vprev{left:0}.vnext{right:0}
/* Bottom bar rather than the top chrome: a caption can run to CAPTION_MAX
(800 chars), which would shove the filename and the Fit/1:1 toggle around. */
.vcap{flex:0 0 auto;max-height:22vh;overflow-y:auto;padding:10px clamp(12px,3vw,20px);
font-size:var(--size-body);line-height:var(--leading-body);color:var(--text-body);background:var(--surface-base);
border-top:1px solid var(--border-subtle);white-space:pre-wrap}
@media print{.vcap{max-height:none;overflow:visible}}
@media print{.vnav{display:none}}
.vprev{left:0}.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 img = document.getElementById('vimg');
var stage = document.getElementById('vstage');
var toggle = document.getElementById('vtoggle');
var bFit = document.getElementById('btn-fit');
var bOne = document.getElementById('btn-one');
var BACK = {{ ('/b/' ~ name_url ~ '/')|tojson }};
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 }};
function setMode(mode) {
var fit = mode === 'fit';
stage.classList.toggle('fit', fit);
stage.classList.toggle('one', !fit);
bFit.classList.toggle('on', fit);
bOne.classList.toggle('on', !fit);
/* Fit / 1:1 — bound only when the stage is a picture (R2 C6). */
var img = document.getElementById('vimg');
if (img) {
var stage = document.getElementById('vstage');
var toggle = document.getElementById('vtoggle');
var bFit = document.getElementById('btn-fit');
var bOne = document.getElementById('btn-one');
var setMode = function (mode) {
var fit = mode === 'fit';
stage.classList.toggle('fit', fit);
stage.classList.toggle('one', !fit);
bFit.classList.toggle('on', fit);
bOne.classList.toggle('on', !fit);
};
/* "fits" == the image at natural size already sits inside the stage, so
Fit and 1:1 would render identically — then the toggle is hidden. */
var evaluate = function () {
if (!img.naturalWidth) return;
if (img.naturalWidth <= stage.clientWidth && img.naturalHeight <= stage.clientHeight) {
toggle.style.display = 'none';
setMode('fit');
} else {
toggle.style.display = 'inline-flex';
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();
}
// "fits" == the image at natural size already sits inside the stage, so Fit
// and 1:1 would render identically — in that case we hide the toggle entirely.
function fits() {
return img.naturalWidth <= stage.clientWidth && img.naturalHeight <= stage.clientHeight;
}
function evaluate() {
if (!img.naturalWidth) return;
if (fits()) {
toggle.style.display = 'none';
setMode('fit');
} else {
toggle.style.display = 'inline-flex';
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();
/* An arrow key inside the note field is a CARET move, not a navigation.
The handler is on `document` and the note textarea shipped into this same
page, so typing a note and reaching for ← threw the draft away; Escape
did it in one keystroke. Anything editable keeps its own keys. */
/* 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;
else if (e.key === ' ' && NEXT && e.target !== player) { 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>