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
+48 -11
View File
@@ -1706,19 +1706,56 @@ def create_app(
"flagged": any(m.shape == "flag" for m in item_marks), "flagged": any(m.shape == "flag" for m in item_marks),
} }
if item is not None and item.kind == "image": if item is not None and item.kind in REVIEW_KINDS:
# prev/next ring (wraps; only when there is more than one item in # THE REVIEW (R2 C6): images, video and audio at full size with the
# it). R2 C2: the ring is `review_chain` — the item order filtered to # judgment on screen. The ring is `review_chain` — the item order
# MEDIA — so a set that mixes pictures and sound steps through both. # filtered to MEDIA — and the prev/next, the filmstrip and the tape
names = review_chain(items) # all read that ONE list, so they cannot disagree about "next".
ring = review_chain(items)
by_rel = {it.rel: it for it in items}
pos = ring.index(f)
prev_url = next_url = None prev_url = next_url = None
if f in names and len(names) > 1: if len(ring) > 1:
i = names.index(f) prev_url = quote(ring[(pos - 1) % len(ring)], safe="/")
prev_url = quote(names[(i - 1) % len(names)], safe="/") next_url = quote(ring[(pos + 1) % len(ring)], safe="/")
next_url = quote(names[(i + 1) % len(names)], safe="/") flagged_rels = {m.target for m in marks
if m.shape == "flag" and m.error is None}
# recorded above, before this read: the current item counts as seen
seen = read_seen(booth) & set(ring)
film = [{"name": r, "url": by_rel[r].url, "ordinal": by_rel[r].ordinal,
"kind": by_rel[r].kind, "blurred": by_rel[r].blurred,
"flagged": r in flagged_rels, "seen": r in seen,
"current": r == f} for r in ring]
# Position within the group, only when there IS grouping: two or
# more groups among the ring. One group for everything says nothing.
group = None
ring_groups = {by_rel[r].group for r in ring if by_rel[r].group}
if item.group and len(ring_groups) > 1:
members = [r for r in ring if by_rel[r].group == item.group]
group = {"key": item.group, "k": members.index(f) + 1, "n": len(members)}
open_now = open_marks(marks)
return templates.TemplateResponse( return templates.TemplateResponse(
request, "view.html", {**common, "prev_url": prev_url, "next_url": next_url} request, "view.html", {
) **common,
"kind": item.kind,
"ordinal": item.ordinal,
"ord_width": len(str(len(items))),
"ring_k": pos + 1,
"ring_m": len(ring),
"prev_url": prev_url,
"next_url": next_url,
"film": film,
"seen_n": len(seen),
"group": group,
# a question ABOUT this item is answerable here; the rest are
# a count and a link — until the last item, where the end of
# the set offers them all
"item_picks": [m for m in open_now if m.target == f],
"other_picks": [m for m in open_now if m.target != f],
"is_last": pos == len(ring) - 1,
"tray": [x for x in film if x["flagged"]],
"back_url": f"/b/{quote(name, safe='')}/#item-{item.url}",
})
# .md renders, .txt/.log show as text — viewable in-booth, no download # .md renders, .txt/.log show as text — viewable in-booth, no download
if item is not None: if item is not None:
+9
View File
@@ -26,6 +26,11 @@
{% set flags = marks | selectattr('shape', 'equalto', 'flag') | rejectattr('error') | list %} {% set flags = marks | selectattr('shape', 'equalto', 'flag') | rejectattr('error') | list %}
<section class="marks"> <section class="marks">
{# `picks_only` + `back_view`: the review rail (view.html) includes this panel
with `marks` narrowed to the open picks it should offer, and wants only the
pick forms, each landing back on the review (`back=view`, R2 C3). ONE
renderer of a pick form, whichever page it sits on. #}
{% if not picks_only %}
{% for a in broken %} {% for a in broken %}
<article class="mark mark-note is-broken" id="mark-{{ a.id }}"> <article class="mark mark-note is-broken" id="mark-{{ a.id }}">
<header class="mark-head"> <header class="mark-head">
@@ -42,6 +47,7 @@
</article> </article>
{% endfor %} {% endfor %}
{% endif %}
{% for a in picks %} {% for a in picks %}
<article class="mark mark-pick{% if a.answer and a.answer.complete %} is-answered{% elif a.answer %} is-partial{% elif a.error %} is-broken{% endif %}" id="mark-{{ a.id }}"> <article class="mark mark-pick{% if a.answer and a.answer.complete %} is-answered{% elif a.answer %} is-partial{% elif a.error %} is-broken{% endif %}" id="mark-{{ a.id }}">
<header class="mark-head"> <header class="mark-head">
@@ -83,6 +89,7 @@
{# On the standalone page, come back HERE — the booth's own page is a {# On the standalone page, come back HERE — the booth's own page is a
verbatim report that cannot show the recorded judgment. #} verbatim report that cannot show the recorded judgment. #}
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %} {% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
{% if back_view %}<input type="hidden" name="back" value="view"><input type="hidden" name="f" value="{{ back_view }}">{% endif %}
{% for q in a.questions %} {% for q in a.questions %}
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %} {% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
{% set qa = a.answer.answers.get(q.key) if (a.answer and a.multi) else a.answer %} {% set qa = a.answer.answers.get(q.key) if (a.answer and a.multi) else a.answer %}
@@ -117,6 +124,7 @@
</article> </article>
{% endfor %} {% endfor %}
{% if not picks_only %}
{# FLAGS come right after the picks. On the lightbox (`tray` defined) they {# FLAGS come right after the picks. On the lightbox (`tray` defined) they
render as the TRAY: the flagged items in SET order — by tile number, the render as the TRAY: the flagged items in SET order — by tile number, the
declared R2 change from the click order below — each the original shown declared R2 change from the click order below — each the original shown
@@ -176,4 +184,5 @@
<textarea name="text" rows="2" placeholder="a note on this booth, for the session that posted it"></textarea> <textarea name="text" rows="2" placeholder="a note on this booth, for the session that posted it"></textarea>
<button type="submit">Add note</button> <button type="submit">Add note</button>
</form> </form>
{% endif %}
</section> </section>
+79 -5
View File
@@ -547,7 +547,7 @@
pseudo-element, so no markup is added. They sit 4px INSIDE the box: an pseudo-element, so no markup is added. They sit 4px INSIDE the box: an
overflow:hidden tile clips an outside bracket's entire stroke (the SVOS overflow:hidden tile clips an outside bracket's entire stroke (the SVOS
foot-gun). A hairline shadow keeps them visible over a bright image. */ foot-gun). A hairline shadow keeps them visible over a bright image. */
.item.is-cursor::after,.item:target::after,.mark-opt:has(input:checked)::after{content:"";position:absolute;inset:4px;z-index:3; .item.is-cursor::after,.item:target::after,.mark-opt:has(input:checked)::after,.film-f.is-current::after{content:"";position:absolute;inset:4px;z-index:3;
pointer-events:none;--rl:16px;--rt:2px; pointer-events:none;--rl:16px;--rt:2px;
background: background:
linear-gradient(var(--accent) 0 0) top left / var(--rl) var(--rt), linear-gradient(var(--accent) 0 0) top left / var(--rl) var(--rt),
@@ -560,6 +560,7 @@
linear-gradient(var(--accent) 0 0) bottom right / var(--rt) var(--rl); linear-gradient(var(--accent) 0 0) bottom right / var(--rt) var(--rl);
background-repeat:no-repeat;filter:drop-shadow(0 0 1px oklch(0.14 0.01 250 / .7))} background-repeat:no-repeat;filter:drop-shadow(0 0 1px oklch(0.14 0.01 250 / .7))}
.mark-opt:has(input:checked)::after{inset:3px;--rl:10px;--rt:1.5px;filter:none} .mark-opt:has(input:checked)::after{inset:3px;--rl:10px;--rt:1.5px;filter:none}
.film-f.is-current::after{inset:2px;--rl:9px;--rt:1.5px}
.item.is-cursor{border-color:color-mix(in oklab,var(--accent) 60%,transparent)} .item.is-cursor{border-color:color-mix(in oklab,var(--accent) 60%,transparent)}
/* ⚠ Blur is COSMETIC. The file is still served at its own URL and still in /* ⚠ Blur is COSMETIC. The file is still served at its own URL and still in
@@ -657,6 +658,69 @@
.vnote pre{flex:1;min-width:0} .vnote pre{flex:1;min-width:0}
pre.vnote{display:block} pre.vnote{display:block}
/* ---- THE REVIEW (R2 C6) ------------------------------------------------
Bar, tape, stage + rail, filmstrip. The rail scrolls on its own so the
judgment stays beside the artifact at any caption length. */
.viewer.review{display:grid;grid-template-rows:auto auto minmax(0,1fr) auto}
.review-body{position:relative;display:grid;grid-template-columns:minmax(0,1fr) 360px;min-height:0}
.review .vstage{position:relative;min-height:0}
.review .vstage audio{width:min(640px,92%)}
.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);
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)}
.vrail{display:flex;flex-direction:column;min-height:0;overflow:auto;border-left:1px solid var(--border-subtle);
background:var(--surface-card)}
.vr-sec{padding:12px 14px;border-bottom:1px solid var(--border-subtle)}
.vr-sec .marks{margin:0}
.vr-where{font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)}
.vr-where .ord{color:var(--text-heading);font-weight:600}
.vr-judge{display:flex;flex-direction:column;gap:8px}
.vr-judge .vflag-btn{width:100%;height:40px;justify-content:center;font-family:var(--font-sans);font-weight:600}
.vr-judge .vaddnote{flex-direction:column;align-items:stretch}
.vr-judge .vaddnote button{align-self:flex-end}
.vr-end-head{margin:0 0 8px;font-family:var(--font-mono);font-size:var(--size-micro);letter-spacing:var(--tracking-caps);
text-transform:uppercase;color:var(--success-text)}
.vr-end .tray{padding:0 0 10px}
.vr-more a{font-size:var(--size-sm)}
.vr-keys{margin-top:auto;padding:10px 14px;font-family:var(--font-mono);font-size:var(--size-micro);color:var(--text-muted);
border-top:1px solid var(--border-subtle)}
kbd{display:inline-block;min-width:1.4em;padding:1px 5px;border:1px solid var(--border-strong);border-radius:var(--radius-sm);
background:var(--surface-sunken);font-family:var(--font-mono);font-size:var(--size-micro);line-height:1.3;
color:var(--text-body);text-align:center}
.tape{display:flex;align-items:center;gap:12px;padding:8px 14px;background:var(--surface-base);
border-bottom:1px solid var(--border-subtle)}
.tape-segs{flex:1;display:flex;gap:2px;align-items:center;height:14px;min-width:0}
.tape-s{flex:1;min-width:2px;height:6px;border-radius:2px;background:var(--surface-raised);border:1px solid var(--border-default)}
.tape-s:hover{border-color:var(--text-muted)}
.tape-s.is-seen{background:var(--border-strong);border-color:var(--border-strong)}
.tape-s.is-flagged{background:var(--success);border-color:var(--success)}
.tape-s.is-current{height:12px;background:var(--accent);border-color:var(--accent)}
.tape-count{font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted);white-space:nowrap}
.film{display:flex;gap:6px;padding:8px 14px;overflow-x:auto;background:var(--surface-base);
border-top:1px solid var(--border-subtle);scrollbar-width:thin}
.film-f{position:relative;flex:0 0 auto;width:84px;height:62px;border-radius:var(--radius-md);overflow:hidden;
border:1px solid var(--border-default);background:var(--surface-sunken);opacity:.72;
transition:opacity var(--dur-1) var(--ease-out)}
.film-f:hover{opacity:1;text-decoration:none}
.film-f img{width:100%;height:100%;object-fit:cover;display:block}
.film-f.is-blurred img{filter:blur(6px)}
.film-f.is-flagged{border-color:var(--success);box-shadow:inset 0 -3px 0 var(--success);opacity:.9}
.film-f.is-current{opacity:1;border-color:color-mix(in oklab,var(--accent) 60%,transparent)}
.film-kind{display:flex;align-items:center;justify-content:center;height:100%;font-size:20px;color:var(--text-muted)}
.film-ord{position:absolute;left:3px;top:3px;padding:1px 4px;border-radius:var(--radius-sm);
font:600 9.5px/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .85);color:oklch(0.91 0.008 216)}
@media (max-width:900px){
.viewer.review{position:static;display:block;min-height:100vh}
.review-body{grid-template-columns:1fr}
.review .vstage.fit{height:60vh}
.vrail{border-left:0;border-top:1px solid var(--border-subtle)}
.vnext{right:0}
}
/* ---- the standing link board ------------------------------------------ /* ---- the standing link board ------------------------------------------
Rows, not a markdown blob. Dense enough that thirty entries stay Rows, not a markdown blob. Dense enough that thirty entries stay
scannable; provenance recedes so the description leads, and the × only scannable; provenance recedes so the description leads, and the × only
@@ -801,7 +865,7 @@
playing, a decoded image does not collapse to zero height and jolt the playing, a decoded image does not collapse to zero height and jolt the
page), and the per-viewer view state a reload would have reset anyway page), and the per-viewer view state a reload would have reset anyway
but an in-place save must not — a revealed blur, a closed doc. */ but an in-place save must not — a revealed blur, a closed doc. */
function carry(oldEl, newEl) { function carry(oldEl, newEl, sent) {
var olds = [].slice.call(oldEl.querySelectorAll('img[src], video[src], audio[src]')); var olds = [].slice.call(oldEl.querySelectorAll('img[src], video[src], audio[src]'));
newEl.querySelectorAll('img[src], video[src], audio[src]').forEach(function (m) { newEl.querySelectorAll('img[src], video[src], audio[src]').forEach(function (m) {
for (var i = 0; i < olds.length; i++) { for (var i = 0; i < olds.length; i++) {
@@ -815,8 +879,18 @@
['revealed', 'is-closed'].forEach(function (c) { ['revealed', 'is-closed'].forEach(function (c) {
if (oldEl.classList.contains(c)) newEl.classList.add(c); if (oldEl.classList.contains(c)) newEl.classList.add(c);
}); });
/* An unsaved DRAFT survives a swap it was not part of: a note half-typed
on one tile must not vanish because a flag landed on another. The
form that was just sent is the exception — its field is supposed to
come back empty. Matched by name and position within the region. */
var fresh = newEl.querySelectorAll('textarea, input[type=text]');
oldEl.querySelectorAll('textarea, input[type=text]').forEach(function (f, i) {
if (!f.value || (sent && sent.contains(f))) return;
var t = fresh[i];
if (t && t.name === f.name && !t.value) t.value = f.value;
});
} }
function swap(html) { function swap(html, sent) {
var fresh = new DOMParser().parseFromString(html, 'text/html'); var fresh = new DOMParser().parseFromString(html, 'text/html');
document.querySelectorAll('[data-region]').forEach(function (el) { document.querySelectorAll('[data-region]').forEach(function (el) {
var id = el.getAttribute('data-region'); var id = el.getAttribute('data-region');
@@ -827,7 +901,7 @@
}); });
if (next) { if (next) {
var node = document.importNode(next, true); var node = document.importNode(next, true);
carry(el, node); carry(el, node, sent);
el.replaceWith(node); el.replaceWith(node);
} }
}); });
@@ -848,7 +922,7 @@
}).then(function (r) { }).then(function (r) {
if (!r.ok) throw new Error('status ' + r.status); if (!r.ok) throw new Error('status ' + r.status);
return r.text(); return r.text();
}).then(swap).catch(function () { }).then(function (html) { swap(html, form); }).catch(function () {
say('Could not save in place — reloading to show what was saved.'); say('Could not save in place — reloading to show what was saved.');
window.location.reload(); window.location.reload();
}); });
+158 -48
View File
@@ -1,52 +1,136 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %} {% 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 %} {% block content %}
<div class="viewer"> <div class="viewer review">
<div class="vbar"> <div class="vbar">
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)">✕</a> <a class="vbtn vx" href="{{ back_url }}" title="back to the grid (Esc)">✕</a>
<span class="vname">{{ file }}</span> <span class="vname"><span class="ord">{{ num(ordinal) }}</span> {{ file }}</span>
<span class="vspacer"></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"> <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> <button type="button" class="vseg on" id="btn-fit">Fit</button><button type="button" class="vseg" id="btn-one">1:1</button>
</span> </span>
{% endif %}
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a> <a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a>
</div> </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 %} {# THE TAPE (B's device): one segment per item in the review ring — seen,
<div class="vstage fit" id="vstage"><img id="vimg" src="{{ file_url }}" alt="{{ file }}"></div> flagged, current — so how far through the set you are is always in view. #}
{# THE ANNOTATION, at full size. It was never rendered here before U1 — not <div class="tape" data-region="tape" aria-label="{{ seen_n }} of {{ ring_m }} seen">
because the template dropped it, but because the route never resolved it. <div class="tape-segs">
A caption is most useful at the size where you are actually judging the {% for x in film %}
thing, so it belongs here at least as much as in the grid. #} <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 %} {% if caption %}<div class="vcap">{{ caption }}</div>{% endif %}
{# INV-3: the JUDGMENT travels to full size too, not just the caption. This is </div>
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="vr-sec vr-judge">
<div class="vmarks"> <form class="vflag" method="post" action="/b/{{ name_url }}/flag" data-inplace>
<form class="vflag" method="post" action="/b/{{ name_url }}/flag">
<input type="hidden" name="target" value="{{ file }}"> <input type="hidden" name="target" value="{{ file }}">
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}"> <input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
<button class="vbtn{% if flagged %} is-flagged{% endif %}" <input type="hidden" name="back" value="view">
title="{{ 'un-flag this item' if flagged else 'flag this one' }}" <input type="hidden" name="f" value="{{ file }}">
>{{ '✔ flagged' if flagged else '○ flag' }}</button> <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> </form>
{% for m in marks if m.shape == 'note' %} {% for m in marks if m.shape == 'note' %}
<div class="vnote"><pre>{{ m.text }}</pre> <div class="vnote"><pre>{{ m.text }}</pre>
<form method="post" action="/b/{{ name_url }}/unmark"> <form method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ m.id }}"> <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> <button class="mark-x" title="withdraw this note">×</button>
</form> </form>
</div> </div>
{% endfor %} {% endfor %}
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note"> <form class="vaddnote" method="post" action="/b/{{ name_url }}/note" data-inplace>
<input type="hidden" name="target" value="{{ file }}"> <input type="hidden" name="target" value="{{ file }}">
<textarea name="text" rows="2" placeholder="a note on this item"></textarea> <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> <button type="submit">Add note</button>
</form> </form>
</div> </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" 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> </div>
<style> <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; 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); 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; 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)} 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; .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)} color:oklch(0.91 0.008 216)}
.vprev{left:0}.vnext{right:0} .vprev{left:0}.vnext{right:360px}
/* Bottom bar rather than the top chrome: a caption can run to CAPTION_MAX .vcap{margin-top:10px;max-height:30vh;overflow-y:auto;font-size:var(--size-sm);line-height:var(--leading-body);
(800 chars), which would shove the filename and the Fit/1:1 toggle around. */ color:var(--text-body);white-space:pre-wrap}
.vcap{flex:0 0 auto;max-height:22vh;overflow-y:auto;padding:10px clamp(12px,3vw,20px); @media print{.vcap{max-height:none;overflow:visible}.vnav{display:none}}
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}}
</style> </style>
<script> <script>
(function () { (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 }};
/* Fit / 1:1 — bound only when the stage is a picture (R2 C6). */
var img = document.getElementById('vimg'); var img = document.getElementById('vimg');
if (img) {
var stage = document.getElementById('vstage'); var stage = document.getElementById('vstage');
var toggle = document.getElementById('vtoggle'); var toggle = document.getElementById('vtoggle');
var bFit = document.getElementById('btn-fit'); var bFit = document.getElementById('btn-fit');
var bOne = document.getElementById('btn-one'); var bOne = document.getElementById('btn-one');
var BACK = {{ ('/b/' ~ name_url ~ '/')|tojson }}; var setMode = function (mode) {
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'; var fit = mode === 'fit';
stage.classList.toggle('fit', fit); stage.classList.toggle('fit', fit);
stage.classList.toggle('one', !fit); stage.classList.toggle('one', !fit);
bFit.classList.toggle('on', fit); bFit.classList.toggle('on', fit);
bOne.classList.toggle('on', !fit); bOne.classList.toggle('on', !fit);
} };
// "fits" == the image at natural size already sits inside the stage, so Fit /* "fits" == the image at natural size already sits inside the stage, so
// and 1:1 would render identically — in that case we hide the toggle entirely. Fit and 1:1 would render identically — then the toggle is hidden. */
function fits() { var evaluate = function () {
return img.naturalWidth <= stage.clientWidth && img.naturalHeight <= stage.clientHeight;
}
function evaluate() {
if (!img.naturalWidth) return; if (!img.naturalWidth) return;
if (fits()) { if (img.naturalWidth <= stage.clientWidth && img.naturalHeight <= stage.clientHeight) {
toggle.style.display = 'none'; toggle.style.display = 'none';
setMode('fit'); setMode('fit');
} else { } else {
toggle.style.display = 'inline-flex'; toggle.style.display = 'inline-flex';
if (!stage.classList.contains('one')) setMode('fit'); if (!stage.classList.contains('one')) setMode('fit');
} }
} };
bFit.addEventListener('click', function () { setMode('fit'); }); bFit.addEventListener('click', function () { setMode('fit'); });
bOne.addEventListener('click', function () { setMode('one'); }); bOne.addEventListener('click', function () { setMode('one'); });
img.addEventListener('load', evaluate); img.addEventListener('load', evaluate);
window.addEventListener('resize', evaluate); window.addEventListener('resize', evaluate);
if (img.complete) evaluate(); if (img.complete) evaluate();
}
/* An arrow key inside the note field is a CARET move, not a navigation. /* Keep the current frame in view on the filmstrip — on load, and after an
The handler is on `document` and the note textarea shipped into this same in-place save swaps the strip for a fresh one. Additive: without it the
page, so typing a note and reaching for ← threw the draft away; Escape strip is still a row of links. */
did it in one keystroke. Anything editable keeps its own keys. */ 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) { function isEditable(el) {
return !!(el && (el.isContentEditable || return !!(el && (el.isContentEditable ||
/^(input|textarea|select)$/i.test(el.tagName || ''))); /^(input|textarea|select)$/i.test(el.tagName || '')));
} }
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (isEditable(e.target)) return; 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; if (e.key === 'Escape') window.location.href = BACK;
else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV; else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV;
else if (e.key === 'ArrowRight' && NEXT) window.location.href = NEXT; 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> </script>
+82
View File
@@ -476,3 +476,85 @@ def test_every_mark_dependent_element_is_a_swappable_region(tmp_path):
_booth(tmp_path, "links", {"links.md": b"- [x](http://x/) <sub>\xc2\xb7 a \xc2\xb7 2026-09-01 10:00</sub>\n"}) _booth(tmp_path, "links", {"links.md": b"- [x](http://x/) <sub>\xc2\xb7 a \xc2\xb7 2026-09-01 10:00</sub>\n"})
board = _client(tmp_path).get("/b/links/").text board = _client(tmp_path).get("/b/links/").text
assert 'data-region="verdict"' not in board and 'class="lightbox"' not in board assert 'data-region="verdict"' not in board and 'class="lightbox"' not in board
# ---- C6: the review -----------------------------------------------------------
def test_an_audio_item_is_reviewed_like_a_picture(tmp_path):
"""The tracer for C6. A track gets the review page — its native player on
the stage, no Fit/1:1 toggle (that is for images only), and the judgment
rail with a flag that lands back here."""
_booth(tmp_path, "g", {"a.png": PNG, "b.mp3": b"ID3", "c.mp3": b"ID3"})
r = _client(tmp_path).get("/b/g/view?f=b.mp3", follow_redirects=False)
assert r.status_code == 200
body = r.text
stage = body[body.index('id="vstage"'):]
assert len(re.findall(r"<audio\b[^>]*\bcontrols\b", stage.split("</div>")[0])) == 1
assert 'id="vtoggle"' not in body and ">1:1<" not in body
rail = _region(body, "rail")
assert 'name="back" value="view"' in rail and 'name="f" value="b.mp3"' in rail
assert "2 of 3" in rail and "#2" in rail
def test_filmstrip_and_tape_are_the_review_ring_with_seen_and_flags(tmp_path):
"""One list, three surfaces: the filmstrip and the tape are the ring in
set order. The tape counts seen ∩ ring — including the item being looked
at, which is recorded before the page renders; a doc is never in it."""
b = _booth(tmp_path, "g", {"a.png": PNG, "b.md": b"# b", "c.png": PNG, "d.mp3": b"ID3"})
set_flag(b, "d.mp3", True)
c = _client(tmp_path)
c.get("/b/g/view?f=a.png")
body = c.get("/b/g/view?f=c.png").text
film = re.findall(r'<a class="film-f([^"]*)"\s+href="\?f=([^"]+)"', _region(body, "film"))
assert [(rel, cls.split()) for cls, rel in film] == [
("a.png", []), ("c.png", ["is-current"]), ("d.mp3", ["is-flagged"])]
assert re.findall(r'class="film-ord">#(\d)<', body) == ["1", "3", "4"] # whole-set numbers
tape = _region(body, "tape")
assert re.findall(r'<a class="tape-s([^"]*)"', tape) == [" is-seen", " is-current", " is-flagged"]
assert "2 of 3 seen" in tape
def test_a_question_about_this_item_is_answerable_here_and_the_rest_wait_for_the_end(tmp_path):
"""The rail offers a pick TARGETING the item; booth-level picks are a count
and a link — until the last item, where the end of the set offers them
all, each landing back on the review."""
from booth.marks import declare_pick
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
declare_pick(b, "about-a", {"prompt": "Is a sharp?", "options": ["yes", "no"]}, target="a.png")
declare_pick(b, "overall", {"prompt": "Ship the set?", "options": ["yes", "no"]})
c = _client(tmp_path)
first = _region(c.get("/b/g/view?f=a.png").text, "rail")
assert "Is a sharp?" in first and "Ship the set?" not in first
assert "1 more open question on this booth" in first
assert first.count('name="back" value="view"') >= 2 # flag + the pick
last = _region(c.get("/b/g/view?f=b.png").text, "rail")
assert "End of the set" in last
assert "Ship the set?" in last and "Is a sharp?" in last
form = re.search(r'<form class="mark-form"[^>]*>.*?Ship the set\?|Ship the set\?.*?</form>', last, re.S)
assert form and 'name="f" value="b.png"' in last
def test_only_a_picture_gets_the_fit_toggle_and_blur_stays_honest(tmp_path):
from booth.app import set_blurred
b = _booth(tmp_path, "g", {"a.png": PNG, "v.webm": b"\x1aE"})
set_blurred(b, "a.png", True)
c = _client(tmp_path)
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 'class="vstage fit is-blurred"' in pic and "blur is cosmetic" in pic
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)
# ---- C7: copy and brand -------------------------------------------------------
def test_no_emblem_in_the_chrome(tmp_path):
"""Ruling `emblem=no`: the top bar carries the brand dot and the name, and
no image at all."""
_booth(tmp_path, "g", {"a.png": PNG})
c = _client(tmp_path)
for path in ("/", "/b/g/", "/b/g/view?f=a.png"):
body = c.get(path).text
m = re.search(r'<header class="topbar">.*?</header>', body, re.S)
if m:
assert "<img" not in m.group(0) and "<svg" not in m.group(0), path
+36
View File
@@ -112,3 +112,39 @@ def test_a_failed_save_says_so_reloads_and_never_re_posts(browser, live):
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
page.close() page.close()
assert len(posts) == 1, f"re-POSTed: {posts}" assert len(posts) == 1, f"re-POSTed: {posts}"
def test_the_review_keys_judge_in_place_and_stay_out_of_the_note(browser, live):
"""At full size: F typed into the note is a letter; F outside it flags IN
PLACE (the filmstrip underline and the tape catch up, no reload); Space
moves on; Esc goes back to the grid at the tile you were on."""
base, root = live
_set(root, 4)
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/view?f=02.png", wait_until="networkidle")
page.evaluate("window.__noReload = 1")
page.locator("#vnote-text").click()
page.keyboard.type("fff")
assert page.locator(".vflag-btn.is-flagged").count() == 0
assert page.locator("#vnote-text").input_value() == "fff"
page.locator(".vr-where").click() # focus back on the page, not a field
page.keyboard.press("f")
page.wait_for_selector(".vflag-btn.is-flagged", timeout=10000)
state = page.evaluate("""() => ({
reload: window.__noReload !== 1,
film: [...document.querySelectorAll('.film-f.is-flagged .film-ord')].map(e => e.textContent),
draft: document.getElementById('vnote-text').value,
})""")
assert not state["reload"]
assert state["film"] == ["#2"]
assert state["draft"] == "fff", "an unsaved note must survive a swap it was not part of"
with page.expect_navigation():
page.keyboard.press(" ")
assert page.url.endswith("/b/g/view?f=03.png")
with page.expect_navigation():
page.keyboard.press("Escape")
assert page.url.endswith("/b/g/#item-03.png")
page.close()