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:
+48
-11
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
+188
-78
@@ -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 %}"
|
||||||
{% if caption %}<div class="vcap">{{ caption }}</div>{% endif %}
|
href="?f={{ x.url }}" title="{{ num(x.ordinal) }} {{ x.name }}"></a>
|
||||||
{# INV-3: the JUDGMENT travels to full size too, not just the caption. This is
|
{% endfor %}
|
||||||
the size at which the operator is actually deciding, so the flag toggle and
|
</div>
|
||||||
the notes belong here at least as much as on the tile. #}
|
<span class="tape-count">{{ seen_n }} of {{ ring_m }} seen</span>
|
||||||
<div class="vmarks">
|
</div>
|
||||||
<form class="vflag" method="post" action="/b/{{ name_url }}/flag">
|
|
||||||
<input type="hidden" name="target" value="{{ file }}">
|
<div class="review-body">
|
||||||
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
|
{% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous">‹</a>{% endif %}
|
||||||
<button class="vbtn{% if flagged %} is-flagged{% endif %}"
|
<div class="vstage fit{% if blurred %} is-blurred{% endif %}" id="vstage">
|
||||||
title="{{ 'un-flag this item' if flagged else 'flag this one' }}"
|
{% if kind == 'image' %}<img id="vimg" src="{{ file_url }}" alt="{{ file }}">
|
||||||
>{{ '✔ flagged' if flagged else '○ flag' }}</button>
|
{% elif kind == 'video' %}<video id="vmedia" controls preload="metadata" src="{{ file_url }}"></video>
|
||||||
</form>
|
{% else %}<audio id="vmedia" controls preload="metadata" src="{{ file_url }}"></audio>
|
||||||
{% for m in marks if m.shape == 'note' %}
|
{% endif %}
|
||||||
<div class="vnote"><pre>{{ m.text }}</pre>
|
{% if blurred %}<button type="button" class="reveal" id="vreveal" aria-label="reveal {{ file }}">👁 reveal — blur is cosmetic</button>{% endif %}
|
||||||
<form method="post" action="/b/{{ name_url }}/unmark">
|
</div>
|
||||||
<input type="hidden" name="mark" value="{{ m.id }}">
|
{% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next">›</a>{% endif %}
|
||||||
<button class="mark-x" title="withdraw this note">×</button>
|
|
||||||
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note">
|
{# A question ABOUT this item is answerable here. #}
|
||||||
<input type="hidden" name="target" value="{{ file }}">
|
{% if item_picks %}
|
||||||
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
|
<div class="vr-sec">
|
||||||
<button type="submit">Add note</button>
|
{% with marks=item_picks, picks_only=true, back_view=file, marks_page=false %}{% include "_marks.html" %}{% endwith %}
|
||||||
</form>
|
</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>
|
</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 img = document.getElementById('vimg');
|
var BACK = {{ back_url|tojson }};
|
||||||
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 PREV = {{ (('?f=' ~ prev_url) if prev_url else '')|tojson }};
|
var PREV = {{ (('?f=' ~ prev_url) if prev_url else '')|tojson }};
|
||||||
var NEXT = {{ (('?f=' ~ next_url) if next_url else '')|tojson }};
|
var NEXT = {{ (('?f=' ~ next_url) if next_url else '')|tojson }};
|
||||||
|
|
||||||
function setMode(mode) {
|
/* Fit / 1:1 — bound only when the stage is a picture (R2 C6). */
|
||||||
var fit = mode === 'fit';
|
var img = document.getElementById('vimg');
|
||||||
stage.classList.toggle('fit', fit);
|
if (img) {
|
||||||
stage.classList.toggle('one', !fit);
|
var stage = document.getElementById('vstage');
|
||||||
bFit.classList.toggle('on', fit);
|
var toggle = document.getElementById('vtoggle');
|
||||||
bOne.classList.toggle('on', !fit);
|
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.
|
/* 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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user