diff --git a/booth/app.py b/booth/app.py index 098a1f5..b48db1f 100644 --- a/booth/app.py +++ b/booth/app.py @@ -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"), ) diff --git a/booth/templates/base.html b/booth/templates/base.html index d334641..da1b13d 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -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); diff --git a/booth/templates/index.html b/booth/templates/index.html index b56647c..424f040 100644 --- a/booth/templates/index.html +++ b/booth/templates/index.html @@ -1,136 +1,155 @@ {% extends "base.html" %} {% from "_provenance.html" import provenance %} {% from "_lifetime.html" import lifetime %} -{% block content %} -
+{# 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. #} -{{ keep_marker }}{{ data_dir }}.
- {{ data_dir }}, or upload files for pickup.
+