feat(r2): C4 the Desk — the index triaged by what needs the operator

- list_booths gains open_since (parsed, never compared as text), flags,
  landed_at (content only; a new, differently named clock, INV-5),
  viewed_at, and a four-image preview that keeps blur.
- The index renders needs you / new since you looked / everything else,
  always in that order. Needs you includes unreadable marks, so a damaged
  judgment file cannot hide. Everything else keeps list_booths' order
  rather than stating a second rule. An empty section renders nothing.
- The side column holds live benches (a damaged registry says so),
  bookmarks from BOOTH_LINKS_BOARD with booth URLs left out (capped at 8),
  and the pickup form.
- test_booth's kept-lane test is rewritten as the contract declared: kept
  is a fact on each row, not a lane.

Two of the new tests were VACUOUS on their first draft, and mutation-
checking caught both. The clocks test used a future t0, so a hand-set
marker outranked every real write. The look-then-judge test followed the
flag's 303, and the resulting GET recorded a fresh look. Both are fixed
and now go red under their mutation.
This commit is contained in:
vh
2026-09-23 08:45:54 -07:00
parent b9750d221a
commit ce27b06f32
6 changed files with 545 additions and 162 deletions
+111 -7
View File
@@ -41,11 +41,13 @@ import os
import re
import secrets
import shutil
import stat
import tempfile
import time
import zipfile
from contextlib import asynccontextmanager
from dataclasses import replace
from datetime import datetime, timezone
from pathlib import Path
from typing import Sequence
from urllib.parse import quote, unquote
@@ -254,6 +256,56 @@ def _newest_mtime(path: Path) -> float:
return newest
def _content_mtime(path: Path) -> float:
"""`landed_at` (R2 C4): the newest mtime among the booth's CONTENT — regular
files with no dot-component in their path. Deliberately NOT `_newest_mtime`
(INV-5 of r2): a mark, a view, a blur or a keep is activity, never new
content, so none of them may make a booth read as newly landed.
Files only, never directories: creating `.viewed` bumps the booth
directory's own mtime, and counting that would make the first look at a
booth look like a delivery. An empty booth landed at 0.0. Unknowable reads
as NOW, the posture `_newest_mtime` takes and for a milder reason here: a
booth we cannot read is shown as new rather than hidden as old.
"""
newest = 0.0
try:
for p in path.rglob("*"):
rel = p.relative_to(path)
if any(part.startswith(".") for part in rel.parts):
continue
try:
st = p.stat()
except FileNotFoundError:
continue
if stat.S_ISREG(st.st_mode) and st.st_mtime > newest:
newest = st.st_mtime
except OSError:
return time.time()
return newest
def _viewed_at(path: Path) -> float | None:
"""The mtime of the booth's `.viewed` marker (U4), or None if it has never
been looked at. `lstat`, like `is_kept`: a planted symlink is read as the
marker it claims to be, never followed."""
try:
return os.lstat(path / VIEW_MARKER).st_mtime
except OSError:
return None
def _stamp(created: str) -> datetime | None:
"""A mark's `created` as an aware datetime, or None when it will not parse.
Strings are never compared: two ISO stamps with different offsets sort
wrong as text. A naive stamp is read as UTC."""
try:
dt = datetime.fromisoformat(created)
except (TypeError, ValueError):
return None
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def booth_age_seconds(path: Path, now: float | None = None) -> float:
now = time.time() if now is None else now
return now - _newest_mtime(path)
@@ -384,6 +436,9 @@ def wants_json(accept: str | None) -> bool:
return False
# The Desk shows this many bookmarks and links to the board for the rest.
BOOKMARKS_SHOWN = 8
HOLD_UNREADABLE = "unreadable"
HOLD_OPEN = "open"
@@ -529,6 +584,10 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
# rather than re-opening .blurred here.
thumb_blurred = it.blurred
mtime = _newest_mtime(child)
# R2 C4: the oldest question still owed an answer, PARSED. Unparseable
# stamps are left out, so a booth whose every open pick is unparseable
# has no `open_since` and sorts after every booth that has one.
stamps = [st for st in (_stamp(m.created) for m in open_marks(marks)) if st]
booths.append(
{
"name": child.name,
@@ -555,6 +614,17 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
"hold": hold,
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
"mtime": mtime,
# ---- R2 C4, the Desk. All from the pass above; no second read.
"open_since": min(stamps) if stamps else None,
"flags": sum(1 for m in marks if m.shape == "flag"),
# Two clocks, named apart (INV-5): `mtime` is activity,
# `landed_at` is content. "New since you looked" reads only the
# second, so a flag or a view never makes a booth look new.
"landed_at": _content_mtime(child),
"viewed_at": _viewed_at(child),
# The first four images in item order, as the originals shown
# small. Blurred ones stay blurred, the cover's rule.
"preview": [(it.url, it.blurred) for it in items if it.kind == "image"][:4],
}
)
# Newest first, NAME as the tie-break. Sorting on mtime alone left equal-mtime
@@ -779,6 +849,7 @@ def create_app(
sweep_interval_s: int = 900,
max_upload_mb: float = 1024.0,
max_files: int = 50,
links_board: str = "links",
) -> FastAPI:
data_dir = Path(data_dir).expanduser().resolve()
data_dir.mkdir(parents=True, exist_ok=True)
@@ -889,19 +960,49 @@ def create_app(
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
# Two lanes, split here rather than in the template: kept boards are a
# different KIND of thing from the ephemeral churn — durable, deliberate,
# operator-facing — and burying them in a feed that turns over daily is
# exactly how they would get lost, which is the problem they exist to
# solve. Kept renders first.
"""THE DESK (R2 C4) — the index triaged by what needs the operator.
Three sections, ALWAYS in this order, each booth in exactly one:
needs — an open pick, or marks that cannot be read (somebody has to
fix those, so they must not hide further down). Oldest open
question first; a booth with no parseable stamp after every
booth that has one; name breaks ties.
new — content landed since the booth was last looked at, or never
looked at. Newest content first; name breaks ties.
rest — everything else, in `list_booths`' own order (last activity
first, name as the tie-break). No second rule is stated.
The kept/ephemeral lanes are gone: 23 of 24 live booths were kept, so
the lanes sorted nothing. Kept status still shows on every row.
"""
everything = list_booths(data_dir, ttl_seconds)
needs = [b for b in everything
if b["marks_open"] > 0 or b["hold"] == HOLD_UNREADABLE]
needs.sort(key=lambda b: ((0, b["open_since"].timestamp())
if b["open_since"] else (1, 0.0), b["name"]))
in_needs = {b["name"] for b in needs}
new = [b for b in everything if b["name"] not in in_needs
and (b["viewed_at"] is None or b["landed_at"] > b["viewed_at"])]
new.sort(key=lambda b: (-b["landed_at"], b["name"]))
in_new = {b["name"] for b in new}
rest = [b for b in everything
if b["name"] not in in_needs and b["name"] not in in_new]
benches, benches_error = read_benches(data_dir)
board = data_dir / links_board
bookmarks = [row for row in _board_rows(board)
if booth_target(row["url"]) is None] if board.is_dir() else []
return templates.TemplateResponse(
request,
"index.html",
{
**base_ctx,
"kept": [b for b in everything if b["kept"]],
"booths": [b for b in everything if not b["kept"]],
"needs": needs,
"new": new,
"rest": rest,
"benches": [b for b in benches if b.state != "retired"],
"benches_error": benches_error,
"bookmarks": bookmarks[:BOOKMARKS_SHOWN],
"bookmarks_total": len(bookmarks),
"board_url": f"/b/{quote(links_board, safe='')}/",
},
)
@@ -1875,6 +1976,9 @@ def _from_env() -> FastAPI:
sweep_interval_s=interval,
max_upload_mb=max_mb,
max_files=max_n,
# The board the CLI's `booth link` writes (scripts/booth reads the same
# variable), so the Desk's bookmarks come from where they are written.
links_board=os.environ.get("BOOTH_LINKS_BOARD", "links"),
)
+81
View File
@@ -157,6 +157,87 @@
box-shadow:var(--shadow-sm)}
.thumb .badge+.badge-mark{top:36px}
/* ---- THE DESK (R2 C4) -------------------------------------------------
The index as triage. Sections stack in a fixed order; a row is a booth.
"Needs you" rows carry an amber inner edge — the one thing on the page
that asks to be looked at. */
.desk{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:var(--space-6);align-items:start}
@media (max-width:1000px){.desk{grid-template-columns:1fr}}
.desk-sec{margin:0 0 var(--space-6)}
.desk-head{display:flex;align-items:center;gap:10px;margin:0 0 10px;font-family:var(--font-mono);
font-size:var(--size-micro);font-weight:500;letter-spacing:var(--tracking-caps);text-transform:uppercase;
color:var(--text-muted)}
.desk-head::after{content:"";flex:1;height:1px;background:var(--border-subtle)}
.desk-head-needs{color:var(--warning-text)}
.desk-head-new{color:var(--intel-text)}
.desk-rule{font-weight:400;letter-spacing:0;text-transform:none;color:var(--text-muted)}
.desk-row{display:grid;grid-template-columns:210px minmax(0,1fr) auto;gap:var(--space-4);align-items:center;
padding:12px;margin-bottom:8px;border:1px solid var(--border-default);border-radius:var(--radius-xl);
background:var(--surface-card);box-shadow:var(--shadow-sm);
transition:border-color var(--dur-2) var(--ease-out)}
.desk-row:hover,.desk-row:focus-within{border-color:var(--border-strong)}
.desk-row.is-needs{box-shadow:inset 3px 0 0 var(--warning),var(--shadow-sm)}
.desk-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:3px;height:58px;border-radius:var(--radius-md);
overflow:hidden;background:var(--surface-sunken)}
.desk-strip img{width:100%;height:100%;object-fit:cover;display:block}
.desk-strip .ph{grid-column:1/-1;display:flex;align-items:center;justify-content:center;font-family:var(--font-mono);
font-size:var(--size-caption);letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted)}
.desk-strip:hover{text-decoration:none}
.desk-main{min-width:0}
.desk-title{display:block;font-weight:600;font-size:var(--size-h3);line-height:1.3;color:var(--text-heading);
overflow-wrap:anywhere}
.desk-title:hover{color:var(--text-link);text-decoration:none}
.desk-slug{font-family:var(--font-mono);font-size:var(--size-caption);font-weight:400;color:var(--text-muted);margin-left:6px}
.desk-main .prov{margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.desk-facts{margin-top:5px;font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)}
.desk-flags{color:var(--success-text)}
.desk-side{display:flex;flex-direction:column;align-items:flex-end;gap:8px}
.badge-new{color:var(--intel-text);background:var(--intel-soft)}
.badge-broken{color:var(--danger-text);background:var(--danger-soft);border-color:color-mix(in oklab,var(--danger) 40%,transparent)}
/* The row's keep / release / × — quiet until the row is hovered or focused,
because a destructive control must not compete with the thing you came to
read. Static here: on the old cards they floated over a thumbnail. */
.desk-acts{display:flex;gap:6px;opacity:0;transition:opacity var(--dur-1)}
.desk-row:hover .desk-acts,.desk-acts:focus-within{opacity:1}
.desk-acts form{position:static;opacity:1;margin:0}
.desk-acts button{height:28px;min-width:28px;padding:0 8px;font-size:var(--size-sm);border-radius:var(--radius-md);
border:1px solid var(--border-strong);background:var(--surface-raised);color:var(--text-body);
-webkit-backdrop-filter:none;backdrop-filter:none;cursor:pointer}
.desk-acts .release button{font-family:var(--font-mono);font-size:var(--size-caption)}
.desk-acts .wipe button:hover{background:var(--danger);border-color:var(--danger);color:var(--danger-contrast)}
.desk-acts .keepit button:hover,.desk-acts .release button:hover{background:var(--surface-overlay)}
@media (max-width:700px){
.desk-row{grid-template-columns:1fr}
.desk-side{flex-direction:row;align-items:center;justify-content:space-between}
.desk-acts{opacity:1}
}
/* the side column */
.desk-panel{border:1px solid var(--border-default);border-radius:var(--radius-xl);background:var(--surface-card);
overflow:hidden;margin-bottom:var(--space-4);box-shadow:var(--shadow-sm)}
.desk-panel-head{display:flex;align-items:baseline;gap:8px;margin:0;padding:10px 14px;background:var(--surface-raised);
border-bottom:1px solid var(--border-subtle);font-family:var(--font-mono);font-size:var(--size-micro);font-weight:500;
letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-heading)}
.desk-panel-head .desk-rule{margin-left:auto}
.desk-bench,.desk-mark{display:flex;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid var(--border-subtle);
font-size:var(--size-sm);color:var(--text-heading)}
.desk-bench:last-child,.desk-mark:last-of-type{border-bottom:0}
.desk-bench:hover,.desk-mark:hover{background:var(--surface-sunken);text-decoration:none}
.desk-mark{display:block;color:var(--text-link)}
.desk-mark.is-pinned{background:var(--surface-overlay)}
.desk-bench-dot{flex:0 0 auto;width:6px;height:6px;border-radius:var(--radius-pill);background:var(--text-muted)}
/* Device 3: a live bench is a running thing — the one row that glows. */
.desk-bench.is-live .desk-bench-dot{background:var(--accent);box-shadow:var(--glow-armed)}
.desk-bench.is-promoted .desk-bench-dot{background:var(--intel)}
.desk-bench-main{display:flex;flex-direction:column;min-width:0}
.desk-bench-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.desk-bench-sub{display:block;font-family:var(--font-mono);font-size:var(--size-micro);color:var(--text-muted)}
.desk-more{display:block;padding:9px 14px;border-top:1px solid var(--border-subtle);font-family:var(--font-mono);
font-size:var(--size-caption)}
.desk-panel .uploader{flex-direction:column;margin:0;padding:12px}
.desk-panel .drop{flex:1 1 auto;padding:14px 12px}
.desk-panel .up-go{padding:10px 14px;border-radius:var(--radius-lg)}
.desk-panel .drop.has+.up-go{padding:10px 14px;border-radius:var(--radius-lg)}
/* ---- index: lanes and cards ------------------------------------------ */
.lane-head{margin:28px 0 12px;font-family:var(--font-mono);font-size:var(--size-micro);font-weight:500;
letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted);
+153 -142
View File
@@ -1,136 +1,155 @@
{% extends "base.html" %}
{% from "_provenance.html" import provenance %}
{% from "_lifetime.html" import lifetime %}
{% 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>
{# THE DESK (R2 C4). The index triaged by what needs the operator: needs you,
then new since you looked, then everything else — always in that order, and
the ORDER WITHIN each is decided in app.index, never here. A section with no
booths renders nothing at all: no heading, no empty box (the negative half
of the kept-lane pair this replaces). #}
{% 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' }} · {{ lifetime(true, b.hold, b.expires_in) }} · <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"
data-booth="{{ b.name }}" data-confirm="release">
<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"
data-booth="{{ b.name }}" data-confirm="wipe-kept">
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
</form>
</div>
</article>
{# The first four images, the originals shown small. A blurred one stays
blurred (`blurred-thumb`, the cover's rule). A booth with no images shows the
kind placeholder the cards used to. #}
{% macro preview(b) -%}
<a class="desk-strip" href="/b/{{ b.name_url }}/" tabindex="-1" aria-hidden="true">
{% if b.preview %}
{% for url, blurred in b.preview %}
<img class="{{ 'blurred-thumb' if blurred }}" loading="lazy" src="/b/{{ b.name_url }}/{{ url }}" alt="">
{% 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>
{% elif b.has_index %}<span class="ph">▦ page</span>
{% elif b.kinds.video %}<span class="ph">▶ video</span>
{% elif b.kinds.audio %}<span class="ph">♪ audio</span>
{% else %}<span class="ph">◆ files</span>
{% 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>
{%- endmacro %}
{% macro row(b, section) -%}
<article class="desk-row{% if section == 'needs' %} is-needs{% endif %}" data-booth="{{ b.name }}" data-kept="{{ '1' if b.kept else '0' }}">
{{ preview(b) }}
<div class="desk-main">
{# The manifest title leads when there is one; the directory name stays
beside it because it is what the URL says. #}
<a class="desk-title" href="/b/{{ b.name_url }}/">
{%- if b.manifest and not b.manifest.error and b.manifest.title and b.manifest.title != b.name -%}
{{ b.manifest.title }} <span class="desk-slug">{{ b.name }}</span>
{%- else -%}{{ b.name }}{%- 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' }} · {{ lifetime(false, b.hold, b.expires_in) }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
{{ provenance(b.manifest) }}
{{ provenance(b.manifest) }}
<div class="desk-facts">
{{ b.count }} item{{ '' if b.count == 1 else 's' }}
{% if b.flags %} · <span class="desk-flags">{{ b.flags }} flagged</span>{% endif %}
· {{ lifetime(b.kept, b.hold, b.expires_in) }}
· <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>
</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"
data-booth="{{ b.name }}" data-confirm="wipe">
<button title="wipe now" aria-label="wipe booth">×</button>
</form>
</article>
{% endfor %}
</div>
<div class="desk-side">
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>
{% elif b.hold == "unreadable" %}<span class="badge badge-broken">marks unreadable</span>
{% elif section == 'new' %}<span class="badge badge-new">new</span>{% endif %}
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
<div class="desk-acts">
{# Keep / release and the ×. The confirmation text is DATA-DRIVEN: the
booth name travels as a data attribute and never reaches a JS string
(see the script below). Release is reversible, so it has no prompt of
its own beyond the wording. #}
{% if b.kept %}
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
data-booth="{{ b.name }}" data-confirm="release">
<button title="release this booth so it can be wiped">release</button>
</form>
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
data-booth="{{ b.name }}" data-confirm="wipe-kept">
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
</form>
{% else %}
<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"
data-booth="{{ b.name }}" data-confirm="wipe">
<button title="wipe now" aria-label="wipe booth">×</button>
</form>
{% endif %}
</div>
</div>
</article>
{%- endmacro %}
{% block content %}
<div class="desk">
<div class="desk-list">
{% if needs %}
<section class="desk-sec" data-section="needs">
<h2 class="desk-head desk-head-needs">Needs you <span class="desk-rule">oldest question first</span></h2>
{% for b in needs %}{{ row(b, 'needs') }}{% endfor %}
</section>
{% endif %}
{% if new %}
<section class="desk-sec" data-section="new">
<h2 class="desk-head desk-head-new">New since you looked <span class="desk-rule">newest first</span></h2>
{% for b in new %}{{ row(b, 'new') }}{% endfor %}
</section>
{% endif %}
{% if rest %}
<section class="desk-sec" data-section="rest">
<h2 class="desk-head">Everything else <span class="desk-rule">last activity first</span></h2>
{% for b in rest %}{{ row(b, 'rest') }}{% endfor %}
</section>
{% endif %}
{% if not needs and not new and not rest %}
<div class="empty">
No booths yet. Drop a folder into <code>{{ data_dir }}</code>, or upload files for pickup.
</div>
{% endif %}
</div>
{% endif %}
<aside class="desk-aside">
{# Benches: running things. DAMAGED AND ABSENT MUST NOT RENDER THE SAME —
an unreadable registry says so; an empty one renders no panel. #}
{% if benches_error %}
<section class="desk-panel" data-panel="benches">
<h2 class="desk-panel-head">Benches</h2>
<div class="bench-err">the bench registry could not be read: {{ benches_error }}</div>
</section>
{% elif benches %}
<section class="desk-panel" data-panel="benches">
<h2 class="desk-panel-head">Benches <span class="desk-rule">running things</span></h2>
{% for b in benches %}
<a class="desk-bench is-{{ b.state }}" href="{{ b.url }}" target="_blank" rel="noopener">
<span class="desk-bench-dot" aria-hidden="true"></span>
<span class="desk-bench-main"><span class="desk-bench-name">{{ b.name or b.url }}</span>
<span class="desk-bench-sub">{% if b.owner %}{{ b.owner }} · {% endif %}{{ b.state }}</span></span>
</a>
{% endfor %}
</section>
{% endif %}
{% if bookmarks %}
<section class="desk-panel" data-panel="bookmarks">
<h2 class="desk-panel-head">Bookmarks <span class="desk-rule">pinned first</span></h2>
{% for e in bookmarks %}
<a class="desk-mark{% if e.pinned %} is-pinned{% endif %}" href="{{ e.url }}" target="_blank" rel="noopener">
{{ e.desc }}{% if e.who %}<span class="desk-bench-sub">{{ e.who }}</span>{% endif %}</a>
{% endfor %}
<a class="desk-more" href="{{ board_url }}">all {{ bookmarks_total }} on the board →</a>
</section>
{% endif %}
<section class="desk-panel" data-panel="pickup">
<h2 class="desk-panel-head">Pickup</h2>
<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 · 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>
</section>
</aside>
</div>
<script>
/* progressive enhancement: reflect chosen files + drag-drop onto the panel.
@@ -162,31 +181,23 @@
});
})();
/* Destructive-action confirmation, delegated and DATA-DRIVEN.
These were an inline onsubmit calling confirm() with the booth NAME
interpolated straight into the JS string literal. Jinja's autoescape is
HTML-attribute escaping, not JS-string escaping: the browser decodes the
entity back to a quote before the JS parser ever sees it, so a booth name
crafted to close that string executed on submit. Booth names are
agent-authored — making a folder under the data dir is the whole API — so
that is a live path, not a theoretical one.
The name now travels as a DATA ATTRIBUTE, where escaping is escaping, and
never reaches a JS string literal. Same pattern the board controls already
use. With JS off the form submits without a prompt, which is what every
no-JS browser here already did. */
/* Destructive-action confirmation, delegated and DATA-DRIVEN. The booth name
travels as a data attribute, where escaping is escaping, and never reaches
a JS string literal: a booth name is agent-authored, and an inline handler
carrying one was a live injection path. With JS off the form submits
without a prompt. */
(function () {
var WORDS = {
release: function (n) {
return 'Release \u201c' + n + '\u201d?\n\nIt moves to the ephemeral lane so you '
+ 'can wipe it from there. Nothing is deleted by this step.';
return 'Release “' + n + '”?\n\nIt rejoins the sweep: it will be wiped '
+ '{{ ttl_hours|int }}h after its last activity. Nothing is deleted by this step.';
},
'wipe-kept': function (n) {
return 'WIPE the KEPT booth \u201c' + n + '\u201d?\n\nThis deletes it and its files '
return 'WIPE the KEPT booth “' + n + '”?\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 \u2014 and nothing brings it back.';
+ 'else is going to do this for you — and nothing brings it back.';
},
wipe: function (n) { return 'Wipe booth \u201c' + n + '\u201d?'; }
wipe: function (n) { return 'Wipe booth “' + n + '”?'; }
};
document.addEventListener('submit', function (ev) {
var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
+7 -4
View File
@@ -31,7 +31,7 @@ touches:
- "booth/templates/base.html (layout CSS; the in-place script)"
- "booth/templates/doc.html (NOT restructured — a doc keeps its reading page; named because it was checked)"
- "booth/static/embed.js (NOT TOUCHED — the verbatim path keeps its author's layout; requirement 6)"
- "tests/test_booth.py (TWO assertions change: L785-786, the kept-lane presence pair. 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)"
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."
@@ -220,8 +220,10 @@ rule — a second renderer in JavaScript would be the same bug in a new language
have no `open_since` and sort after every booth that has one.
2. **New since you looked** — `not in_needs_you and (viewed_at is None or
landed_at > viewed_at)`. Ordered by `(-landed_at, name)`, newest first.
3. **Everything else** — ordered by `(-mtime, name)`, where `mtime` is today's
`_newest_mtime`: last activity first.
3. **Everything else** — in `list_booths`' own existing order: `(mtime, name)`
descending, where `mtime` is today's `_newest_mtime`. That is last activity
first, with name as the tie-break (`test_the_index_order_has_a_tie_breaker`
pins it). The Desk reuses that rule rather than stating a second one.
- Flagging or viewing a booth moves it up this section. That is intended:
it is activity. It never moves the booth into (2), because (2) reads
`landed_at` (INV-5).
@@ -346,7 +348,7 @@ This applies to image, video and audio items. Docs keep `doc.html`.
| Desk sections | fixed: needs → new → everything |
| needs you | `(open_since, name)` |
| new since you looked | `(-landed_at, name)` |
| everything else | `(-mtime, name)` |
| everything else | `list_booths` order: `(mtime, name)` descending |
| bookmarks | `order_for_display` |
The notes list keeps `(created, id)`.
@@ -387,6 +389,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 L786 | `class="card card-kept"` present | replaced by the row carrying `data-kept="1"` | same |
| 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) | — |
Every other existing assertion is expected to survive, and one of the TDD
+10 -9
View File
@@ -771,7 +771,13 @@ def test_sentinel_is_not_counted_as_an_item(tmp_path):
assert booth["count"] == 1
def test_index_separates_kept_from_ephemeral(client):
def test_index_marks_kept_on_the_row_instead_of_a_lane(client):
"""R2 C4 (docs/contracts/r2_flow.contract.md, "Assertions that change").
This test used to require a kept LANE rendered before the ephemeral grid.
The Desk removed the lanes — 23 of 24 live booths were kept, so they sorted
nothing — and orders by what needs the operator instead (tested in
tests/test_flow.py). What survives is the fact: a kept booth still says it
is kept, on its own row."""
c, data = client
_touch(data / "scratch" / "a.png")
_touch(data / "links" / "a.png")
@@ -779,14 +785,9 @@ def test_index_separates_kept_from_ephemeral(client):
html = c.get("/").text
# Assert on the lane's markup, not on the word "Kept" — that string also
# appears in the stylesheet comment that is served on every page, so a bare
# substring check passes for the wrong reason.
assert 'class="grid kept-grid"' in html, "kept booths need their own lane"
assert 'class="card card-kept"' in html
# The kept lane is rendered before the ephemeral grid, so the operator sees
# durable boards first rather than hunting for them among the churn.
assert html.index("links") < html.index("scratch")
assert 'data-booth="links" data-kept="1"' in html
assert 'data-booth="scratch" data-kept="0"' in html
assert 'class="grid kept-grid"' not in html, "no lane: kept is a fact, not a grouping"
def test_kept_booth_shows_kept_instead_of_a_countdown(client):
+183
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import pathlib
import re
import time
import sys
import pytest
@@ -224,3 +225,185 @@ def test_back_view_lands_on_the_review_only_for_a_media_item(tmp_path, f, landin
r = c.post("/b/g/flag", data={"target": "b b.png", "on": "1", "back": "view", "f": f})
assert r.status_code == 303
assert r.headers["location"] == landing
# ---- C4: the Desk -------------------------------------------------------------
def _at(path: pathlib.Path, t: float) -> None:
import os
os.utime(path, (t, t))
def _desk(body: str) -> dict[str, list[str]]:
"""section -> the booths it renders, in render order."""
out = {}
for sec, inner in re.findall(r'<section class="desk-sec"[^>]*data-section="(\w+)"[^>]*>(.*?)</section>',
body, re.S):
out[sec] = re.findall(r'<article class="desk-row[^"]*" data-booth="([^"]+)"', inner)
return out
def test_the_desk_triages_needs_you_then_new_then_everything_else(tmp_path):
"""The tracer for C4: three sections, always in this order, each booth in
exactly one of them."""
from booth.marks import declare_pick
t0 = time.time() - 10_000 # in the PAST: a future stamp outranks every real write and hides the bug
asks = _booth(tmp_path, "asks", {"a.png": PNG})
declare_pick(asks, "q", {"prompt": "Which?", "options": ["x", "y"]})
_booth(tmp_path, "fresh", {"a.png": PNG}) # never looked at
seen = _booth(tmp_path, "seen", {"a.png": PNG})
_at(seen / "a.png", t0)
(seen / ".viewed").write_bytes(b"")
_at(seen / ".viewed", t0 + 60) # looked AFTER it landed
body = _client(tmp_path).get("/").text
assert _desk(body) == {"needs": ["asks"], "new": ["fresh"], "rest": ["seen"]}
assert body.index('data-section="needs"') < body.index('data-section="new"') \
< body.index('data-section="rest"')
def _set_created(booth: pathlib.Path, mark_id: str, created: str) -> None:
import json
doc = json.loads((booth / ".marks.json").read_text())
for m in doc["marks"]:
if m["id"] == mark_id:
m["created"] = created
(booth / ".marks.json").write_text(json.dumps(doc))
def test_needs_you_orders_by_the_parsed_stamp_not_the_string(tmp_path):
"""`created` is a string. As text, 11:00-07:00 sorts before 12:30-05:00;
as time it is 18:00Z against 17:30Z, so the second question is OLDER and
leads. An unparseable stamp, and a booth whose marks cannot be read, sort
after every parseable one; name breaks the tie."""
from booth.marks import declare_pick
for n in ("alpha", "bravo", "charlie", "delta"):
b = _booth(tmp_path, n, {"a.png": PNG})
if n != "delta":
declare_pick(b, "q", {"prompt": "?", "options": ["x", "y"]})
_set_created(tmp_path / "alpha", "q", "2026-09-22T11:00:00-07:00")
_set_created(tmp_path / "bravo", "q", "2026-09-22T12:30:00-05:00")
_set_created(tmp_path / "charlie", "q", "last tuesday")
(tmp_path / "delta" / ".marks.json").write_text("{not json")
body = _client(tmp_path).get("/").text
assert _desk(body)["needs"] == ["bravo", "alpha", "charlie", "delta"]
assert "marks unreadable" in body
def test_flags_and_notes_alone_do_not_make_a_booth_need_you(tmp_path):
"""Needs-you means a question TO the operator. Flags and notes are the
operator's own judgment."""
from booth.marks import write_note
b = _booth(tmp_path, "judged", {"a.png": PNG})
set_flag(b, "a.png", True)
write_note(b, None, "done here")
assert "needs" not in _desk(_client(tmp_path).get("/").text)
def test_new_since_you_looked_reads_content_not_activity(tmp_path):
"""INV-5, the two clocks. A flag made after the last look is ACTIVITY and
must not make a booth look new; a file landed after the last look is
CONTENT and must. Newest content first."""
t0 = time.time() - 10_000 # in the PAST: a future stamp outranks every real write and hides the bug
judged = _booth(tmp_path, "judged", {"a.png": PNG})
delivered = _booth(tmp_path, "delivered", {"a.png": PNG})
later = _booth(tmp_path, "later", {"a.png": PNG})
for b in (judged, delivered, later):
_at(b / "a.png", t0)
(b / ".viewed").write_bytes(b"")
_at(b / ".viewed", t0 + 10)
set_flag(judged, "a.png", True) # activity after the look
(delivered / "b.png").write_bytes(PNG)
_at(delivered / "b.png", t0 + 20) # content after the look
(later / "b.png").write_bytes(PNG)
_at(later / "b.png", t0 + 30) # ...and later still
desk = _desk(_client(tmp_path).get("/").text)
assert desk["new"] == ["later", "delivered"]
assert desk["rest"] == ["judged"]
def test_an_empty_section_renders_nothing_and_a_full_one_renders(tmp_path):
"""The negative half of the kept-lane pair, carried forward: a section with
no booths has no heading and no box. Checked against the element, never a
bare word the stylesheet also contains."""
c = _client(tmp_path)
body = c.get("/").text
for sec in ("needs", "new", "rest"):
assert f'data-section="{sec}"' not in body
assert 'data-panel="benches"' not in body and 'data-panel="bookmarks"' not in body
_booth(tmp_path, "fresh", {"a.png": PNG})
body = c.get("/").text
assert 'data-section="new"' in body
assert 'data-section="needs"' not in body and 'data-section="rest"' not in body
def test_a_look_then_a_judgment_leaves_the_booth_out_of_new(tmp_path):
"""Through the routes, not hand-set markers. Every Booth write that CREATES
a dotfile — `.viewed`, the marks file's temp-and-replace — bumps the booth
DIRECTORY's mtime. A `landed_at` that read the directory would make the
flag you set after looking read as a fresh delivery."""
t0 = time.time() - 10_000 # in the PAST: a future stamp outranks every real write and hides the bug
b = _booth(tmp_path, "g", {"a.png": PNG})
_at(b / "a.png", t0)
_at(b, t0)
c = _client(tmp_path)
assert _desk(c.get("/").text) == {"new": ["g"]}
c.get("/b/g/")
time.sleep(0.02)
# Not following the 303: following it GETs the booth page, which records a
# fresh look and would hide the defect this pins. A session writing a mark
# from the CLI never looks at the page at all.
c.post("/b/g/flag", data={"target": "a.png", "on": "1"}, follow_redirects=False)
assert _desk(c.get("/").text) == {"rest": ["g"]}
def _link(desc: str, url: str, who: str = "x-dev") -> str:
return f"- [{desc}]({url}) <sub>· {who} · 2026-09-01 10:00</sub>\n"
def test_the_side_column_shows_live_benches_and_non_booth_bookmarks(tmp_path):
"""Benches: non-retired, registry order. Bookmarks: the board the CLI
writes, booth URLs left out (a booth announces itself on the Desk), pinned
first then newest, capped at eight with the way to the rest."""
from booth.benches import set_bench_state, upsert_bench
upsert_bench(tmp_path, "http://h:1/", "live one", "a-dev")
retired, _ = upsert_bench(tmp_path, "http://h:2/", "old one", "a-dev")
set_bench_state(tmp_path, retired.id, "retired")
rows = "".join(_link(f"ref {n}", f"http://ref/{n}") for n in range(10))
rows += _link("a booth", "http://10.0.0.1:8090/b/somebooth/")
board = _booth(tmp_path, "links", {"links.md": rows.encode()})
(board / ".forever").write_bytes(b"")
body = _client(tmp_path).get("/").text
benches = re.search(r'data-panel="benches".*?</section>', body, re.S).group(0)
assert "live one" in benches and "old one" not in benches
marks = re.search(r'data-panel="bookmarks".*?</section>', body, re.S).group(0)
shown = re.findall(r'class="desk-mark[^"]*" href="([^"]+)"', marks)
assert shown == [f"http://ref/{n}" for n in (9, 8, 7, 6, 5, 4, 3, 2)] # newest first, 8
assert "all 10 on the board" in marks
def test_a_damaged_bench_registry_says_so_rather_than_rendering_empty(tmp_path):
(tmp_path / ".benches.json").write_text("{broken")
body = _client(tmp_path).get("/").text
panel = re.search(r'data-panel="benches".*?</section>', body, re.S)
assert panel and "could not be read" in panel.group(0)
def test_a_row_previews_four_images_keeps_blur_and_counts_flags(tmp_path):
"""The originals shown small (no generated thumbnail), the first four in
item order, a blurred one still blurred. The flag count is on the row."""
from booth.app import set_blurred
b = _booth(tmp_path, "g", {f"{n}.png": PNG for n in "abcde"})
set_blurred(b, "b.png", True)
set_flag(b, "c.png", True)
set_flag(b, "e.png", True)
body = _client(tmp_path).get("/").text
row = re.search(r'<article class="desk-row[^"]*" data-booth="g".*?</article>', body, re.S).group(0)
imgs = re.findall(r'<img class="([^"]*)" loading="lazy" src="/b/g/([^"]+)"', row)
assert imgs == [("", "a.png"), ("blurred-thumb", "b.png"), ("", "c.png"), ("", "d.png")]
assert "2 flagged" in row
def test_a_booth_without_images_shows_its_kind_instead(tmp_path):
_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)
assert "♪ audio" in row and "<img" not in row