The operator ruled blur A, and made it urgent: "per booth blurring is now
important since we are showing up to 4 images."
- Reveal all: one control per booth, in the booth header and the review's
top bar, outside every data-region. It is in the markup only when
something is blurred, always `hidden` until the script shows it.
- The state is sessionStorage per booth, per tab, and nothing reaches
the server. It is carried as one `reveal-all` class on <html>, applied
before first paint from the page's own data-booth, so booth A's reveal
cannot follow you into booth B and the index is never revealed.
- Per-item reveal buttons stand down by stylesheet, and an item's own
reveal is never touched, so "blur again" restores each item as it was.
- A storage write that throws still applies the click.
- The booth blur control: a plain form to booth-dev's POST /blurbooth, so
it works with scripts off. Its label follows is_booth_blurred; from the
review it carries `back` and lands on the same item. A fogged booth's
Desk row says "◉ blurred".
- Found by rendering it: under a fogged booth every item reported
`blurred`, so an item blurred only by the booth offered an un-blur that
visibly did nothing. The gallery now carries `blurred_self`, and such an
item shows "◉ booth", a label rather than a control.
Contract docs/contracts/r2b_desk_reveal_theme.contract.md (heid contract
panel 4/4, folded). tests/mutations/r2b.toml: 14/14 proved. 765 passed.
246 lines
13 KiB
HTML
246 lines
13 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
|
||
{% block html_attrs %} data-booth="{{ name }}"{% 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 review">
|
||
<div class="vbar">
|
||
<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 %}
|
||
{# 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. #}
|
||
<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>
|
||
{% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden>👁 reveal all — blur is cosmetic</button>{% endif %}
|
||
<a class="vbtn" href="{{ file_url }}" download title="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" 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>
|
||
|
||
{# 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" 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>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" 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 / .6);
|
||
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}}
|
||
.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 BACK = {{ back_url|tojson }};
|
||
var PREV = {{ (('?f=' ~ prev_url) if prev_url else '')|tojson }};
|
||
var NEXT = {{ (('?f=' ~ next_url) if next_url else '')|tojson }};
|
||
|
||
/* Fit / 1:1 — bound only when the stage is a picture (R2 C6). */
|
||
var img = document.getElementById('vimg');
|
||
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();
|
||
}
|
||
|
||
/* 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>
|
||
{% endblock %}
|