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:
+111
-7
@@ -41,11 +41,13 @@ import os
|
|||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
|
import stat
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
from urllib.parse import quote, unquote
|
from urllib.parse import quote, unquote
|
||||||
@@ -254,6 +256,56 @@ def _newest_mtime(path: Path) -> float:
|
|||||||
return newest
|
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:
|
def booth_age_seconds(path: Path, now: float | None = None) -> float:
|
||||||
now = time.time() if now is None else now
|
now = time.time() if now is None else now
|
||||||
return now - _newest_mtime(path)
|
return now - _newest_mtime(path)
|
||||||
@@ -384,6 +436,9 @@ def wants_json(accept: str | None) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# The Desk shows this many bookmarks and links to the board for the rest.
|
||||||
|
BOOKMARKS_SHOWN = 8
|
||||||
|
|
||||||
HOLD_UNREADABLE = "unreadable"
|
HOLD_UNREADABLE = "unreadable"
|
||||||
HOLD_OPEN = "open"
|
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.
|
# rather than re-opening .blurred here.
|
||||||
thumb_blurred = it.blurred
|
thumb_blurred = it.blurred
|
||||||
mtime = _newest_mtime(child)
|
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(
|
booths.append(
|
||||||
{
|
{
|
||||||
"name": child.name,
|
"name": child.name,
|
||||||
@@ -555,6 +614,17 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
|||||||
"hold": hold,
|
"hold": hold,
|
||||||
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
|
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
|
||||||
"mtime": 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
|
# 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,
|
sweep_interval_s: int = 900,
|
||||||
max_upload_mb: float = 1024.0,
|
max_upload_mb: float = 1024.0,
|
||||||
max_files: int = 50,
|
max_files: int = 50,
|
||||||
|
links_board: str = "links",
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
data_dir = Path(data_dir).expanduser().resolve()
|
data_dir = Path(data_dir).expanduser().resolve()
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -889,19 +960,49 @@ def create_app(
|
|||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index(request: Request):
|
def index(request: Request):
|
||||||
# Two lanes, split here rather than in the template: kept boards are a
|
"""THE DESK (R2 C4) — the index triaged by what needs the operator.
|
||||||
# different KIND of thing from the ephemeral churn — durable, deliberate,
|
|
||||||
# operator-facing — and burying them in a feed that turns over daily is
|
Three sections, ALWAYS in this order, each booth in exactly one:
|
||||||
# exactly how they would get lost, which is the problem they exist to
|
needs — an open pick, or marks that cannot be read (somebody has to
|
||||||
# solve. Kept renders first.
|
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)
|
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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"index.html",
|
"index.html",
|
||||||
{
|
{
|
||||||
**base_ctx,
|
**base_ctx,
|
||||||
"kept": [b for b in everything if b["kept"]],
|
"needs": needs,
|
||||||
"booths": [b for b in everything if not b["kept"]],
|
"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,
|
sweep_interval_s=interval,
|
||||||
max_upload_mb=max_mb,
|
max_upload_mb=max_mb,
|
||||||
max_files=max_n,
|
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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,87 @@
|
|||||||
box-shadow:var(--shadow-sm)}
|
box-shadow:var(--shadow-sm)}
|
||||||
.thumb .badge+.badge-mark{top:36px}
|
.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 ------------------------------------------ */
|
/* ---- index: lanes and cards ------------------------------------------ */
|
||||||
.lane-head{margin:28px 0 12px;font-family:var(--font-mono);font-size:var(--size-micro);font-weight:500;
|
.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);
|
letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted);
|
||||||
|
|||||||
+153
-142
@@ -1,136 +1,155 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "_provenance.html" import provenance %}
|
{% from "_provenance.html" import provenance %}
|
||||||
{% from "_lifetime.html" import lifetime %}
|
{% from "_lifetime.html" import lifetime %}
|
||||||
{% block content %}
|
{# THE DESK (R2 C4). The index triaged by what needs the operator: needs you,
|
||||||
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
|
then new since you looked, then everything else — always in that order, and
|
||||||
<label class="drop" for="booth-files">
|
the ORDER WITHIN each is decided in app.index, never here. A section with no
|
||||||
<span class="drop-icon">⬆</span>
|
booths renders nothing at all: no heading, no empty box (the negative half
|
||||||
<span class="drop-main">Upload files for pickup</span>
|
of the kept-lane pair this replaces). #}
|
||||||
<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 %}
|
{# The first four images, the originals shown small. A blurred one stays
|
||||||
{# Kept boards render FIRST and look different on purpose: they are durable
|
blurred (`blurred-thumb`, the cover's rule). A booth with no images shows the
|
||||||
operator-facing things (the agent link board, standing reports) and the
|
kind placeholder the cards used to. #}
|
||||||
point of the lane is that they cannot be lost in a feed that turns over
|
{% macro preview(b) -%}
|
||||||
every day. No countdown — they have no expiry to advertise. #}
|
<a class="desk-strip" href="/b/{{ b.name_url }}/" tabindex="-1" aria-hidden="true">
|
||||||
<h2 class="lane-head">Kept <span class="lane-note">· no expiry · <code>{{ keep_marker }}</code></span></h2>
|
{% if b.preview %}
|
||||||
<div class="grid kept-grid">
|
{% for url, blurred in b.preview %}
|
||||||
{% for b in kept %}
|
<img class="{{ 'blurred-thumb' if blurred }}" loading="lazy" src="/b/{{ b.name_url }}/{{ url }}" alt="">
|
||||||
<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>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
{% elif b.has_index %}<span class="ph">▦ page</span>
|
||||||
{% if booths %}<h2 class="lane-head">Ephemeral <span class="lane-note">· wiped {{ ttl_hours }}h after last activity</span></h2>{% endif %}
|
{% elif b.kinds.video %}<span class="ph">▶ video</span>
|
||||||
{% endif %}
|
{% elif b.kinds.audio %}<span class="ph">♪ audio</span>
|
||||||
|
{% else %}<span class="ph">◆ files</span>
|
||||||
{% 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 %}
|
{% endif %}
|
||||||
{% else %}
|
</a>
|
||||||
<div class="grid">
|
{%- endmacro %}
|
||||||
{% for b in booths %}
|
|
||||||
<article class="card">
|
{% macro row(b, section) -%}
|
||||||
<a class="thumb" href="/b/{{ b.name_url }}/">
|
<article class="desk-row{% if section == 'needs' %} is-needs{% endif %}" data-booth="{{ b.name }}" data-kept="{{ '1' if b.kept else '0' }}">
|
||||||
{% if b.thumb_url %}
|
{{ preview(b) }}
|
||||||
<img class="{{ 'blurred-thumb' if b.thumb_blurred }}" loading="lazy"
|
<div class="desk-main">
|
||||||
src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
|
{# The manifest title leads when there is one; the directory name stays
|
||||||
{% elif b.has_index %}
|
beside it because it is what the URL says. #}
|
||||||
<div class="ph">▦ page</div>
|
<a class="desk-title" href="/b/{{ b.name_url }}/">
|
||||||
{% elif b.kinds.video %}
|
{%- if b.manifest and not b.manifest.error and b.manifest.title and b.manifest.title != b.name -%}
|
||||||
<div class="ph">▶ video</div>
|
{{ b.manifest.title }} <span class="desk-slug">{{ b.name }}</span>
|
||||||
{% elif b.kinds.audio %}
|
{%- else -%}{{ b.name }}{%- endif -%}
|
||||||
<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>
|
</a>
|
||||||
<div class="meta">
|
{{ provenance(b.manifest) }}
|
||||||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
<div class="desk-facts">
|
||||||
<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>
|
{{ b.count }} item{{ '' if b.count == 1 else 's' }}
|
||||||
{{ provenance(b.manifest) }}
|
{% 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>
|
</div>
|
||||||
{# Promote to the kept lane. The /keep route and the `booth keep` CLI verb
|
</div>
|
||||||
both predate this button; until 2026-09-19 the UI could only RELEASE a
|
<div class="desk-side">
|
||||||
kept booth, never keep an ephemeral one, so the round trip was only
|
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>
|
||||||
closed if you had a shell. Reversible, so no confirmation — the × next
|
{% elif b.hold == "unreadable" %}<span class="badge badge-broken">marks unreadable</span>
|
||||||
to it is the destructive one and keeps its prompt. #}
|
{% elif section == 'new' %}<span class="badge badge-new">new</span>{% endif %}
|
||||||
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep">
|
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
|
||||||
<button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button>
|
<div class="desk-acts">
|
||||||
</form>
|
{# Keep / release and the ×. The confirmation text is DATA-DRIVEN: the
|
||||||
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
|
booth name travels as a data attribute and never reaches a JS string
|
||||||
data-booth="{{ b.name }}" data-confirm="wipe">
|
(see the script below). Release is reversible, so it has no prompt of
|
||||||
<button title="wipe now" aria-label="wipe booth">×</button>
|
its own beyond the wording. #}
|
||||||
</form>
|
{% if b.kept %}
|
||||||
</article>
|
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
|
||||||
{% endfor %}
|
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>
|
</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>
|
<script>
|
||||||
/* progressive enhancement: reflect chosen files + drag-drop onto the panel.
|
/* progressive enhancement: reflect chosen files + drag-drop onto the panel.
|
||||||
@@ -162,31 +181,23 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/* Destructive-action confirmation, delegated and DATA-DRIVEN.
|
/* Destructive-action confirmation, delegated and DATA-DRIVEN. The booth name
|
||||||
These were an inline onsubmit calling confirm() with the booth NAME
|
travels as a data attribute, where escaping is escaping, and never reaches
|
||||||
interpolated straight into the JS string literal. Jinja's autoescape is
|
a JS string literal: a booth name is agent-authored, and an inline handler
|
||||||
HTML-attribute escaping, not JS-string escaping: the browser decodes the
|
carrying one was a live injection path. With JS off the form submits
|
||||||
entity back to a quote before the JS parser ever sees it, so a booth name
|
without a prompt. */
|
||||||
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. */
|
|
||||||
(function () {
|
(function () {
|
||||||
var WORDS = {
|
var WORDS = {
|
||||||
release: function (n) {
|
release: function (n) {
|
||||||
return 'Release \u201c' + n + '\u201d?\n\nIt moves to the ephemeral lane so you '
|
return 'Release “' + n + '”?\n\nIt rejoins the sweep: it will be wiped '
|
||||||
+ 'can wipe it from there. Nothing is deleted by this step.';
|
+ '{{ ttl_hours|int }}h after its last activity. Nothing is deleted by this step.';
|
||||||
},
|
},
|
||||||
'wipe-kept': function (n) {
|
'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 '
|
+ '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) {
|
document.addEventListener('submit', function (ev) {
|
||||||
var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
|
var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ touches:
|
|||||||
- "booth/templates/base.html (layout CSS; the in-place script)"
|
- "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/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)"
|
- "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)"
|
- "tests/test_flow.py (NEW)"
|
||||||
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."
|
||||||
@@ -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.
|
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
|
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.
|
landed_at > viewed_at)`. Ordered by `(-landed_at, name)`, newest first.
|
||||||
3. **Everything else** — ordered by `(-mtime, name)`, where `mtime` is today's
|
3. **Everything else** — in `list_booths`' own existing order: `(mtime, name)`
|
||||||
`_newest_mtime`: last activity first.
|
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:
|
- 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
|
it is activity. It never moves the booth into (2), because (2) reads
|
||||||
`landed_at` (INV-5).
|
`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 |
|
| Desk sections | fixed: needs → new → everything |
|
||||||
| needs you | `(open_since, name)` |
|
| needs you | `(open_since, name)` |
|
||||||
| new since you looked | `(-landed_at, name)` |
|
| new since you looked | `(-landed_at, name)` |
|
||||||
| everything else | `(-mtime, name)` |
|
| everything else | `list_booths` order: `(mtime, name)` descending |
|
||||||
| bookmarks | `order_for_display` |
|
| bookmarks | `order_for_display` |
|
||||||
|
|
||||||
The notes list keeps `(created, id)`.
|
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 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_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) | — |
|
||||||
|
|
||||||
Every other existing assertion is expected to survive, and one of the TDD
|
Every other existing assertion is expected to survive, and one of the TDD
|
||||||
|
|||||||
+10
-9
@@ -771,7 +771,13 @@ def test_sentinel_is_not_counted_as_an_item(tmp_path):
|
|||||||
assert booth["count"] == 1
|
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
|
c, data = client
|
||||||
_touch(data / "scratch" / "a.png")
|
_touch(data / "scratch" / "a.png")
|
||||||
_touch(data / "links" / "a.png")
|
_touch(data / "links" / "a.png")
|
||||||
@@ -779,14 +785,9 @@ def test_index_separates_kept_from_ephemeral(client):
|
|||||||
|
|
||||||
html = c.get("/").text
|
html = c.get("/").text
|
||||||
|
|
||||||
# Assert on the lane's markup, not on the word "Kept" — that string also
|
assert 'data-booth="links" data-kept="1"' in html
|
||||||
# appears in the stylesheet comment that is served on every page, so a bare
|
assert 'data-booth="scratch" data-kept="0"' in html
|
||||||
# substring check passes for the wrong reason.
|
assert 'class="grid kept-grid"' not in html, "no lane: kept is a fact, not a grouping"
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
def test_kept_booth_shows_kept_instead_of_a_countdown(client):
|
def test_kept_booth_shows_kept_instead_of_a_countdown(client):
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import pytest
|
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})
|
r = c.post("/b/g/flag", data={"target": "b b.png", "on": "1", "back": "view", "f": f})
|
||||||
assert r.status_code == 303
|
assert r.status_code == 303
|
||||||
assert r.headers["location"] == landing
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user