Files
booth/booth/templates/booth.html
T
vh c3a97c1b64 feat(u4): a booth's lifetime is derived from its state, not from a boolean
`.forever` was the only way to say three different things — "this is durable",
"I have not answered yet", "I am still looking" — and the census said it was
carrying all three: 17 of 24 live booths (70%, up from 54% the day before).
Three of the four booths in the fleet awaiting an answer had been pinned by
hand as well, and 10 of the 17 were younger than the TTL, so the sentinel had
bought them nothing and was pressed pre-emptively.

Only the first meaning is what `keep` means. The other two are facts the
service already held and did not consult.

    KEPT       `.forever` present                      never swept  (unchanged)
    HELD       an open pick, or marks we cannot read   never swept  (new)
    EPHEMERAL  everything else                         24h          (unchanged)

Viewing is activity: a deliberately-served response from a booth's own page
route writes `.viewed`, which is a dotfile and not a `.lock` dotfile, so
`_newest_mtime` already counts it. There is no new arithmetic — `booth_age_seconds`,
`is_expired` and `expires_in` are unchanged. Machine reads are excluded on
purpose: an agent must not be able to hold its own booth open by polling for
the answer it is waiting on.

The hold is unbounded, and what makes that safe is visibility plus two exits
that already existed. Every surface whose chrome the Booth owns says
`held until answered` where the countdown was, and `booth rm` / the UI x /
`DELETE /b/<n>` take a held booth exactly as they take a kept one. A hold is
protection from the timer, never from the operator.

Three cross-frontier panels ran and each found a class the others could not:

  * the paraphrase panel found that two reads of one file are not one read of
    one state — the contract's `is_held(marks_for(c), read_error(c))` could
    resolve to `([], None)`, the pair that deletes. `hold_read` is one read.
  * the code-review panel found, 4-of-4, that the booth header's board branch
    rendered no lifetime at all; and that five of seven invariant tests passed
    under the change that defeats them.
  * the bug-hunt panel found four more paths where a failed read still
    authorized a delete, and a `record_view` that followed a planted symlink.

`is_held` became `hold_reason`, which returns the reason rather than a bool
beside a string that can disagree with it.

Prediction, to re-count on or after 2026-10-06: the `.forever` rate falls to
the booths that are genuinely durable references. Only 4 booths carry marks at
all, so this rests on both halves of the unit; a null result cannot distinguish
a wrong diagnosis from a habit that outlived its need.

406 tests (341 before). Contract: docs/contracts/u4_derived_lifetime.contract.md
2026-09-22 09:44:25 -07:00

