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:
@@ -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
@@ -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
@@ -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 Booth</span></a>
|
<a class="brand" href="/"><span class="dot"></span><span class="name">The 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
@@ -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 %}
|
||||||
|
|||||||
@@ -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) | — |
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
Reference in New Issue
Block a user