feat(r2): C5 the lightbox, and the in-place client

- On a gallery booth the marks panel moves into a sticky verdict aside
  beside the set. The aside comes first in the document, so a narrow screen
  stacks the question above the work; grid areas place it on the right when
  wide. Nothing in an ordered collection moves. Boards are unchanged.
- The flag tray lists flagged items by tile number: the declared change
  from the panel list's (created, id). The standalone marks page keeps the
  list.
- Inline group headers are divs, never figure.item.
- Every mark-dependent element is a data-region: the verdict, each tile,
  the rail's filter counts. There is also a server-rendered status line.
- The in-place script (base.html) POSTs with an explicit JSON Accept, then
  on 204 swaps every region from a fresh GET. Live media and per-viewer view
  state are carried across the swap, so there is no layout jolt and no
  stopped track. It never re-POSTs: on failure it says so and reloads. Tile
  controls re-bind after a swap, and the grid cursor survives it.
- The `n` key opens the tile's closed note disclosure before focusing it.
- test_embed_browser's keyboard-flag test expected a navigation, which is
  the defect R2 removes. It is updated as declared in the contract, and
  tightened: a window marker must survive, proving no reload.

Browser tests: flag in place, with no reload and no scroll jump, and the
tile, tray and rail count all updated; and a failed save that reloads
without re-POSTing. Two mutations turn them red (no carry, no rail region).
700 passed.
This commit is contained in:
vh
2026-09-23 08:57:36 -07:00
parent ce27b06f32
commit 50f88a3e5e
8 changed files with 445 additions and 109 deletions
+6
View File
@@ -1139,6 +1139,12 @@ def create_app(
it["name"]: marks_for_target(marks, it["name"]) for it in gallery
},
"booth_marks": marks_for_target(marks, None),
# R2 C5: the flag tray — the flagged items in SET order, i.e.
# by ordinal, over the full gallery (a filter hides tiles, not
# judgments). Order is total with no tie-break: rels are unique.
"tray": [it for it in gallery if it["name"] in {
m.target for m in marks if m.shape == "flag" and m.error is None}],
"ord_width": len(str(len(gallery))),
"uploaded": (booth / UPLOAD_MARKER).exists(),
# The same provenance line the index card carries. Deliberate:
# a booth URL handed to the operator lands HERE, never on the
+39 -19
View File
@@ -32,7 +32,7 @@
<span class="mark-state">⚠ broken</span>
<span class="mark-id"><code>{{ a.id }}</code></span>
<span class="board-spacer"></span>
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark">
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ a.id }}">
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<button type="submit" class="mark-x" title="withdraw this mark">×</button>
@@ -75,7 +75,7 @@
{% endif %}
<details class="mark-formwrap"{% if not a.answer %} open{% endif %}>
<summary class="mark-change">{% if a.answer %}change answer{% else %}answer{% endif %}</summary>
<form class="mark-form" method="post" action="/b/{{ name_url }}/answer">
<form class="mark-form" method="post" action="/b/{{ name_url }}/answer" data-inplace>
{# The field is still `ask`: inline fragments in reports the operator
has already published POST that name, and breaking every landed
verbatim report to tidy a form field is not a trade worth making. #}
@@ -117,25 +117,26 @@
</article>
{% endfor %}
{% for a in notes %}
<article class="mark mark-note" id="mark-{{ a.id }}">
{# 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
small, blurred if the item is. The standalone marks page has no item
records, so it keeps the list, in `(created, id)` order. #}
{% if tray is defined and tray %}
<article class="mark mark-flags" id="mark-flags">
<header class="mark-head">
<span class="mark-state mark-state-note">note</span>
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>
{% else %}<span class="mark-target">on this booth</span>{% endif %}
<span class="board-spacer"></span>
<span class="mark-when">{{ a.created }}{% if a.by %} · {{ a.by }}{% endif %}</span>
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark">
<input type="hidden" name="mark" value="{{ a.id }}">
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<button type="submit" class="mark-x" title="withdraw this note">×</button>
</form>
<span class="mark-state mark-state-flag">✔ flagged</span>
<span class="mark-id">{{ tray|length }} item{{ '' if tray|length == 1 else 's' }} · in set order</span>
</header>
<pre class="mark-text">{{ a.text }}</pre>
<div class="tray">
{% for it in tray %}
<a class="tray-item{% if it.blurred %} is-blurred{% endif %}" href="view?f={{ it.url }}" title="{{ it.name }}">
{%- if it.kind == 'image' %}<img loading="lazy" src="{{ it.url }}" alt="">{% else %}<span class="tray-kind">{{ it.kind }}</span>{% endif -%}
<span class="tray-ord">#{{ "%0*d"|format(ord_width, it.ordinal) }}</span></a>
{% endfor %}
</div>
</article>
{% endfor %}
{% if flags %}
{% elif tray is not defined and flags %}
<article class="mark mark-flags" id="mark-flags">
<header class="mark-head">
<span class="mark-state mark-state-flag">✔ flagged</span>
@@ -149,9 +150,28 @@
</article>
{% endif %}
{% for a in notes %}
<article class="mark mark-note" id="mark-{{ a.id }}">
<header class="mark-head">
<span class="mark-state mark-state-note">note</span>
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>
{% else %}<span class="mark-target">on this booth</span>{% endif %}
<span class="board-spacer"></span>
<span class="mark-when">{{ a.created }}{% if a.by %} · {{ a.by }}{% endif %}</span>
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ a.id }}">
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<button type="submit" class="mark-x" title="withdraw this note">×</button>
</form>
</header>
<pre class="mark-text">{{ a.text }}</pre>
</article>
{% endfor %}
{# The operator volunteering a remark, which before marks had no mechanism at
all — this is the direction that was running through chat. #}
<form class="mark-add" method="post" action="/b/{{ name_url }}/note">
<form class="mark-add" method="post" action="/b/{{ name_url }}/note" data-inplace>
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<textarea name="text" rows="2" placeholder="a note on this booth, for the session that posted it"></textarea>
<button type="submit">Add note</button>
+129 -1
View File
@@ -419,6 +419,52 @@
.mark-add textarea,.vaddnote textarea,.item-addnote textarea{flex:1;min-width:0}
.mark-add{margin-top:2px}
/* ---- THE LIGHTBOX (R2 C5) ----------------------------------------------
The set on the left, the verdict on the right — placed by grid AREAS, not
by `order`: the aside is FIRST in the document so a narrow screen stacks
the question above the work without anything being reordered. The aside
is sticky and scrolls on its own, so the question stays beside tile 40. */
main:has(.lightbox){max-width:1560px}
.lightbox{display:grid;grid-template-columns:minmax(0,1fr) 360px;grid-template-areas:"set verdict";
gap:var(--space-6);align-items:start}
.lb-set{grid-area:set;min-width:0}
.verdict{grid-area:verdict;position:sticky;top:12px;max-height:calc(100vh - 24px);overflow:auto;
border:1px solid var(--border-default);border-radius:var(--radius-xl);background:var(--surface-base);
padding:12px;scrollbar-width:thin}
.verdict .marks{margin:0}
.verdict .mark-prompt{font-size:var(--size-body)}
.verdict .mark-add{flex-direction:column;align-items:stretch}
.verdict .mark-add button{align-self:flex-end}
@media (max-width:1000px){
.lightbox{grid-template-columns:1fr;grid-template-areas:"verdict" "set"}
.verdict{position:static;max-height:none}
}
/* the flag tray: the flagged items in set order, the originals shown small */
.tray{display:grid;grid-template-columns:repeat(auto-fill,minmax(64px,1fr));gap:6px;padding:12px 14px}
.tray-item{position:relative;display:block;aspect-ratio:1;border-radius:var(--radius-md);overflow:hidden;
border:1px solid var(--success);background:var(--surface-sunken)}
.tray-item img{width:100%;height:100%;object-fit:cover;display:block}
.tray-item.is-blurred img{filter:blur(8px)}
.tray-kind{display:flex;align-items:center;justify-content:center;height:100%;font-family:var(--font-mono);
font-size:var(--size-micro);text-transform:uppercase;color:var(--text-muted)}
.tray-ord{position:absolute;left:3px;bottom:3px;padding:1px 4px;border-radius:var(--radius-sm);
font:600 10px/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .85);color:oklch(0.91 0.008 216)}
.tray-item:hover{text-decoration:none;box-shadow:0 0 0 1px var(--success)}
/* ordinals: the item's number in the whole set (C1) */
.item > .ord{position:absolute;top:10px;left:10px;z-index:2;padding:3px 6px;border-radius:var(--radius-sm);
font:600 11px/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .82);color:oklch(0.91 0.008 216);
pointer-events:none}
.item.blurred > .ord{top:44px}
.doc-bar .ord{font:600 11px/1 var(--font-mono);color:var(--text-muted)}
/* the inline group header: spans the grid, is never a tile */
.grp-head{grid-column:1 / -1;display:flex;align-items:center;gap:8px;margin:8px 0 -4px;
font-family:var(--font-mono);font-size:var(--size-micro);letter-spacing:var(--tracking-caps);text-transform:uppercase}
.grp-head::after{content:"";flex:1;height:1px;background:var(--border-subtle)}
.grp-key{color:var(--text-heading);font-weight:600}
.grp-n{color:var(--text-muted)}
.status{margin:0 0 12px;padding:8px 12px;border-radius:var(--radius-md);background:var(--warning-soft);
color:var(--warning-text);font-size:var(--size-sm)}
/* ---- the gallery ------------------------------------------------------ */
/* THE RAIL. Sticky, opaque, and counted in mono because the counts are
records. The active filter is a place you are, not an armed thing, so it
@@ -726,7 +772,89 @@
<a class="brand" href="/"><span class="dot"></span><span class="name">The&nbsp;Booth</span></a>
<span class="tagline">ephemeral media · auto-wipes {{ ttl_hours }}h · kept boards don't</span>
</header>
<main>{% block content %}{% endblock %}</main>
<main>
{# R2 C3: where the in-place script says it could not save in place. Server
rendered and empty, so the script only ever sets its text — it builds no
markup (INV-6). #}
<p class="status" data-region="status" role="status" aria-live="polite" hidden></p>
{% block content %}{% endblock %}</main>
<script>
/* IN-PLACE JUDGMENT (R2 C3). A form marked `data-inplace` is POSTed in the
background with `Accept: application/json`; on 204 the page is re-fetched
and every server-rendered `data-region` is swapped for its fresh copy. The
SERVER RENDERS EVERY STATE; this only places it — no markup is built here
(INV-6), for U3's reason: a second renderer in JavaScript would be the same
bug in a new language.
It NEVER RE-POSTS. After a lost response the write may already have
landed, and a retry would duplicate a note or re-date an answer. On any
failure it says so and reloads with a GET, so what shows is the server's
truth. With scripts off, every one of these is a plain form and a 303. */
(function () {
if (!window.fetch || !window.DOMParser || !window.URLSearchParams) return;
function say(text) {
var st = document.querySelector('[data-region="status"]');
if (st) { st.textContent = text; st.hidden = false; }
}
/* What the server cannot render, carried from the old node to the new:
live MEDIA elements whose src did not change (a playing track keeps
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) {
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++) {
var o = olds[i];
if (o && o.tagName === m.tagName && o.getAttribute('src') === m.getAttribute('src')) {
o.className = m.className; /* the SERVER decides blur/state classes */
m.replaceWith(o); olds[i] = null; break;
}
}
});
['revealed', 'is-closed'].forEach(function (c) {
if (oldEl.classList.contains(c)) newEl.classList.add(c);
});
}
function swap(html) {
var fresh = new DOMParser().parseFromString(html, 'text/html');
document.querySelectorAll('[data-region]').forEach(function (el) {
var id = el.getAttribute('data-region');
if (id === 'status') return;
var next = null;
fresh.querySelectorAll('[data-region]').forEach(function (cand) {
if (!next && cand.getAttribute('data-region') === id) next = cand;
});
if (next) {
var node = document.importNode(next, true);
carry(el, node);
el.replaceWith(node);
}
});
document.dispatchEvent(new CustomEvent('booth:swapped'));
}
document.addEventListener('submit', function (ev) {
var form = ev.target;
if (!form.matches || !form.matches('form[data-inplace]') || ev.defaultPrevented) return;
ev.preventDefault();
var data = new FormData(form);
if (ev.submitter && ev.submitter.name) data.append(ev.submitter.name, ev.submitter.value);
fetch(form.action, {
method: 'POST', body: new URLSearchParams(data),
headers: {'Accept': 'application/json'}, credentials: 'same-origin'
}).then(function (r) {
if (r.status !== 204) throw new Error('status ' + r.status);
return fetch(window.location.href, {headers: {'Accept': 'text/html'}, credentials: 'same-origin'});
}).then(function (r) {
if (!r.ok) throw new Error('status ' + r.status);
return r.text();
}).then(swap).catch(function () {
say('Could not save in place — reloading to show what was saved.');
window.location.reload();
});
});
})();
</script>
<script>
/* The sticky rail's real height, published as --rail-h for .item's
scroll-margin. ADDITIVE: with JS off the static fallback applies, and a
+73 -85
View File
@@ -20,7 +20,7 @@
one definition. `marks` here is THIS item's marks, from item_marks. #}
{% macro markcontrols(name_url, it, marks, cls='') -%}
{% set flagged = marks | selectattr('shape', 'equalto', 'flag') | list | length > 0 %}
<form class="flagtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/flag">
<form class="flagtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/flag" data-inplace>
<input type="hidden" name="target" value="{{ it.name }}">
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
<button title="{{ 'un-flag this item' if flagged else 'flag this one — the session that posted it can read the selection' }}"
@@ -37,7 +37,7 @@
{% for m in marks if m.shape == 'note' %}
<div class="item-note" id="mark-{{ m.id }}">
<pre>{{ m.text }}</pre>
<form method="post" action="/b/{{ name_url }}/unmark">
<form method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ m.id }}">
<button class="mark-x" title="withdraw this note">×</button>
</form>
@@ -45,7 +45,7 @@
{% endfor %}
<details class="item-addnote">
<summary>+ note</summary>
<form method="post" action="/b/{{ name_url }}/note">
<form method="post" action="/b/{{ name_url }}/note" data-inplace>
<input type="hidden" name="target" value="{{ it.name }}">
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
<button type="submit">Add</button>
@@ -116,7 +116,11 @@
a gallery, and the add-note control would be noise on it — but the
suppression was unconditional, so a pick declared on a booth that happens to
carry a links.md had no form to answer it and nothing said so. #}
{% if marks or not board %}
{# R2 C5: on a GALLERY booth the panel moves into the verdict aside beside the
set (below). It renders up here only where there is no set to sit beside —
a board, or a booth with marks and nothing to show. #}
{% set lightbox = all_items and not board %}
{% if (marks or not board) and not lightbox %}
{% include "_marks.html" %}
{% endif %}
@@ -257,6 +261,16 @@
{% if not all_items and not board and not marks %}
<div class="empty">This booth is empty.</div>
{% elif all_items %}
{# THE LIGHTBOX (R2 C5). The verdict aside comes FIRST in the document and the
set second: on a narrow screen that is the stacking the contract wants
(the question above the work), and on a wide one the grid areas in
base.html put the aside on the right. Placement, not order — nothing in an
ordered collection moves. #}
<div class="lightbox">
<aside class="verdict" data-region="verdict" aria-label="your verdict">
{% include "_marks.html" %}
</aside>
<div class="lb-set">
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its
links.md is rendered as the board above and filtered out), so a plain else
would emit an empty <div class="gallery"> under the board. #}
@@ -273,7 +287,7 @@
is therefore the whole guard; the two degenerate cases (one group for
everything, one group per item) are decided in Python, where they can be
measured, rather than by a count in a template. #}
<div class="rail">
<div class="rail" data-region="filters">
<span class="rail-total">{{ rail.total }} item{{ '' if rail.total == 1 else 's' }}</span>
{% for f in rail.counts %}
<a class="rail-f{% if f.key == filter %} on{% endif %}"
@@ -299,14 +313,21 @@
<div class="empty">No items match the <b>{{ filter }}</b> filter.
<a href="/b/{{ name_url }}/">show all {{ rail.total }}</a></div>
{% endif %}
{% set group_n = {} %}{% for g in rail.groups %}{% set _ = group_n.update({g.key: g.n}) %}{% endfor %}
<div class="gallery" id="grid" tabindex="-1">
{% for it in items %}
{# R2 C5: an inline header before each group's FIRST tile, only when the
rail thinks grouping is informative. A <div> spanning the grid, never a
figure.item, so the keyboard and the order check are blind to it. #}
{% if rail.groups and it.group and (loop.first or loop.previtem.group != it.group) %}
<div class="grp-head" aria-hidden="true"><span class="grp-key">{{ it.group }}</span> <span class="grp-n">{{ group_n.get(it.group, '') }}</span></div>
{% endif %}
{% if it.doc and it.rendered is not none %}
{# Docs render INLINE, collapsible, and closable — not a link to a
separate page. <details open> is native collapse (works with JS off);
the ✕ hides the item for the session (JS, progressive enhancement).
The item spans the full grid width so prose has room to read. #}
<figure class="item item-doc{% if it.blurred %} blurred{% endif %}" data-name="{{ it.name }}" data-item="{{ it.name }}" id="item-{{ it.url }}">
<figure class="item item-doc{% if it.blurred %} blurred{% endif %}" data-name="{{ it.name }}" data-item="{{ it.name }}" id="item-{{ it.url }}" data-region="item-{{ it.url }}">
{% if it.blurred %}
{# Inline docs need this MORE than images, not less: a rendered doc puts
its text straight on the page, so "blur the picture" logic that skips
@@ -340,7 +361,7 @@
</details>
</figure>
{% else %}
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if item_marks.get(it.name, []) | selectattr('shape', 'equalto', 'flag') | list %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.url }}">
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if item_marks.get(it.name, []) | selectattr('shape', 'equalto', 'flag') | list %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.url }}" data-region="item-{{ it.url }}">
{{ ordinal(it) }}
{% if it.blurred %}
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
@@ -385,6 +406,8 @@
{% endif %}
{% endfor %}
</div>
</div>{# .lb-set #}
</div>{# .lightbox #}
{% endif %}
{% if items %}
@@ -455,6 +478,11 @@
Found by the heid bug-hunt panel (hulda), 2026-09-22. */
case 'f': click('.flagtoggle button'); e.preventDefault(); break;
case 'n': var el = current();
/* The add-note field lives in a closed <details> (270 tiles
must not each carry an open textarea); a closed one cannot
take focus, so open it first. */
var d = el && el.querySelector('details.item-addnote');
if (d) d.open = true;
if (el) { var f = el.querySelector('input[type=text], textarea');
if (f) { f.focus(); e.preventDefault(); } }
break;
@@ -464,6 +492,13 @@
at = -1; break;
}
});
/* R2 C3: an in-place save swaps the tiles for fresh server-rendered ones,
and the cursor is client state the server cannot render. Put it back on
the same position — the order did not change, only the judgment. */
document.addEventListener('booth:swapped', function () {
if (at < 0) return;
tiles().forEach(function (el, j) { el.classList.toggle('is-cursor', j === at); });
});
})();
</script>
{% endif %}
@@ -500,17 +535,30 @@
});
})();
/* Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits
/* TILE CONTROLS, bound per node and RE-BOUND after an in-place swap (R2 C3):
the swap puts fresh server-rendered tiles in the page, and a handler bound
to the node it replaced goes with that node. `__bound` keeps a node from
being bound twice.
Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits
inside <summary>, so without this its click would just toggle the <details>
open/closed — stopPropagation + preventDefault make ✕ mean "close", not
"collapse". Collapse stays available via the rest of the summary bar. With
JS off the button is inert and collapse via <details> still works. */
(function () {
JS off the button is inert and collapse via <details> still works.
Blur reveal. WARNING: this handler previously sat after the content block's
closing tag, which in a child template Jinja DISCARDS — the button rendered
and did nothing, and two commits plus a README claimed click-to-reveal
worked. Anything that must reach the page belongs inside the content
block. Per-viewer and never persisted: a reload re-hides. */
function bindTiles() {
function once(el) { if (el.__bound) return false; el.__bound = true; return true; }
/* A form inside <summary> would otherwise collapse the doc on submit. */
document.querySelectorAll('.doc-bar .blurtoggle').forEach(function (f) {
f.addEventListener('click', function (ev) { ev.stopPropagation(); });
document.querySelectorAll('.doc-bar .blurtoggle, .doc-bar .flagtoggle').forEach(function (f) {
if (once(f)) f.addEventListener('click', function (ev) { ev.stopPropagation(); });
});
document.querySelectorAll('.doc-close').forEach(function (btn) {
if (!once(btn)) return;
btn.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
@@ -518,80 +566,20 @@
if (item) item.classList.add('is-closed');
});
});
})();
/* Link-board multi-select. PROGRESSIVE ENHANCEMENT: the checkboxes, the per-row
× / ★, and the bulk 🗑 all submit as plain form POSTs with JS off — this only
adds select-all, a live count, and disabling 🗑 when nothing is ticked. The
per-row × confirm reads desc/url from data-* attributes rather than being
interpolated into an inline handler, so an arbitrary agent-posted description
(quotes, newlines) can never break out into the page's JS. */
(function () {
var form = document.getElementById('boardform');
if (!form) return;
var boxes = Array.prototype.slice.call(form.querySelectorAll('.board-check'));
var selall = document.getElementById('board-selall');
var delBtn = document.getElementById('board-del-sel');
var countEl = document.getElementById('board-selcount');
function selected() { return boxes.filter(function (b) { return b.checked; }); }
function refresh() {
var n = selected().length;
if (countEl) countEl.textContent = n;
if (delBtn) delBtn.disabled = n === 0;
if (selall) {
selall.checked = n > 0 && n === boxes.length;
selall.indeterminate = n > 0 && n < boxes.length;
}
}
if (selall) {
selall.addEventListener('change', function () {
boxes.forEach(function (b) { b.checked = selall.checked; });
refresh();
});
}
boxes.forEach(function (b) { b.addEventListener('change', refresh); });
// Bulk delete: confirm with the count. Attached to the button (not the form's
// submit) so the per-row × / ★ submits — which share this form — are unaffected.
if (delBtn) {
delBtn.addEventListener('click', function (ev) {
var n = selected().length;
if (n === 0) { ev.preventDefault(); return; }
if (!confirm('Delete ' + n + ' selected link' + (n === 1 ? '' : 's') + '?\n\nThe rest of the board is untouched.')) {
ev.preventDefault();
}
});
}
form.querySelectorAll('.board-rm-btn').forEach(function (btn) {
btn.addEventListener('click', function (ev) {
var d = btn.getAttribute('data-desc') || '';
var u = btn.getAttribute('data-url') || '';
if (!confirm('Remove this link?\n\n' + d + '\n' + u + '\n\nThe rest of the board is untouched.')) {
ev.preventDefault();
}
});
});
refresh();
})();
/* Blur reveal. WARNING: this handler previously sat after the content
block's closing tag, which in a
child template Jinja DISCARDS — the button rendered and did nothing, and
two commits plus a README claimed click-to-reveal worked. Anything that
must reach the page belongs inside the content block. Verified now by
grepping the SERVED html for this function, not the template for the text.
Per-viewer and never persisted: a reload re-hides. */
document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
btn.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
var fig = btn.closest('.item');
var on = fig.classList.toggle('revealed');
btn.textContent = on ? '🙈 hide' : '👁 reveal';
/* a swap carries `revealed` across (base.html); the label follows it */
btn.textContent = fig.classList.contains('revealed') ? '🙈 hide' : '👁 reveal';
if (!once(btn)) return;
btn.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
var on = fig.classList.toggle('revealed');
btn.textContent = on ? '🙈 hide' : '👁 reveal';
});
});
});
}
bindTiles();
document.addEventListener('booth:swapped', bindTiles);
</script>
{% endblock %}
+2
View File
@@ -33,6 +33,7 @@ touches:
- "booth/static/embed.js (NOT TOUCHED — the verbatim path keeps its author's layout; requirement 6)"
- "tests/test_booth.py (THREE assertions change, all in test_index_separates_kept_from_ephemeral: L785-786, the kept-lane presence pair, and L789, kept-before-ephemeral. L810-811, the absence pair, survive unchanged. See 'Assertions that change')"
- "tests/test_flow.py (NEW)"
- "tests/test_embed_browser.py (ONE test changes: test_the_keyboard_flag_actually_submits expected a navigation, which is the defect R2 removes. See 'Assertions that change')"
assumptions:
- "ONE VIEWER. `.seen` records what has been seen at full size, not WHO saw it. ROADMAP parks 'per-viewer state (who has seen what)' on the one-viewer premise; this contract keeps that premise and does not reopen the parked item."
- "EVERY JUDGMENT WORKS WITH JAVASCRIPT OFF. Each control stays a plain <form method=post>. The in-place behaviour is additive and falls back to today's 303."
@@ -389,6 +390,7 @@ This applies to image, video and audio items. Docs keep `doc.html`.
|---|---|---|---|
| test_booth.py L785 | `class="grid kept-grid"` present when a booth is kept | absent; the kept booth appears in its Desk section with the `kept` lifetime line | requirement 8: the lanes sort nothing |
| test_booth.py L786 | `class="card card-kept"` present | replaced by the row carrying `data-kept="1"` | same |
| test_embed_browser.py `test_the_keyboard_flag_actually_submits` | pressing `f` causes a NAVIGATION (`page.expect_navigation()`), and the reloaded page shows the flag | pressing `f` causes NO navigation; the flag comes back from the server into the swapped tile. A window marker set before the keypress must survive, proving no reload | requirement 3: the reload IS the defect. The test's real claim — the key reaches the server and the server's state comes back — is kept, and asserted more strictly |
| test_booth.py L789 | the kept booth renders BEFORE the ephemeral one (`html.index("links") < html.index("scratch")`) | replaced by the Desk's stated order (needs → new → everything, each with its own key) | the kept-first order was the lane's; with no lane there is no kept-first rule, and a second hidden ordering would break INV-2 |
| test_booth.py L810-811 | lane absent when nothing is kept | these two SURVIVE unchanged (they assert absence and stay true) | — |
+13 -4
View File
@@ -592,17 +592,26 @@ def test_the_keyboard_flag_actually_submits(browser, live):
a hidden input does not submit its form. The shortcut never worked while
still swallowing the keystroke.
Asserted end to end: press f, and the flag must come back from the server
on the reloaded page."""
Asserted end to end: press f, and the flag must come back from the server.
R2 C3 (docs/contracts/r2_flow.contract.md, "Assertions that change"): this
used to expect a NAVIGATION — the flag form POSTed and the page reloaded.
That reload is the defect R2 removes. The claim that matters is kept and
tightened: the flag must come back from the SERVER (the swapped tile is
server-rendered), and a marker set on the window before the keypress must
survive, which a reload would wipe."""
base, root = live
_gallery(root)
page = browser.new_page()
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.evaluate("window.__noReload = 1")
page.keyboard.press("ArrowRight") # cursor onto the first tile
with page.expect_navigation(): # the flag form POSTs and redirects back
page.keyboard.press("f")
page.keyboard.press("f")
page.wait_for_selector("figure.item.is-flagged", timeout=10000)
flagged = page.locator("figure.item.is-flagged").count()
survived = page.evaluate("window.__noReload === 1")
page.close()
assert flagged == 1, f"the f key flagged {flagged} items, expected 1"
assert survived, "the flag reloaded the page; in-place judgment must not"
+69
View File
@@ -407,3 +407,72 @@ def test_a_booth_without_images_shows_its_kind_instead(tmp_path):
_booth(tmp_path, "songs", {"a.mp3": b"ID3", "b.mp3": b"ID3"})
row = re.search(r'data-booth="songs".*?</article>', _client(tmp_path).get("/").text, re.S).group(0)
assert "♪ audio" in row and "<img" not in row
# ---- C5: the lightbox ---------------------------------------------------------
def _region(body: str, rid: str) -> str:
m = re.search(r'<(\w+)[^>]*data-region="%s"[^>]*>' % re.escape(rid), body)
assert m, f"no region {rid}"
tag = m.group(1)
# regions in these templates do not nest a same-named tag inside themselves
end = body.index(f"</{tag}>", m.end())
return body[m.start():end]
def test_the_verdict_sits_beside_the_set_on_a_gallery_booth(tmp_path):
"""The tracer for C5: the open question, the flags and the notes live in
one aside next to the grid — not in a panel above it that scrolls away."""
from booth.marks import declare_pick
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
declare_pick(b, "q", {"prompt": "Which one?", "options": ["a", "b"]})
body = _client(tmp_path).get("/b/g/").text
aside = _region(body, "verdict")
assert aside.startswith('<aside class="verdict"')
assert 'action="/b/g/answer"' in aside and "Which one?" in aside
assert body.count('action="/b/g/answer"') == 1, "the pick renders once, in the aside"
assert 'class="lightbox"' in body and 'id="grid"' in body
def test_the_flag_tray_lists_flags_by_tile_number_not_by_click_order(tmp_path):
"""Flag #3 first, then #1: the tray reads #1, #3. Today's panel list is in
click order `(created, id)`; the tray is the SET's order, the declared
change. Each entry is the original shown small, blurred if the item is."""
from booth.app import set_blurred
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG, "c.png": PNG})
set_flag(b, "c.png", True)
time.sleep(0.01)
set_flag(b, "a.png", True)
set_blurred(b, "a.png", True)
aside = _region(_client(tmp_path).get("/b/g/").text, "verdict")
tray = re.findall(r'<a class="tray-item( is-blurred)?" href="view\?f=([^"]+)"[^>]*>.*?#(\d+)<', aside, re.S)
assert tray == [(" is-blurred", "a.png", "1"), ("", "c.png", "3")]
def test_groups_get_an_inline_header_that_is_not_a_tile(tmp_path):
"""When the rail finds grouping informative, each group's first tile is
preceded by a header — a div, never a figure.item, so the tile sequence
the keyboard and the order check walk is exactly the items."""
files = {f"{g}-{n}.png": PNG for g in ("aa", "bb", "cc") for n in (1, 2)}
_booth(tmp_path, "g", files)
body = _client(tmp_path).get("/b/g/").text
grid = body[body.index('id="grid"'):]
heads = re.findall(r'<div class="grp-head"[^>]*><span class="grp-key">(\w+)</span> <span class="grp-n">(\d+)</span>', grid)
assert heads == [("aa", "2"), ("bb", "2"), ("cc", "2")]
assert len(re.findall(r'<figure class="item', grid)) == 6
# and no header at all when grouping is not informative
_booth(tmp_path, "flat", {f"DSC{n}.jpg": PNG for n in range(4)})
assert 'class="grp-head"' not in _client(tmp_path).get("/b/flat/").text
def test_every_mark_dependent_element_is_a_swappable_region(tmp_path):
"""The in-place script replaces regions by id; a stale flag count or tile
after an in-place flag is the failure this pins."""
b = _booth(tmp_path, "g", {"a b.png": PNG, "c.png": PNG})
body = _client(tmp_path).get("/b/g/").text
for rid in ("verdict", "filters", "item-a%20b.png", "item-c.png", "status"):
assert f'data-region="{rid}"' in body, rid
# the standing board has no lightbox and therefore no verdict aside
_booth(tmp_path, "links", {"links.md": b"- [x](http://x/) <sub>\xc2\xb7 a \xc2\xb7 2026-09-01 10:00</sub>\n"})
board = _client(tmp_path).get("/b/links/").text
assert 'data-region="verdict"' not in board and 'class="lightbox"' not in board
+114
View File
@@ -0,0 +1,114 @@
"""R2 C3/C5/C6 in a real DOM: in-place judgment.
A TestClient can prove what the server answers. It cannot prove that a flag
made in the page lands without a reload, that the regions come back fresh, or
that a failure never re-POSTs. So: a real uvicorn, a real Chromium — the same
harness as test_embed_browser.py, and like it this SKIPS, never fails, when no
browser is available.
"""
import pathlib
import socket
import sys
import threading
import time
import pytest
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from booth.app import create_app # noqa: E402
playwright_api = pytest.importorskip("playwright.sync_api", reason="playwright is not installed")
PNG = b"\x89PNG\r\n\x1a\n"
@pytest.fixture(scope="module")
def browser():
with playwright_api.sync_playwright() as pw:
try:
b = pw.chromium.launch()
except Exception as exc: # noqa: BLE001 - any launch failure is a skip
pytest.skip(f"no usable chromium: {exc}")
yield b
b.close()
@pytest.fixture
def live(tmp_path):
import uvicorn
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error"))
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
deadline = time.time() + 10
while not server.started and time.time() < deadline:
time.sleep(0.02)
if not server.started:
pytest.skip("uvicorn did not come up")
try:
yield f"http://127.0.0.1:{port}", tmp_path
finally:
server.should_exit = True
thread.join(timeout=10)
def _set(root: pathlib.Path, n: int = 30) -> pathlib.Path:
b = root / "g"
b.mkdir()
for i in range(1, n + 1):
(b / f"{i:02d}.png").write_bytes(PNG)
return b
def test_a_flag_lands_in_place_and_every_region_catches_up(browser, live):
"""Click a tile's flag far down the page: no reload, the scroll position
holds, and the tile, the tray and the rail's count all show the server's
new state — the three places one flag appears."""
base, root = live
_set(root)
posts = []
page = browser.new_page(viewport={"width": 1400, "height": 800})
page.on("request", lambda r: posts.append(r.url) if r.method == "POST" else None)
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.evaluate("window.__noReload = 1")
tile = page.locator('figure.item[data-item="24.png"]')
tile.scroll_into_view_if_needed()
y = page.evaluate("window.scrollY")
tile.locator(".flagtoggle button").click()
page.wait_for_selector('figure.item.is-flagged[data-item="24.png"]', timeout=10000)
state = page.evaluate("""() => ({
reload: window.__noReload !== 1,
y: window.scrollY,
tray: [...document.querySelectorAll('.tray .tray-ord')].map(e => e.textContent),
count: document.querySelector('.rail-f[data-filter="flagged"] b').textContent,
})""")
page.close()
assert not state["reload"]
assert abs(state["y"] - y) < 4, "the page must not jump"
assert state["tray"] == ["#24"]
assert state["count"] == "1"
assert len(posts) == 1
def test_a_failed_save_says_so_reloads_and_never_re_posts(browser, live):
"""The server refuses the write (the marks file went bad under the page).
The script must not retry — a retry after a lost response would duplicate
the judgment — it says so and reloads to show the server's truth."""
base, root = live
b = _set(root, 3)
posts = []
page = browser.new_page()
page.on("request", lambda r: posts.append(r.url) if r.method == "POST" else None)
page.goto(f"{base}/b/g/", wait_until="networkidle")
(b / ".marks.json").write_text("{damaged")
with page.expect_navigation(timeout=10000):
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
page.wait_for_load_state("networkidle")
page.close()
assert len(posts) == 1, f"re-POSTed: {posts}"