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 %}