The index card showed a name, an item count and a countdown, and nothing
the poster chose. An agent with something to show therefore had no way to
make the booth say "look at this" and posted a URL to the link board
instead — which is why 145 of that board's 210 rows (69%) ended up
pointing at booths that had already been swept. The board was absorbing a
job it was never shaped for. This is the shape.
Each booth carries `.booth.json` — {handle, title, why, created} — written
by the CLI from $ALTHING_HANDLE, and the provenance line renders on both
index lanes and on the booth page header.
WHAT IS WHERE
- booth/manifest.py, stdlib-only and importing nothing from booth.* either:
scripts/booth imports it under the system python3 with no venv, and a
cross-import between two stdlib-only modules is a second way for that
invariant to break. It joins the shared test_stdlib_only list and keeps
a stricter copy of its own.
- The read is lenient and cannot raise. list_booths touches every booth on
every index load, so a manifest that cannot be parsed costs that booth's
provenance and nothing else. That is the v0.2.2 lesson applied before the
same mistake rather than after it.
- Absent and damaged render differently — `unannounced` and `unreadable`.
Folding "cannot be read" into "never said" would hide the one case
somebody has to go and fix.
- Re-announcing preserves `created`. A second `booth add` sharpening the
why is not a second appearance of the booth.
- The write is atomic (invariant 5); the temp file is itself a dotfile, so
no listing can see it mid-write.
THREE OPERATOR CALLS, 2026-09-22
Flags on the existing new/add verbs rather than a separate `announce` verb
(a second step is the step that gets forgotten, which is the rot's own
mechanism). Unannounced booths get a quiet marker rather than nothing — the
convention is only adoptable if the gap is visible. U5 adds provenance only
and does NOT add a second index ordering keyed on announcement time; that
is a different surface needing its own stated rule, parked for v1.1.
NO EXEMPTION LIST
A pickup booth and the standing link board are created by the service, so
they announce themselves with handle `booth`, which is true rather than
manufactured. One rule — a booth with no manifest is unannounced — instead
of a growing set of special cases.
ALSO
tests/test_booth.py's keep/release assertion was slicing the page on the
bare word `boothhead`, which has lived in the stylesheet far longer than
the assertion has; it was reading CSS and passing on luck, and went red the
first time a new rule landed above the old one. Same assertion, aimed at
the markup. A U5 test had the mirror-image bug: pytest derives tmp_path
from the test name and the index renders data_dir, so a test named
`test_an_unannounced_booth_says_so` put the needle in the haystack itself
and passed against a template that did not yet exist.
310 tests (304 before this unit's CLI half). Live service restarted, 26/26
booth pages verified 200, end-to-end smoke through the real CLI.
NOT TAGGED. The cold contract-review panel is still in flight and the
code-review and bug-hunt gates have not run. Tagging with a gate
outstanding is what made v0.2.0 premature.
165 lines
8.0 KiB
HTML
165 lines
8.0 KiB
HTML
{% extends "base.html" %}
|
||
{% from "_provenance.html" import provenance %}
|
||
{% block content %}
|
||
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
|
||
<label class="drop" for="booth-files">
|
||
<span class="drop-icon">⬆</span>
|
||
<span class="drop-main">Upload files for pickup</span>
|
||
<span class="drop-sub" id="drop-sub">drop here, or click to choose · one pickup id, wiped in {{ ttl_hours }}h</span>
|
||
<input id="booth-files" name="files" type="file" multiple>
|
||
</label>
|
||
<button class="up-go" type="submit">Get pickup id →</button>
|
||
</form>
|
||
|
||
{% if kept %}
|
||
{# Kept boards render FIRST and look different on purpose: they are durable
|
||
operator-facing things (the agent link board, standing reports) and the
|
||
point of the lane is that they cannot be lost in a feed that turns over
|
||
every day. No countdown — they have no expiry to advertise. #}
|
||
<h2 class="lane-head">Kept <span class="lane-note">· no expiry · <code>{{ keep_marker }}</code></span></h2>
|
||
<div class="grid kept-grid">
|
||
{% for b in kept %}
|
||
<article class="card card-kept">
|
||
<a class="thumb" href="/b/{{ b.name_url }}/">
|
||
{% if b.thumb_url %}
|
||
{# A cover blurred inside the booth must be blurred here too, or the
|
||
front page undoes the censoring the booth page applied. #}
|
||
<img class="{{ 'blurred-thumb' if b.thumb_blurred }}" loading="lazy"
|
||
src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
|
||
{% elif b.has_index %}
|
||
<div class="ph">▦ page</div>
|
||
{% elif b.kinds.video %}
|
||
<div class="ph">▶ video</div>
|
||
{% elif b.kinds.audio %}
|
||
<div class="ph">♪ audio</div>
|
||
{% else %}
|
||
<div class="ph">◆ files</div>
|
||
{% endif %}
|
||
<span class="badge badge-kept">★ kept</span>
|
||
</a>
|
||
<div class="meta">
|
||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · kept · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||
{{ provenance(b.manifest) }}
|
||
</div>
|
||
{# There IS a × here now (operator, 2026-09-21). The old rule was
|
||
release-then-find-it-in-the-other-lane, on the theory that two
|
||
deliberate acts protect durable boards. In practice it protects
|
||
nothing and costs a hunt: the board you just released is loose in a
|
||
feed that turns over, and you have to go find it to finish the job
|
||
you had already decided on.
|
||
|
||
The protection now lives in the CONFIRMATION, not in the number of
|
||
lanes you must traverse — this one names the booth and says the word
|
||
KEPT, where the ephemeral × just asks. A deliberate act, one click,
|
||
reachable.
|
||
|
||
Release still exists and is still the reversible option. Note it
|
||
BUMPS the directory mtime, so the board's age resets and it survives
|
||
another full TTL — unkeep-and-wait is a 24h delay, not a delete,
|
||
which is exactly why a direct × was worth adding. #}
|
||
{# ⚠ BOTH OF THESE WERE position:absolute ON THE SAME CORNER, and `release`
|
||
is the later sibling, so it painted over the × completely: measured
|
||
30x22 px of overlap on a 30px button, and elementFromPoint at the ×'s
|
||
centre returned the release form. The × was unclickable from the day
|
||
it shipped.
|
||
|
||
One flex row, positioned once, instead of two independently guessed
|
||
offsets — so neither control can drift back on top of the other when
|
||
a label changes width. #}
|
||
<div class="kept-actions">
|
||
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
|
||
onsubmit="return confirm('Release \u201c{{ b.name }}\u201d?\n\nIt moves to the ephemeral lane so you can wipe it from there. Nothing is deleted by this step.')">
|
||
<button title="release this board so it can be wiped">release</button>
|
||
</form>
|
||
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
|
||
onsubmit="return confirm('WIPE the KEPT booth \u201c{{ b.name }}\u201d?\n\nThis deletes it and its files immediately. Kept booths are the ones nothing else will clean up, so nobody else is going to do this for you — and nothing brings it back.')">
|
||
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
|
||
</form>
|
||
</div>
|
||
</article>
|
||
{% endfor %}
|
||
</div>
|
||
{% if booths %}<h2 class="lane-head">Ephemeral <span class="lane-note">· wiped {{ ttl_hours }}h after last activity</span></h2>{% endif %}
|
||
{% endif %}
|
||
|
||
{% if not booths %}
|
||
{% if not kept %}
|
||
<div class="empty">
|
||
No booths yet. Upload files above, or drop a folder into <code>{{ data_dir }}</code>.
|
||
</div>
|
||
{% endif %}
|
||
{% else %}
|
||
<div class="grid">
|
||
{% for b in booths %}
|
||
<article class="card">
|
||
<a class="thumb" href="/b/{{ b.name_url }}/">
|
||
{% if b.thumb_url %}
|
||
<img class="{{ 'blurred-thumb' if b.thumb_blurred }}" loading="lazy"
|
||
src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
|
||
{% elif b.has_index %}
|
||
<div class="ph">▦ page</div>
|
||
{% elif b.kinds.video %}
|
||
<div class="ph">▶ video</div>
|
||
{% elif b.kinds.audio %}
|
||
<div class="ph">♪ audio</div>
|
||
{% else %}
|
||
<div class="ph">◆ files</div>
|
||
{% endif %}
|
||
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
|
||
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>{% endif %}
|
||
</a>
|
||
<div class="meta">
|
||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · expires in {{ b.expires_in|dur }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||
{{ provenance(b.manifest) }}
|
||
</div>
|
||
{# Promote to the kept lane. The /keep route and the `booth keep` CLI verb
|
||
both predate this button; until 2026-09-19 the UI could only RELEASE a
|
||
kept booth, never keep an ephemeral one, so the round trip was only
|
||
closed if you had a shell. Reversible, so no confirmation — the × next
|
||
to it is the destructive one and keeps its prompt. #}
|
||
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep">
|
||
<button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button>
|
||
</form>
|
||
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
|
||
onsubmit="return confirm('Wipe booth “{{ b.name }}”?')">
|
||
<button title="wipe now" aria-label="wipe booth">×</button>
|
||
</form>
|
||
</article>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
<script>
|
||
/* progressive enhancement: reflect chosen files + drag-drop onto the panel.
|
||
With JS off, the native file input + submit still works. */
|
||
(function () {
|
||
var input = document.getElementById('booth-files');
|
||
var sub = document.getElementById('drop-sub');
|
||
var drop = document.querySelector('.drop');
|
||
if (!input) return;
|
||
function show() {
|
||
var n = input.files ? input.files.length : 0;
|
||
if (n) {
|
||
sub.textContent = n + ' file' + (n > 1 ? 's' : '') + ' ready — hit “Get pickup id”';
|
||
drop.classList.add('has');
|
||
}
|
||
}
|
||
input.addEventListener('change', show);
|
||
['dragover', 'dragenter'].forEach(function (e) {
|
||
drop.addEventListener(e, function (ev) { ev.preventDefault(); drop.classList.add('over'); });
|
||
});
|
||
['dragleave', 'drop'].forEach(function (e) {
|
||
drop.addEventListener(e, function (ev) { ev.preventDefault(); drop.classList.remove('over'); });
|
||
});
|
||
drop.addEventListener('drop', function (ev) {
|
||
if (ev.dataTransfer && ev.dataTransfer.files.length) {
|
||
try { input.files = ev.dataTransfer.files; } catch (_) {}
|
||
show();
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
{% endblock %}
|