diff --git a/booth/app.py b/booth/app.py index 1852388..0b8d79b 100644 --- a/booth/app.py +++ b/booth/app.py @@ -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: diff --git a/booth/templates/_marks.html b/booth/templates/_marks.html index de38719..4792bf8 100644 --- a/booth/templates/_marks.html +++ b/booth/templates/_marks.html @@ -26,6 +26,11 @@ {% set flags = marks | selectattr('shape', 'equalto', 'flag') | rejectattr('error') | list %}
+{# `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 %}
@@ -42,6 +47,7 @@
{% endfor %} +{% endif %} {% for a in picks %}
@@ -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 %}{% endif %} + {% if 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 @@
{% 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 @@ +{% endif %}
diff --git a/booth/templates/base.html b/booth/templates/base.html index 93e9fac..728a0b6 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -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(); }); diff --git a/booth/templates/view.html b/booth/templates/view.html index 2b174ba..5fe6aca 100644 --- a/booth/templates/view.html +++ b/booth/templates/view.html @@ -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 %} -
+
- ✕ - {{ file }} + ✕ + {{ num(ordinal) }} {{ file }} + {% 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. #} + {% endif %} ⬇
- {% if prev_url %}‹{% endif %} - {% if next_url %}›{% endif %} -
{{ file }}
- {# 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 %}
{{ caption }}
{% 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. #} -
-
- - - -
- {% for m in marks if m.shape == 'note' %} -
{{ m.text }}
-
- - + + {# 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. #} +
+
+ {% for x in film %} + + {% endfor %} +
+ {{ seen_n }} of {{ ring_m }} seen +
+ +
+ {% if prev_url %}‹{% endif %} +
+ {% if kind == 'image' %}{{ file }} + {% elif kind == 'video' %} + {% else %} + {% endif %} + {% if blurred %}{% endif %} +
+ {% if next_url %}›{% endif %} + +
+ + {# THE FILMSTRIP: the review ring in set order, numbered like the tiles, + flagged frames underlined, the current one in the reticle. #} +
diff --git a/tests/test_flow.py b/tests/test_flow.py index 2c2606d..e0b5c07 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -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/) \xc2\xb7 a \xc2\xb7 2026-09-01 10:00\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"]*\bcontrols\b", stage.split("
")[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'#(\d)<', body) == ["1", "3", "4"] # whole-set numbers + tape = _region(body, "tape") + assert re.findall(r'= 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'
]*>.*?Ship the set\?|Ship the set\?.*?
', 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"]*\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'
.*?
', body, re.S) + if m: + assert " ({ + 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()