388 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% from "_provenance.html" import provenance %}
{% from "_lifetime.html" import lifetime %}
{# The blur toggle, defined ONCE. There are three item branches in this file
(doc / media / other) and the first cut of this feature patched only one of
them, so docs rendered with no control at all. A macro makes "patched two of
three" impossible rather than merely unlikely. #}
{% macro blurtoggle(name_url, it, cls='') -%}
<form class="blurtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/blur">
<input type="hidden" name="f" value="{{ it.name }}">
<input type="hidden" name="on" value="{{ '0' if it.blurred else '1' }}">
<button title="{{ 'un-blur this item' if it.blurred else 'blur this item — cosmetic only, the file is still served' }}"
aria-label="{{ 'un-blur' if it.blurred else 'blur' }} {{ it.name }}"
>{{ '◉ blurred' if it.blurred else '◌ blur' }}</button>
</form>
{%- endmacro %}
{# The per-item MARK controls: flag (the operator pointing at this one) and a
note field. Same macro discipline as blurtoggle above — three item branches,
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">
<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' }}"
aria-label="{{ 'un-flag' if flagged else 'flag' }} {{ it.name }}"
>{{ '✔ flagged' if flagged else '○ flag' }}</button>
</form>
{%- endmacro %}
{# An item's notes, rendered BESIDE the artifact — the 2026-09-09 ruling that a
judgment belongs with the thing it is about, applied to notes as well as
picks. The add-field is a <details> so 270 tiles do not each carry an open
textarea. #}
{% macro marknotes(name_url, it, marks) -%}
{% 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">
<input type="hidden" name="mark" value="{{ m.id }}">
<button class="mark-x" title="withdraw this note">×</button>
</form>
</div>
{% endfor %}
<details class="item-addnote">
<summary>+ note</summary>
<form method="post" action="/b/{{ name_url }}/note">
<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>
</form>
</details>
{%- endmacro %}
{% block title %}{{ name }} · The Booth{% endblock %}
{% block content %}
<div class="boothhead">
<a class="back" href="/">‹ all booths</a>
{# The manifest's TITLE is the display name; the directory name stays visible
beside it because that is the identity the operator navigates by and refers
to positionally, and losing it would be losing the thing the URL says.
Index cards keep the directory name alone for the same reason. #}
{% if manifest and not manifest.error and manifest.title and manifest.title != name %}
<h1>{{ manifest.title }} <span class="h1-slug">{{ name }}</span></h1>
{% else %}
<h1>{{ name }}</h1>
{% endif %}
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %}</span>
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
{{ provenance(manifest) }}
{# A durable multi-writer board gets no one-click wipe — same rule as the
kept lane on the index. Remove rows with the per-row ×, or release the
board from the index and wipe it from there. #}
{# Promote or release without going back to the index. `next` keeps you on
this page instead of bouncing you to /. #}
{% if kept %}
<form class="keep-lg" method="post" action="/b/{{ name_url }}/unkeep">
<input type="hidden" name="next" value="/b/{{ name_url }}/">
<button title="release — rejoins the TTL sweep">★ kept — release</button>
</form>
{% else %}
<form class="keep-lg" method="post" action="/b/{{ name_url }}/keep">
<input type="hidden" name="next" value="/b/{{ name_url }}/">
<button title="keep — exempt from the TTL sweep">☆ keep</button>
</form>
{% endif %}
{% if not board %}
<form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete"
onsubmit="return confirm('Wipe this booth now?')">
<button>Wipe now</button>
</form>
{% endif %}
</div>
{% if uploaded %}
<div class="pickup-note">
📦 Pickup <code>{{ name }}</code>
<button type="button" class="copy-btn" data-copy="{{ name }}" title="copy id to clipboard">⧉ copy</button>
— download files below, or on nh3-dev grab <code>~/booth-data/{{ name }}/</code>
</div>
{% endif %}
{# The marks panel: the session's questions, the operator's notes, and the way
back to the flagged items. Always rendered on a gallery booth — the add-note
field is a control, not a result, so it has to be there before the first
mark exists. #}
{# `marks or not board`: the standing link board renders as a board rather than
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 %}
{% include "_marks.html" %}
{% endif %}
{% if board %}
{# THE STANDING LINK BOARD. Every agent session on the fleet appends here, so
this is the one booth where the useful granularity is the ROW, not the
folder. Rendered as real UI rather than a markdown blob so a dead link can
be removed without hand-editing the file — and so provenance (who posted
it, when) is readable at a glance, which is the whole reason a bare URL
three days old is useless.
ORDER: pinned rows first, then newest-first (order_for_display). Pin a row
with the ★ so the ones you care about stop scrolling off the bottom.
ONE <form>, not one-per-row: checkboxes drive the bulk delete, while the
per-row × and ★ are submit buttons with their own `formaction`. That keeps
all three actions in a single form (nested forms are invalid HTML) AND lets
every one work with JS off — JS only adds select-all and the live count.
Every action posts a CONTENT ID, never a row number: another session can
append between this page rendering and a click, and an index would then hit
a neighbour. An id matches the row the operator saw, or nothing. #}
{% set pinned_n = board | selectattr('pinned') | list | length %}
<form class="board" method="post" action="/b/{{ name_url }}/unlink-many" id="boardform">
<div class="board-head">
<label class="board-selall" title="select all"><input type="checkbox" id="board-selall"></label>
<span class="board-title">{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if pinned_n %} · {{ pinned_n }} pinned{% endif %}</span>
<span class="board-note">pinned first · newest on top · ★ pins a row · tick rows to delete</span>
<span class="board-spacer"></span>
<button type="submit" class="board-del-sel" id="board-del-sel"
formaction="/b/{{ name_url }}/unlink-many">🗑 delete <span id="board-selcount">0</span></button>
</div>
{% for e in board %}
<div class="board-row{% if e.pinned %} is-pinned{% endif %}">
<input class="board-check" type="checkbox" name="sel" value="{{ e.id }}" aria-label="select {{ e.desc }}">
<button type="submit" class="board-pin{% if e.pinned %} on{% endif %}" formaction="/b/{{ name_url }}/pin"
name="entry" value="{{ e.id }}" aria-pressed="{{ 'true' if e.pinned else 'false' }}"
title="{{ 'unpin' if e.pinned else 'pin to top' }}">{{ '★' if e.pinned else '☆' }}</button>
<div class="board-main">
<a class="board-link" href="{{ e.url }}" target="_blank" rel="noopener">{{ e.desc }}</a>
<div class="board-url">{{ e.url }}</div>
</div>
<div class="board-meta">
{% if e.who %}<span class="board-who">{{ e.who }}</span>{% endif %}
{% if e.when %}<span class="board-when">{{ e.when }}</span>{% endif %}
</div>
<button type="button" class="copy-btn board-copy" data-copy="{{ e.url }}" title="copy URL">⧉</button>
<button type="submit" class="board-rm-btn" formaction="/b/{{ name_url }}/unlink"
name="entry" value="{{ e.id }}" title="remove this link"
data-desc="{{ e.desc }}" data-url="{{ e.url }}">×</button>
</div>
{% endfor %}
</form>
{% endif %}
{% if not items and not board and not marks %}
<div class="empty">This booth is empty.</div>
{% elif items %}
{# `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. #}
<div class="gallery">
{% for it in items %}
{% 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.name }}">
{% 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
the doc branch leaves the most readable content unblurred. Missed on
the first pass; caught by a live check, not by the suite. #}
<button type="button" class="reveal" aria-label="reveal {{ it.name }}">👁 reveal</button>
{% endif %}
<details class="doc-inline" open>
<summary class="doc-bar">
<span class="doc-chevron" aria-hidden="true">▸</span>
<span class="doc-name">{{ it.name }}</span>
<span class="doc-spacer"></span>
<a class="doc-act" href="view?f={{ it.url }}" title="open full page">⤢</a>
<a class="doc-act" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
{{ blurtoggle(name_url, it, 'doc-act') }}
{{ markcontrols(name_url, it, item_marks.get(it.name, []), 'doc-act') }}
<button type="button" class="doc-act doc-close" title="close (hide for now)" aria-label="close">✕</button>
</summary>
{% if it.rendered_html %}
<article class="markdown-body doc-body">{{ it.rendered|safe }}</article>
{% else %}
<pre class="textview doc-body">{{ it.rendered }}</pre>
{% endif %}
{# The doc branch had `markcontrols` and not `marknotes`, so the
operator could point at a report and not write down why — on the
one item kind whose whole content is prose. Exactly the
"patched two of three" failure the blurtoggle macro above was
written to prevent, recurring on the macro written to prevent it. #}
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
</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.name }}">
{% if it.blurred %}
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
a reload re-hides it. No-JS degrades to STAYS BLURRED, which is the
safe direction to fail in. #}
<button type="button" class="reveal" aria-label="reveal {{ it.name }}">👁 reveal</button>
{% endif %}
{% if it.kind == 'image' %}
<a href="view?f={{ it.url }}"><img loading="lazy" src="{{ it.url }}" alt="{{ it.name }}"></a>
{% elif it.kind == 'video' %}
{# preload="none": a booth of a dozen webms was fetching them
all at page load ("metadata" still pulls real ranges per
file); nothing loads until the viewer hits play #}
<video controls preload="none" src="{{ it.url }}"></video>
{% elif it.kind == 'audio' %}
<audio controls preload="none" src="{{ it.url }}"></audio>
{% elif it.doc %}
{# a doc too large to inline (over DOC_MAX_BYTES) still links out #}
<a class="dl doc" href="view?f={{ it.url }}" title="view {{ it.name }}">📄 {{ it.name }}</a>
{% else %}
<a class="dl" href="{{ it.url }}" download>⬇ {{ it.name }}</a>
{% endif %}
{% if it.kind == 'other' %}
{# Always render the caption row now: it carries the blur toggle, and
"no caption" is not a reason to deny an item its controls. #}
<figcaption>
{% if it.caption %}<span class="cap-text">{{ it.caption }}</span>{% endif %}
{{ blurtoggle(name_url, it) }}
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
</figcaption>
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
{% else %}
<figcaption>
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
<span class="cap-text">{{ it.caption or it.name }}</span>
{{ blurtoggle(name_url, it) }}
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
</figcaption>
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
{% endif %}
</figure>
{% endif %}
{% endfor %}
</div>
{% endif %}
<script>
/* Copy-to-clipboard for any .copy-btn[data-copy]. The Booth serves over plain
HTTP on a LAN IP, where navigator.clipboard is undefined (secure-context
only) — so fall back to a hidden-textarea execCommand('copy'). */
(function () {
function copyText(t) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(t);
}
var ta = document.createElement('textarea');
ta.value = t;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.top = '-1000px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (e) {}
document.body.removeChild(ta);
return Promise.resolve();
}
document.querySelectorAll('.copy-btn').forEach(function (btn) {
var label = btn.textContent;
btn.addEventListener('click', function () {
copyText(btn.getAttribute('data-copy')).then(function () {
btn.classList.add('copied');
btn.textContent = '✓ copied';
setTimeout(function () { btn.classList.remove('copied'); btn.textContent = label; }, 1300);
});
});
});
})();
/* 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 () {
/* 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-close').forEach(function (btn) {
btn.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
var item = btn.closest('.item-doc');
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();
var fig = btn.closest('.item');
var on = fig.classList.toggle('revealed');
btn.textContent = on ? '🙈 hide' : '👁 reveal';
});
});
</script>
{% endblock %}