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),
}
if item is not None and item.kind == "image":
# prev/next ring (wraps; only when there is more than one item in
# it). R2 C2: the ring is `review_chain` — the item order filtered to
# MEDIA — so a set that mixes pictures and sound steps through both.
names = review_chain(items)
if item is not None and item.kind in REVIEW_KINDS:
# THE REVIEW (R2 C6): images, video and audio at full size with the
# judgment on screen. The ring is `review_chain` — the item order
# filtered to MEDIA — and the prev/next, the filmstrip and the tape
# 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
if f in names and len(names) > 1:
i = names.index(f)
prev_url = quote(names[(i - 1) % len(names)], safe="/")
next_url = quote(names[(i + 1) % len(names)], safe="/")
if len(ring) > 1:
prev_url = quote(ring[(pos - 1) % len(ring)], safe="/")
next_url = quote(ring[(pos + 1) % len(ring)], 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(
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
if item is not None:
+9
View File
@@ -26,6 +26,11 @@
{% set flags = marks | selectattr('shape', 'equalto', 'flag') | rejectattr('error') | list %}
<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 %}
<article class="mark mark-note is-broken" id="mark-{{ a.id }}">
<header class="mark-head">
@@ -42,6 +47,7 @@
</article>
{% endfor %}
{% endif %}
{% 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 }}">
<header class="mark-head">
@@ -83,6 +89,7 @@
{# On the standalone page, come back HERE — the booth's own page is a
verbatim report that cannot show the recorded judgment. #}
{% 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 %}
{% 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 %}
@@ -117,6 +124,7 @@
</article>
{% endfor %}
{% if not picks_only %}
{# 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
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>
<button type="submit">Add note</button>
</form>
{% endif %}
</section>
+79 -5
View File
@@ -547,7 +547,7 @@
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
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;
background:
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);
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}
.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)}
/* ⚠ 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}
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 ------------------------------------------
Rows, not a markdown blob. Dense enough that thirty entries stay
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
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. */
function carry(oldEl, newEl) {
function carry(oldEl, newEl, sent) {
var olds = [].slice.call(oldEl.querySelectorAll('img[src], video[src], audio[src]'));
newEl.querySelectorAll('img[src], video[src], audio[src]').forEach(function (m) {
for (var i = 0; i < olds.length; i++) {
@@ -815,8 +879,18 @@
['revealed', 'is-closed'].forEach(function (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');
document.querySelectorAll('[data-region]').forEach(function (el) {
var id = el.getAttribute('data-region');
@@ -827,7 +901,7 @@
});
if (next) {
var node = document.importNode(next, true);
carry(el, node);
carry(el, node, sent);
el.replaceWith(node);
}
});
@@ -848,7 +922,7 @@
}).then(function (r) {
if (!r.ok) throw new Error('status ' + r.status);
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.');
window.location.reload();
});
+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>
+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"})
board = _client(tmp_path).get("/b/links/").text
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.close()
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()