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 it["name"]: marks_for_target(marks, it["name"]) for it in gallery
}, },
"booth_marks": marks_for_target(marks, None), "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(), "uploaded": (booth / UPLOAD_MARKER).exists(),
# The same provenance line the index card carries. Deliberate: # The same provenance line the index card carries. Deliberate:
# a booth URL handed to the operator lands HERE, never on the # 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-state">⚠ broken</span>
<span class="mark-id"><code>{{ a.id }}</code></span> <span class="mark-id"><code>{{ a.id }}</code></span>
<span class="board-spacer"></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 }}"> <input type="hidden" name="mark" value="{{ a.id }}">
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %} {% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<button type="submit" class="mark-x" title="withdraw this mark">×</button> <button type="submit" class="mark-x" title="withdraw this mark">×</button>
@@ -75,7 +75,7 @@
{% endif %} {% endif %}
<details class="mark-formwrap"{% if not a.answer %} open{% endif %}> <details class="mark-formwrap"{% if not a.answer %} open{% endif %}>
<summary class="mark-change">{% if a.answer %}change answer{% else %}answer{% endif %}</summary> <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 {# The field is still `ask`: inline fragments in reports the operator
has already published POST that name, and breaking every landed has already published POST that name, and breaking every landed
verbatim report to tidy a form field is not a trade worth making. #} verbatim report to tidy a form field is not a trade worth making. #}
@@ -117,25 +117,26 @@
</article> </article>
{% endfor %} {% endfor %}
{% for a in notes %} {# FLAGS come right after the picks. On the lightbox (`tray` defined) they
<article class="mark mark-note" id="mark-{{ a.id }}"> 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"> <header class="mark-head">
<span class="mark-state mark-state-note">note</span> <span class="mark-state mark-state-flag">✔ flagged</span>
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span> <span class="mark-id">{{ tray|length }} item{{ '' if tray|length == 1 else 's' }} · in set order</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>
</header> </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> </article>
{% endfor %} {% elif tray is not defined and flags %}
{% if flags %}
<article class="mark mark-flags" id="mark-flags"> <article class="mark mark-flags" id="mark-flags">
<header class="mark-head"> <header class="mark-head">
<span class="mark-state mark-state-flag">✔ flagged</span> <span class="mark-state mark-state-flag">✔ flagged</span>
@@ -149,9 +150,28 @@
</article> </article>
{% endif %} {% 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 {# The operator volunteering a remark, which before marks had no mechanism at
all — this is the direction that was running through chat. #} 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 %} {% 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> <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>
+129 -1
View File
@@ -419,6 +419,52 @@
.mark-add textarea,.vaddnote textarea,.item-addnote textarea{flex:1;min-width:0} .mark-add textarea,.vaddnote textarea,.item-addnote textarea{flex:1;min-width:0}
.mark-add{margin-top:2px} .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 gallery ------------------------------------------------------ */
/* THE RAIL. Sticky, opaque, and counted in mono because the counts are /* 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 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> <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> <span class="tagline">ephemeral media · auto-wipes {{ ttl_hours }}h · kept boards don't</span>
</header> </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> <script>
/* The sticky rail's real height, published as --rail-h for .item's /* 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 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. #} one definition. `marks` here is THIS item's marks, from item_marks. #}
{% macro markcontrols(name_url, it, marks, cls='') -%} {% macro markcontrols(name_url, it, marks, cls='') -%}
{% set flagged = marks | selectattr('shape', 'equalto', 'flag') | list | length > 0 %} {% 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="target" value="{{ it.name }}">
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}"> <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' }}" <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' %} {% for m in marks if m.shape == 'note' %}
<div class="item-note" id="mark-{{ m.id }}"> <div class="item-note" id="mark-{{ m.id }}">
<pre>{{ m.text }}</pre> <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 }}"> <input type="hidden" name="mark" value="{{ m.id }}">
<button class="mark-x" title="withdraw this note">×</button> <button class="mark-x" title="withdraw this note">×</button>
</form> </form>
@@ -45,7 +45,7 @@
{% endfor %} {% endfor %}
<details class="item-addnote"> <details class="item-addnote">
<summary>+ note</summary> <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 }}"> <input type="hidden" name="target" value="{{ it.name }}">
<textarea name="text" rows="2" placeholder="a note on this item"></textarea> <textarea name="text" rows="2" placeholder="a note on this item"></textarea>
<button type="submit">Add</button> <button type="submit">Add</button>
@@ -116,7 +116,11 @@
a gallery, and the add-note control would be noise on it — but the 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 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. #} 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" %} {% include "_marks.html" %}
{% endif %} {% endif %}
@@ -257,6 +261,16 @@
{% if not all_items and not board and not marks %} {% if not all_items and not board and not marks %}
<div class="empty">This booth is empty.</div> <div class="empty">This booth is empty.</div>
{% elif all_items %} {% 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 {# `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 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. #} 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 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 everything, one group per item) are decided in Python, where they can be
measured, rather than by a count in a template. #} 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> <span class="rail-total">{{ rail.total }} item{{ '' if rail.total == 1 else 's' }}</span>
{% for f in rail.counts %} {% for f in rail.counts %}
<a class="rail-f{% if f.key == filter %} on{% endif %}" <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. <div class="empty">No items match the <b>{{ filter }}</b> filter.
<a href="/b/{{ name_url }}/">show all {{ rail.total }}</a></div> <a href="/b/{{ name_url }}/">show all {{ rail.total }}</a></div>
{% endif %} {% 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"> <div class="gallery" id="grid" tabindex="-1">
{% for it in items %} {% 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 %} {% if it.doc and it.rendered is not none %}
{# Docs render INLINE, collapsible, and closable — not a link to a {# Docs render INLINE, collapsible, and closable — not a link to a
separate page. <details open> is native collapse (works with JS off); separate page. <details open> is native collapse (works with JS off);
the ✕ hides the item for the session (JS, progressive enhancement). the ✕ hides the item for the session (JS, progressive enhancement).
The item spans the full grid width so prose has room to read. #} 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 %} {% if it.blurred %}
{# Inline docs need this MORE than images, not less: a rendered doc puts {# 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 its text straight on the page, so "blur the picture" logic that skips
@@ -340,7 +361,7 @@
</details> </details>
</figure> </figure>
{% else %} {% 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) }} {{ ordinal(it) }}
{% if it.blurred %} {% if it.blurred %}
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so {# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
@@ -385,6 +406,8 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
</div>{# .lb-set #}
</div>{# .lightbox #}
{% endif %} {% endif %}
{% if items %} {% if items %}
@@ -455,6 +478,11 @@
Found by the heid bug-hunt panel (hulda), 2026-09-22. */ Found by the heid bug-hunt panel (hulda), 2026-09-22. */
case 'f': click('.flagtoggle button'); e.preventDefault(); break; case 'f': click('.flagtoggle button'); e.preventDefault(); break;
case 'n': var el = current(); 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 (el) { var f = el.querySelector('input[type=text], textarea');
if (f) { f.focus(); e.preventDefault(); } } if (f) { f.focus(); e.preventDefault(); } }
break; break;
@@ -464,6 +492,13 @@
at = -1; break; 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> </script>
{% endif %} {% 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> inside <summary>, so without this its click would just toggle the <details>
open/closed — stopPropagation + preventDefault make ✕ mean "close", not open/closed — stopPropagation + preventDefault make ✕ mean "close", not
"collapse". Collapse stays available via the rest of the summary bar. With "collapse". Collapse stays available via the rest of the summary bar. With
JS off the button is inert and collapse via <details> still works. */ JS off the button is inert and collapse via <details> still works.
(function () {
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. */ /* A form inside <summary> would otherwise collapse the doc on submit. */
document.querySelectorAll('.doc-bar .blurtoggle').forEach(function (f) { document.querySelectorAll('.doc-bar .blurtoggle, .doc-bar .flagtoggle').forEach(function (f) {
f.addEventListener('click', function (ev) { ev.stopPropagation(); }); if (once(f)) f.addEventListener('click', function (ev) { ev.stopPropagation(); });
}); });
document.querySelectorAll('.doc-close').forEach(function (btn) { document.querySelectorAll('.doc-close').forEach(function (btn) {
if (!once(btn)) return;
btn.addEventListener('click', function (ev) { btn.addEventListener('click', function (ev) {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@@ -518,80 +566,20 @@
if (item) item.classList.add('is-closed'); if (item) item.classList.add('is-closed');
}); });
}); });
})(); document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
/* 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();
var fig = btn.closest('.item'); var fig = btn.closest('.item');
var on = fig.classList.toggle('revealed'); /* a swap carries `revealed` across (base.html); the label follows it */
btn.textContent = on ? '🙈 hide' : '👁 reveal'; 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> </script>
{% endblock %} {% 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)" - "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_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_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: 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." - "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." - "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 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_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 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) | — | | 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 a hidden input does not submit its form. The shortcut never worked while
still swallowing the keystroke. still swallowing the keystroke.
Asserted end to end: press f, and the flag must come back from the server Asserted end to end: press f, and the flag must come back from the server.
on the reloaded page."""
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 base, root = live
_gallery(root) _gallery(root)
page = browser.new_page() page = browser.new_page()
page.goto(f"{base}/b/g/", wait_until="networkidle") page.goto(f"{base}/b/g/", wait_until="networkidle")
page.evaluate("window.__noReload = 1")
page.keyboard.press("ArrowRight") # cursor onto the first tile 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() flagged = page.locator("figure.item.is-flagged").count()
survived = page.evaluate("window.__noReload === 1")
page.close() page.close()
assert flagged == 1, f"the f key flagged {flagged} items, expected 1" 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"}) _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) 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 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}"