The markup and CSS half of the anti-slop interaction work. The in-place
client is untouched (that is S5b).
- Glyph-only controls carry a name: the withdraw ×s, downloads, open full
page, the viewers' ✕, the board's pin, copy and remove, the bench's
remove, the 1:1 toggle ("1:1, natural pixels"). Film-strip and tray
frames carry the file's name as sr-only text instead of reading "01".
A Desk row's wipe names its booth.
- Fields are named by aria-label, not by their placeholder.
- The inline ask's options are a radiogroup labelled by the prompt; a
single-question fieldset gets an sr-only legend; a titled ask's title
takes bk-ask-<id>-title (it duplicated the question's id).
- One h1 per page (sr-only on the Desk, review and compare), a skip link
to <main id="main">, theme-color for light and dark.
- The review tape is one picture (role=img); its segments leave the tab
order (the film strip holds the same links, named).
- Wipe now uses the Desk's delegated prompt, moved to base.html: it names
the booth and asks the kept-booth question for a kept booth.
- Embed focus rings of its own; rings drawn inside clipping containers;
the withdraw × at least 24px, 44px under a coarse pointer;
touch-action:manipulation; strips contain their overscroll; a long
slug wraps on a phone.
- A truncated why carries its full text in title; a countdown of 48h or
more reads in days.
Two r2_flow.toml rows for the confirm helper now name base.html, where
the helper moved (anchors unchanged; the gate found them drifted).
Contract: as_antislop S5a. Falsifiers: antislop.toml 86/86 proved (S1-S6, S5a);
all 12 tables 366/366 proved on this tree.
303 lines
17 KiB
HTML
303 lines
17 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
|
||
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
|
||
{% block body_attrs %} class="page-stage"{% 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 %}
|
||
<h1 class="sr-only">{{ file }}</h1>
|
||
<div class="viewer review">
|
||
<div class="vbar">
|
||
<a class="vbtn vx" href="{{ back_url }}" title="back to the grid (Esc)" aria-label="back to the grid">✕</a>
|
||
<span class="vname"><span class="ord">{{ num(ordinal) }}</span> {{ file }}</span>
|
||
<span class="vspacer"></span>
|
||
{# R3: this item against the next one in the ring, side by side (C). #}
|
||
{% if compare_url %}<a class="vbtn vcompare" href="{{ compare_url }}" title="compare with the next item (C)"><span aria-hidden="true">⇆</span><span class="vcompare-l"> compare</span></a>{% endif %}
|
||
{% if kind == 'image' %}
|
||
{# A JS-only VIEWING convenience (INV-3): hidden until the script shows it,
|
||
and only ever rendered for a picture. With scripts off the image shows at
|
||
fit size and no judgment depends on this. #}
|
||
<span class="vtoggle" id="vtoggle" hidden>
|
||
<button type="button" class="vseg on" id="btn-fit" aria-pressed="true" title="the whole picture, as large as the stage allows">Fit</button><button type="button" class="vseg" id="btn-one" aria-pressed="false" aria-label="1:1, natural pixels" title="natural pixels — drag to pan a large picture">1:1</button>
|
||
</span>
|
||
{% endif %}
|
||
{# r2b D2b + D2, in the top bar: outside every data-region, so no swap
|
||
replaces them. The fog form carries `back` and lands on this item. #}
|
||
<span class="region-wrap" data-region="blur-booth"><form class="blur-all{% if booth_blurred %} is-on{% endif %}" method="post" action="/b/{{ name_url }}/blurbooth">
|
||
<input type="hidden" name="on" value="{{ '0' if booth_blurred else '1' }}">
|
||
<input type="hidden" name="back" value="{{ file }}">
|
||
<button title="{{ 'un-blur the whole booth' if booth_blurred else 'blur every image and video in this booth — cosmetic only' }}">{{ '◉ booth blurred' if booth_blurred else '◌ blur booth' }}</button>
|
||
</form></span>
|
||
{% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
|
||
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}" aria-label="download {{ file }}">⬇</a>
|
||
</div>
|
||
|
||
{# THE TAPE (B's device): one segment per item in the review ring — seen,
|
||
flagged, current — so how far through the set you are is always in view. #}
|
||
<div class="tape" data-region="tape" role="img" 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 }}" tabindex="-1" aria-hidden="true"></a>
|
||
{% endfor %}
|
||
</div>
|
||
<span class="tape-count">{{ seen_n }} of {{ ring_m }} seen</span>
|
||
</div>
|
||
|
||
<div class="review-body">
|
||
{% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous">‹</a>{% endif %}
|
||
<div class="vstage{% if kind == 'image' %} is-img{% endif %}{% if blurred %} is-blurred{% endif %}" id="vstage">
|
||
{% if kind == 'image' %}<img id="vimg" src="{{ file_url }}" alt="{{ file }}" draggable="false">
|
||
{% elif kind == 'video' %}<video id="vmedia" controls preload="metadata" src="{{ file_url }}"></video>
|
||
{% else %}<audio id="vmedia" controls preload="metadata" src="{{ file_url }}"></audio>
|
||
{% endif %}
|
||
</div>
|
||
{# The stage's reveal sits OVER the stage, outside its scrolled content
|
||
(r2c): in 1:1 a panned picture would otherwise carry it out of view, and
|
||
outside the stage it can never start a pan. #}
|
||
{# JS-only, so `hidden` until the script binds it (heid bug-hunt: shown with
|
||
scripts off, it did nothing) — the toggle's own pattern. #}
|
||
{% if blurred %}<button type="button" class="reveal" id="vreveal" aria-label="reveal {{ file }}" hidden>👁 reveal — blur is cosmetic</button>{% endif %}
|
||
{% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next">›</a>{% endif %}
|
||
|
||
<aside class="vrail" id="rail" data-region="rail" aria-label="your judgment">
|
||
<div class="vr-sec">
|
||
<div class="vr-where"><span class="ord">{{ num(ordinal) }}</span> · {{ ring_k }} of {{ ring_m }}
|
||
{%- if group %} · {{ group.k }} of {{ group.n }} in {{ group.key }}{% endif %}</div>
|
||
{# THE ANNOTATION, at full size — the size where it is most readable. #}
|
||
{% if caption %}<div class="vcap">{{ caption }}</div>{% endif %}
|
||
</div>
|
||
|
||
<div class="vr-sec vr-judge">
|
||
<form class="vflag" method="post" action="/b/{{ name_url }}/flag" data-inplace>
|
||
<input type="hidden" name="target" value="{{ file }}">
|
||
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
|
||
<input type="hidden" name="back" value="view">
|
||
<input type="hidden" name="f" value="{{ file }}">
|
||
<button class="vbtn vflag-btn{% if flagged %} is-flagged{% endif %}" id="vflag-btn"
|
||
title="{{ 'un-flag this item' if flagged else 'flag this one' }} (F)"
|
||
>{{ '✔ flagged' if flagged else '○ flag' }} <kbd>F</kbd></button>
|
||
</form>
|
||
{% for m in marks if m.shape == 'note' %}
|
||
<div class="vnote"><pre>{{ m.text }}</pre>
|
||
<form method="post" action="/b/{{ name_url }}/unmark" data-inplace>
|
||
<input type="hidden" name="mark" value="{{ m.id }}">
|
||
<input type="hidden" name="back" value="view">
|
||
<input type="hidden" name="f" value="{{ file }}">
|
||
<button class="mark-x" title="withdraw this note" aria-label="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" aria-label="a note on this item" placeholder="a note on this item (N)"></textarea>
|
||
<button type="submit">Add note</button>
|
||
</form>
|
||
</div>
|
||
|
||
{# A question ABOUT this item is answerable here. #}
|
||
{% if item_picks %}
|
||
<div class="vr-sec">
|
||
{% with marks=item_picks, picks_only=true, back_view=file, marks_page=false %}{% include "_marks.html" %}{% endwith %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if is_last %}
|
||
{# THE END OF THE SET — not a separate page: on the last item the rail
|
||
adds the summary and every question still open on the booth. #}
|
||
<div class="vr-sec vr-end">
|
||
<p class="vr-end-head">End of the set · {{ seen_n }} of {{ ring_m }} seen · {{ tray|length }} flagged</p>
|
||
{% if tray %}
|
||
<div class="tray">
|
||
{% for x in tray %}
|
||
<a class="tray-item{% if x.blurred %} is-blurred{% endif %}" href="?f={{ x.url }}" title="{{ x.name }}">
|
||
<span class="sr-only">{{ x.name }}</span>{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="tray-kind">{{ x.kind }}</span>{% endif -%}
|
||
<span class="tray-ord">{{ num(x.ordinal) }}</span></a>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
{% if other_picks %}
|
||
{% with marks=other_picks, picks_only=true, back_view=file, marks_page=false %}{% include "_marks.html" %}{% endwith %}
|
||
{% endif %}
|
||
</div>
|
||
{% elif other_picks %}
|
||
<div class="vr-sec vr-more">
|
||
<a href="/b/{{ name_url }}/">{{ other_picks|length }} more open question{{ '' if other_picks|length == 1 else 's' }} on this booth →</a>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="vr-keys"><kbd>←</kbd> <kbd>→</kbd> <kbd>Space</kbd> move · <kbd>F</kbd> flag · <kbd>N</kbd> note · <kbd>C</kbd> compare · <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 %}>
|
||
<span class="sr-only">{{ x.name }}</span>{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="film-kind">{{ '♪' if x.kind == 'audio' else '▶' }}</span>{% endif -%}
|
||
<span class="film-ord">{{ num(x.ordinal) }}</span></a>
|
||
{% endfor %}
|
||
</nav>
|
||
</div>
|
||
<style>
|
||
.vnav{position:absolute;top:50%;transform:translateY(-50%);z-index:4;display:flex;
|
||
align-items:center;justify-content:center;width:40px;height:56px;font-size:28px;
|
||
line-height:1;text-decoration:none;color:oklch(0.91 0.008 216);background:oklch(0.17 0.01 250 / .82);
|
||
border:1px solid rgb(255 255 255 / .12);border-radius:var(--radius-lg);margin:0 8px;user-select:none;
|
||
-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);
|
||
transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)}
|
||
.vnav:hover{background:oklch(0.21 0.01 248 / .92);border-color:rgb(255 255 255 / .3);text-decoration:none;
|
||
color:oklch(0.91 0.008 216)}
|
||
/* The next arrow clears the 360px verdict rail only while the rail sits
|
||
beside the stage. Scoped to the wide layout: stated bare, this rule came
|
||
later in the page than base.html's narrow override and silently won it,
|
||
parking the arrow 360px in from the edge of a phone. */
|
||
.vprev{left:0}.vnext{right:0}
|
||
@media (min-width:901px){.vnext{right:360px}}
|
||
/* Stacked (<=900px) the stage is the body's first 60vh, so its centre is 30vh
|
||
down: the arrows' spot before the script places them (JS off, loading,
|
||
failed), never over the rail below (heid code-review). HERE, after .vnav:
|
||
in base.html this page's own later rule silently won it (the Nyx trap). */
|
||
@media (max-width:900px){.vnav{top:30vh}}
|
||
.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>
|
||
{% include "_stage_js.html" %}
|
||
<script>
|
||
(function () {
|
||
var BACK = {{ back_url|tojson }};
|
||
var PREV = {{ (('?f=' ~ prev_url) if prev_url else '')|tojson }};
|
||
var NEXT = {{ (('?f=' ~ next_url) if next_url else '')|tojson }};
|
||
var COMPARE = {{ compare_url|tojson }};
|
||
|
||
/* THE STAGE (r2c). The mode is ONE class on <html>, `stage-one` (absent =
|
||
Fit), set by the head script before the stage existed. The shared
|
||
machinery binds the toggle and pans a 1:1 picture; this page places the
|
||
arrows at the drawn picture. */
|
||
var stage = document.getElementById('vstage');
|
||
var img = document.getElementById('vimg');
|
||
var video = stage.querySelector('video');
|
||
var rbody = stage.parentElement; /* .review-body */
|
||
var prevA = rbody.querySelector('.vnav.vprev'), nextA = rbody.querySelector('.vnav.vnext');
|
||
|
||
/* The DRAWN picture's left and right edges, in viewport px, or null until
|
||
they are known (still loading, or failed): the arrows then keep their CSS
|
||
spot. The object-fit: contain box — which in 1:1 (scale 1) IS the
|
||
picture's own box; where it runs past the stage, the clamp in place()
|
||
keeps the arrows inside. */
|
||
function drawn() {
|
||
if (img) {
|
||
if (!img.naturalWidth) return null;
|
||
var b = img.getBoundingClientRect();
|
||
var k = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight), w = img.naturalWidth * k;
|
||
return {l: b.left + (b.width - w) / 2, r: b.left + (b.width + w) / 2};
|
||
}
|
||
if (video && video.videoWidth) {
|
||
var v = video.getBoundingClientRect();
|
||
return {l: v.left, r: v.right};
|
||
}
|
||
return null;
|
||
}
|
||
/* Each arrow wholly outside the drawn edge, its near edge 8px away, clamped
|
||
8px inside the stage — so over the picture only when the picture spans
|
||
the stage (operator: "unless the image spans the entire width"). */
|
||
function place() {
|
||
var p = drawn();
|
||
if (!p) {
|
||
/* unknown (loading, failed): back to the CSS spot, never a stale one */
|
||
[prevA, nextA].forEach(function (a) {
|
||
if (a) { a.classList.remove('is-placed'); a.style.left = ''; a.style.top = ''; }
|
||
});
|
||
return;
|
||
}
|
||
var s = stage.getBoundingClientRect(), o = rbody.getBoundingClientRect();
|
||
/* the stage's CLIENT box: a classic scrollbar is not stage an arrow may
|
||
sit on (heid bug-hunt, groa — the border box put the next arrow under it) */
|
||
var cl = s.left + stage.clientLeft, cr = cl + stage.clientWidth;
|
||
[[prevA, -1], [nextA, 1]].forEach(function (pair) {
|
||
var a = pair[0];
|
||
if (!a) return;
|
||
var w = a.offsetWidth, lo = cl + 8, hi = cr - 8 - w;
|
||
var x = pair[1] < 0 ? p.l - 8 - w : p.r + 8;
|
||
x = Math.max(lo, Math.min(hi, x));
|
||
a.classList.add('is-placed');
|
||
a.style.left = (x - o.left) + 'px';
|
||
a.style.top = (s.top - o.top + s.height / 2) + 'px';
|
||
});
|
||
}
|
||
/* The stage's own machinery — Fit/1:1, pannable, drag-to-pan — is the
|
||
shared include (_stage_js.html, r3 C4); the arrows stay this page's. */
|
||
var st = BoothStage.attach(stage, {img: img, onSettle: place});
|
||
var settle = st.settle;
|
||
if (img) BoothMode.bind({
|
||
toggle: document.getElementById('vtoggle'),
|
||
fit: document.getElementById('btn-fit'),
|
||
one: document.getElementById('btn-one'),
|
||
onChange: settle
|
||
});
|
||
if (video) video.addEventListener('loadedmetadata', settle);
|
||
if (window.ResizeObserver) new ResizeObserver(settle).observe(stage);
|
||
else window.addEventListener('resize', settle);
|
||
|
||
/* 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.hidden = false;
|
||
if (rv) rv.addEventListener('click', function () {
|
||
var on = document.getElementById('vstage').classList.toggle('revealed');
|
||
rv.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic';
|
||
});
|
||
|
||
/* EVERY key here, new and old, is ignored while focus is in something
|
||
editable: an arrow key in the note field is a caret move, and F typed
|
||
into a note is a letter, not a flag. */
|
||
function isEditable(el) {
|
||
return !!(el && (el.isContentEditable ||
|
||
/^(input|textarea|select)$/i.test(el.tagName || '')));
|
||
}
|
||
document.addEventListener('keydown', function (e) {
|
||
if (isEditable(e.target)) return;
|
||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||
/* Space moves only when the stage is not a player that wants it. */
|
||
var player = document.getElementById('vmedia');
|
||
if (e.key === 'Escape') window.location.href = BACK;
|
||
else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV;
|
||
else if (e.key === 'ArrowRight' && NEXT) window.location.href = NEXT;
|
||
/* ...and never from a focused control: Space is how a keyboard presses a
|
||
button or follows a link (r2b, heid bug-hunt — Reveal all and the fog
|
||
control could not be pressed). */
|
||
else if (e.key === ' ' && NEXT && e.target !== player && !(e.target.closest && e.target.closest('button, a, summary'))) { e.preventDefault(); window.location.href = e.shiftKey && PREV ? PREV : NEXT; }
|
||
else if (e.key === 'f' || e.key === 'F') {
|
||
var b = document.getElementById('vflag-btn'); /* re-read: the rail may have been swapped */
|
||
if (b) { e.preventDefault(); b.click(); }
|
||
}
|
||
else if ((e.key === 'c' || e.key === 'C') && COMPARE) { e.preventDefault(); window.location.href = COMPARE; }
|
||
else if (e.key === 'n' || e.key === 'N') {
|
||
var t = document.getElementById('vnote-text');
|
||
if (t) { e.preventDefault(); t.focus(); }
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
{% endblock %}
|