fix(r2): the heid bug-hunt panel (round "Nyx", 4/4) — triaged and folded

In-place client (base.html):
- Saves are serialized: POST, re-fetch and swap complete before the next
  save starts, so an older snapshot can no longer land after a newer one.
- A form already queued or in flight ignores another submit; a
  double-click writes one note.
- Dirty controls (drafts, unsent radio choices) and disclosures carry by
  identity (form action + hidden ask/target/mark/f + name), not position.
- Any non-tile structural difference, or a page with no region to swap,
  reloads instead of patching.

Server and templates:
- .seen is a JSON array read without following links or blocking,
  regular files of at most 1 MiB only; malformed, nested-too-deep or
  planted markers read as nothing seen.
- landed_at reads symlinks by lstat and skips one unreadable entry
  instead of pinning the booth in "new".
- The Desk counts flags on current items only; orphan flags are listed
  under the tray with an unmark form.
- Agent-written bench and bookmark URLs link only when http(s).
- Audio and video tiles carry a review link.
- A rel the filesystem cannot represent is a 404, not a 500.
- A non-finite Accept q-value fails to parse.
- The standalone marks page has regions and updates in place.
- The review's next arrow sits at the edge at phone width.

Contract amended for each, plus an accepted-risks section (unlocked
.seen read-modify-write, a planted .viewed symlink, Item.ordinal with
no default).

741 passed. Each new browser test was mutation-checked against its fix;
the serialization test forces the race with a held first refresh, since
localhost alone never lost it.
This commit is contained in:
vh
2026-09-23 10:22:51 -07:00
parent fa5d46443d
commit 77833dc6d4
11 changed files with 622 additions and 82 deletions
+41 -12
View File
@@ -37,6 +37,8 @@ import asyncio
import fcntl
import hashlib
import io
import json
import math
import os
import re
import secrets
@@ -258,15 +260,16 @@ def _newest_mtime(path: Path) -> float:
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`
files and symlinks (by lstat) 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.
booth look like a delivery. An empty booth landed at 0.0. One unreadable
ENTRY is skipped; a booth whose walk cannot run at all 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:
@@ -275,10 +278,15 @@ def _content_mtime(path: Path) -> float:
if any(part.startswith(".") for part in rel.parts):
continue
try:
st = p.stat()
except FileNotFoundError:
# lstat: a posted SYMLINK counts by its own mtime — when it was
# placed — never by its target's. A link to a busy file outside
# the booth must not make the booth read as newly delivered.
st = p.lstat()
except OSError:
# One unreadable entry costs that entry, not the booth: reading
# the whole booth as landed NOW would pin it in "new" forever.
continue
if stat.S_ISREG(st.st_mode) and st.st_mtime > newest:
if (stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode)) and st.st_mtime > newest:
newest = st.st_mtime
except OSError:
return time.time()
@@ -392,10 +400,13 @@ def record_seen(booth: Path, rel: str, items: Sequence[Item]) -> None:
try:
live = {it.rel for it in items}
seen = (read_seen(booth) | {rel}) & live
# A JSON array, UTF-8 explicitly: a rel may hold a newline or a leading
# space, and the host locale must not decide whether a name encodes.
body = json.dumps(sorted(seen), ensure_ascii=False).encode("utf-8", "surrogateescape")
fd, tmp = tempfile.mkstemp(prefix=".seen.", suffix=".tmp", dir=booth)
try:
with os.fdopen(fd, "w") as fh:
fh.write("".join(f"{r}\n" for r in sorted(seen)))
with os.fdopen(fd, "wb") as fh:
fh.write(body)
os.replace(tmp, booth / SEEN_FILE)
except BaseException:
try:
@@ -403,7 +414,9 @@ def record_seen(booth: Path, rel: str, items: Sequence[Item]) -> None:
except OSError:
pass
raise
except OSError:
except (OSError, ValueError):
# ValueError covers an encode failure — it is not an OSError, and a
# look that cannot be recorded must never cost the page.
pass
@@ -433,6 +446,9 @@ def wants_json(accept: str | None) -> bool:
key, _, value = param.partition("=")
if key.strip().lower() == "q":
q = float(value.strip())
if not math.isfinite(q):
# inf, 1e999, nan parse as floats but are not q-values
raise ValueError("non-finite q")
if mtype.strip().lower() == "application/json" and q > 0:
wanted = True
except ValueError:
@@ -647,7 +663,9 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
# dated question, because the damage is what needs fixing.
"open_since": (min(stamps) if stamps and hold != HOLD_UNREADABLE
else None),
"flags": len(flagged_targets(marks)),
# items that EXIST: a flag on a file since deleted is shown on
# the booth page for withdrawal, not counted as a pick here
"flags": len(flagged_targets(marks) & {it.rel for it in items}),
# 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.
@@ -1180,7 +1198,16 @@ def create_app(
# the wrong group. The rail's jump links do not depend on this.
"inline_groups": bool(rail["groups"]) and _contiguous(
[it["group"] for it in shown]),
# THE flag predicate for this page — the tile class and the tile
# toggle read it too, so no surface on the page can disagree.
"flagged_set": flagged_targets(marks),
"tray": [it for it in gallery if it["name"] in flagged_targets(marks)],
# A flag whose file is gone from the booth: no tile to stamp and
# no tray slot, so it is listed apart with its withdraw control
# rather than vanishing from the page while staying in the file.
"orphan_flags": [m for m in marks
if m.shape == "flag" and m.error is None and m.target
and m.target not in {it["name"] for it in gallery}],
"ord_width": len(str(len(gallery))),
"uploaded": (booth / UPLOAD_MARKER).exists(),
# The same provenance line the index card carries. Deliberate:
@@ -1705,7 +1732,9 @@ def create_app(
booth = resolve_booth(name)
try:
target = (booth / f).resolve()
except OSError:
except (OSError, ValueError):
# ValueError: an embedded NUL. Hostile input, like every other
# unresolvable `f` — a 404, never a 500.
raise HTTPException(status_code=404, detail="no such file")
if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
raise HTTPException(status_code=404, detail="no such file")
+38 -5
View File
@@ -15,7 +15,10 @@ See docs/contracts/u1_item_record.contract.md.
from __future__ import annotations
import json
import os
import re
import stat
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
@@ -108,14 +111,44 @@ class Item:
SEEN_FILE = ".seen"
# A seen marker bigger than this is not one this service wrote: a JSON array of
# every rel in a 270-item booth is a few KB.
SEEN_MAX_BYTES = 1 << 20
def read_seen(booth: Path) -> set[str]:
"""Rels seen at full size. Missing or unreadable file -> empty set; a
damaged marker costs the tape its memory, never the page."""
"""Rels seen at full size (R2 C2). A JSON array of strings, because a rel
may hold a leading space or a newline and must round-trip exactly.
NEVER RAISES and NEVER BLOCKS. Any fleet session can write into a booth,
so the marker may be planted: it is opened without following a link and
without blocking (a FIFO with no writer), refused unless it is a regular
file of sane size, and anything unreadable or malformed reads as nothing
seen — a damaged marker costs the tape its memory, never the page.
"""
try:
text = (booth / SEEN_FILE).read_text()
except (OSError, UnicodeDecodeError):
fd = os.open(booth / SEEN_FILE, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except OSError:
return set()
return {ln.strip() for ln in text.splitlines() if ln.strip()}
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode) or st.st_size > SEEN_MAX_BYTES:
return set()
raw = os.read(fd, SEEN_MAX_BYTES + 1)
except OSError:
return set()
finally:
os.close(fd)
try:
data = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, ValueError, RecursionError):
# RecursionError: a deeply nested array (`[[[[...`) blows the parser's
# stack, and it is neither a ValueError nor an OSError — the same hole
# marks.py, manifest.py and benches.py already close.
return set()
if not isinstance(data, list):
return set()
return {r for r in data if isinstance(r, str)}
def read_blurred(booth: Path) -> set[str]:
+20 -2
View File
@@ -130,7 +130,7 @@
declared R2 change from the click order below — each the original shown
small, blurred if the item is. The standalone marks page has no item
records, so it keeps the list, in `(created, id)` order. #}
{% if tray is defined and tray %}
{% if tray is defined %}{% if tray %}
{# In the lightbox the tray and the notes FOLD on a narrow screen (R2 C5):
a closed <details>, which base.html shows open-and-summary-less above
1000px with no script. Below it, the question sits above the set and the
@@ -151,7 +151,25 @@
</div>
</article>
</details>
{% elif tray is not defined and flags %}
{% endif %}
{% if orphan_flags %}
<article class="mark mark-flags">
<header class="mark-head">
<span class="mark-state mark-state-flag">✔ flagged</span>
<span class="mark-id">{{ orphan_flags|length }} on files no longer in this booth</span>
</header>
<ul class="orphan-flags">
{% for m in orphan_flags %}
<li><span class="mono">{{ m.target }}</span>
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ m.id }}">
<button type="submit" class="mark-x" title="withdraw this flag">×</button>
</form></li>
{% endfor %}
</ul>
</article>
{% endif %}
{% elif flags %}
<article class="mark mark-flags" id="mark-flags">
<header class="mark-head">
<span class="mark-state mark-state-flag">✔ flagged</span>
+107 -36
View File
@@ -422,6 +422,9 @@
font-size:var(--size-micro);text-transform:uppercase;color:var(--text-muted)}
.tray-ord{position:absolute;left:3px;bottom:3px;padding:1px 4px;border-radius:var(--radius-sm);
font:600 10px/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .85);color:oklch(0.91 0.008 216)}
.orphan-flags{margin:0;padding:8px 14px 12px;list-style:none;display:flex;flex-direction:column;gap:4px;
font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)}
.orphan-flags li{display:flex;align-items:center;justify-content:space-between;gap:8px}
.tray-item:hover{text-decoration:none;box-shadow:0 0 0 1px var(--success)}
/* ordinals: the item's number in the whole set (C1) */
.item > .ord{position:absolute;top:10px;left:10px;z-index:2;padding:3px 6px;border-radius:var(--radius-sm);
@@ -484,6 +487,7 @@
padding:10px 12px;color:var(--text-muted);font-size:var(--size-caption);font-family:var(--font-mono);
border-top:1px solid var(--border-subtle);word-break:break-word}
.item figcaption .cap-text{grid-column:2 / -1;font-family:var(--font-sans);font-size:var(--size-sm);line-height:1.5}
.item figcaption .rv-link{grid-row:2;grid-column:2;justify-self:start;font-family:var(--font-mono);font-size:var(--size-micro)}
.item figcaption .blurtoggle{grid-row:2;grid-column:3}
.item figcaption .flagtoggle{grid-row:2;grid-column:4}
.item figcaption .dl-link{margin:0}
@@ -837,11 +841,47 @@
var st = document.querySelector('[data-region="status"]');
if (st) { st.textContent = text; st.hidden = false; }
}
/* A form's IDENTITY: its action plus the hidden fields that say what it is
about (which pick, which item, which mark). Stable across renders, where
a position inside a region is not — a form that appears or vanishes
above another would shift every index after it. */
function formKey(f) {
if (!f) return '';
var id = [];
['ask', 'target', 'mark', 'f'].forEach(function (n) {
var h = f.querySelector('input[type=hidden][name="' + n + '"]');
if (h) id.push(n + '=' + h.value);
});
return (f.getAttribute('action') || '') + '|' + id.join('&');
}
function fieldKey(el) {
var k = formKey(el.form) + '|' + el.name;
return (el.type === 'radio' || el.type === 'checkbox') ? k + '=' + el.value : k;
}
function detailsKey(d, i, sameClass) {
var ask = d.querySelector('input[name="ask"]');
if (ask) return 'ask:' + ask.value;
var f = d.querySelector('form');
if (f) return 'form:' + formKey(f);
return 'cls:' + d.className + '#' + sameClass;
}
function detailsMap(root) {
var m = {}, seen = {};
root.querySelectorAll('details').forEach(function (d, i) {
var n = seen[d.className] = (seen[d.className] || 0) + 1;
m[detailsKey(d, i, n)] = d;
});
return m;
}
/* What the server cannot render, carried from the old node to the new:
live MEDIA elements whose src did not change (a playing track keeps
playing, a decoded image does not collapse to zero height and jolt the
page), and the per-viewer view state a reload would have reset anyway
but an in-place save must not — a revealed blur, a closed doc. */
playing, a decoded image does not collapse and jolt the page); the
per-viewer view state a reload would have reset but an in-place save
must not (a revealed blur, a closed doc, a disclosure the reader
opened or closed); and every DIRTY control — a half-typed note, an
edited one, a radio picked and not yet sent. All matched by IDENTITY,
never by position. The form just sent is the exception: its fields and
its disclosure come back as the server rendered them. */
function carry(oldEl, newEl, sent) {
var olds = [].slice.call(oldEl.querySelectorAll('img[src], video[src], audio[src]'));
newEl.querySelectorAll('img[src], video[src], audio[src]').forEach(function (m) {
@@ -856,57 +896,75 @@
['revealed', 'is-closed'].forEach(function (c) {
if (oldEl.classList.contains(c)) newEl.classList.add(c);
});
/* A disclosure the reader opened or closed stays that way: the server
renders its default, the reader's choice is client state. */
var newDetails = newEl.querySelectorAll('details');
oldEl.querySelectorAll('details').forEach(function (d, i) {
/* ...except the one holding the form just sent: an answered pick's
form comes back folded on purpose, showing the recorded answer. */
var freshDetails = detailsMap(newEl);
var oldDetails = detailsMap(oldEl);
Object.keys(oldDetails).forEach(function (k) {
var d = oldDetails[k];
if (sent && d.contains(sent)) return;
if (newDetails[i]) newDetails[i].open = d.open;
if (freshDetails[k]) freshDetails[k].open = d.open;
});
/* An unsaved DRAFT survives a swap it was not part of: a note half-typed
on one tile must not vanish because a flag landed on another. The
form that was just sent is the exception — its field is supposed to
come back empty. Matched by name and position within the region. */
var fresh = newEl.querySelectorAll('textarea, input[type=text]');
oldEl.querySelectorAll('textarea, input[type=text]').forEach(function (f, i) {
if (!f.value || (sent && sent.contains(f))) return;
var t = fresh[i];
if (t && t.name === f.name && !t.value) t.value = f.value;
var freshFields = {};
newEl.querySelectorAll('textarea, input').forEach(function (el) {
if (el.type !== 'hidden') freshFields[fieldKey(el)] = el;
});
oldEl.querySelectorAll('textarea, input').forEach(function (el) {
if (el.type === 'hidden' || (sent && el.form === sent)) return;
var t = freshFields[fieldKey(el)];
if (!t) return;
if (el.type === 'radio' || el.type === 'checkbox') {
if (el.checked !== el.defaultChecked) t.checked = el.checked;
} else if (el.value !== el.defaultValue) {
t.value = el.value;
}
});
}
/* Returns false when the fresh page's STRUCTURE differs from the live one
beyond a tile falling out of a filter — a panel region that appeared or
vanished — or when the page has no region to swap at all. Then only a
reload tells the truth. */
function swap(html, sent) {
var fresh = new DOMParser().parseFromString(html, 'text/html');
document.querySelectorAll('[data-region]').forEach(function (el) {
var freshById = {}, liveIds = {};
fresh.querySelectorAll('[data-region]').forEach(function (c) {
var id = c.getAttribute('data-region');
if (!(id in freshById)) freshById[id] = c;
});
var live = [].slice.call(document.querySelectorAll('[data-region]'));
live.forEach(function (el) { liveIds[el.getAttribute('data-region')] = true; });
for (var id in freshById) {
if (id !== 'status' && !liveIds[id]) return false; /* a region appeared */
}
var ok = true, swapped = 0;
live.forEach(function (el) {
var id = el.getAttribute('data-region');
if (id === 'status') return;
var next = null;
fresh.querySelectorAll('[data-region]').forEach(function (cand) {
if (!next && cand.getAttribute('data-region') === id) next = cand;
});
var next = freshById[id];
if (next) {
var node = document.importNode(next, true);
carry(el, node, sent);
el.replaceWith(node);
} else {
/* ABSENT from the fresh page — a tile a filter no longer matches,
say, after un-flagging under ?filter=flagged. Left in place, never
deleted (deleting would shift every tile after it under the
swapped++;
} else if (id.indexOf('item-') === 0) {
/* A TILE absent from the fresh page — one a filter no longer
matches, after un-flagging under ?filter=flagged. Left in place,
never deleted (deleting would shift every tile after it under the
reader's eye), and marked stale so it does not pass for current.
The next navigation drops it. */
el.classList.add('is-stale');
} else {
ok = false; /* a panel vanished */
}
});
document.dispatchEvent(new CustomEvent('booth:swapped'));
return ok && swapped > 0; /* a page with no regions shows nothing in place */
}
document.addEventListener('submit', function (ev) {
var form = ev.target;
if (!form.matches || !form.matches('form[data-inplace]') || ev.defaultPrevented) return;
ev.preventDefault();
var data = new FormData(form);
if (ev.submitter && ev.submitter.name) data.append(ev.submitter.name, ev.submitter.value);
fetch(form.action, {
/* SERIALIZED: each save runs its POST, its re-fetch and its swap before
the next begins, so an older snapshot can never land after a newer one.
A form already queued or in flight ignores another submit — a
double-click writes one note, not two. */
var queue = Promise.resolve();
function run(form, data) {
return fetch(form.action, {
method: 'POST', body: new URLSearchParams(data),
headers: {'Accept': 'application/json'}, credentials: 'same-origin'
}).then(function (r) {
@@ -915,12 +973,25 @@
}).then(function (r) {
if (!r.ok) throw new Error('status ' + r.status);
return r.text();
}).then(function (html) { swap(html, form); }).catch(function () {
}).then(function (html) {
if (!swap(html, form)) window.location.reload();
}).catch(function () {
/* Said, then reloaded after a beat, so the words are readable rather
than a flash before the page goes. */
say('Could not save in place — reloading to show what was saved.');
setTimeout(function () { window.location.reload(); }, 900);
});
}
document.addEventListener('submit', function (ev) {
var form = ev.target;
if (!form.matches || !form.matches('form[data-inplace]') || ev.defaultPrevented) return;
ev.preventDefault();
if (form.__busy) return;
form.__busy = true;
var data = new FormData(form);
if (ev.submitter && ev.submitter.name) data.append(ev.submitter.name, ev.submitter.value);
queue = queue.then(function () { return run(form, data); })
.then(function () { form.__busy = false; });
});
})();
</script>
+67 -2
View File
@@ -19,7 +19,8 @@
note field. Same macro discipline as blurtoggle above — three item branches,
one definition. `marks` here is THIS item's marks, from item_marks. #}
{% macro markcontrols(name_url, it, marks, cls='') -%}
{% set flagged = marks | selectattr('shape', 'equalto', 'flag') | list | length > 0 %}
{# THE flag predicate (flagged_targets), shared with every other surface #}
{% set flagged = it.name in flagged_set %}
<form class="flagtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/flag" data-inplace>
<input type="hidden" name="target" value="{{ it.name }}">
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
@@ -370,7 +371,7 @@
</details>
</figure>
{% else %}
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if item_marks.get(it.name, []) | selectattr('shape', 'equalto', 'flag') | list %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.url }}" data-region="item-{{ it.url }}">
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if it.name in flagged_set %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.url }}" data-region="item-{{ it.url }}">
{{ ordinal(it) }}
{% if it.blurred %}
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
@@ -406,6 +407,10 @@
<figcaption>
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
<span class="cap-text">{{ it.caption or it.name }}</span>
{# R2: every MEDIA tile links into the review — a picture through its
image, sound and video through this. Enter on the grid cursor
follows the first `view` link on the tile. #}
{% if it.kind in ('video', 'audio') %}<a class="rv-link" href="view?f={{ it.url }}" title="review at full size">⤢ review</a>{% endif %}
{{ blurtoggle(name_url, it) }}
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
</figcaption>
@@ -592,5 +597,65 @@
}
bindTiles();
document.addEventListener('booth:swapped', bindTiles);
/* RESTORED (heid bug-hunt, 2/4): R2's rewrite of the tile handlers above
deleted this block with them. Its confirmations guard destructive
actions, so it is back verbatim. */
/* Link-board multi-select. PROGRESSIVE ENHANCEMENT: the checkboxes, the per-row
× / ★, and the bulk 🗑 all submit as plain form POSTs with JS off — this only
adds select-all, a live count, and disabling 🗑 when nothing is ticked. The
per-row × confirm reads desc/url from data-* attributes rather than being
interpolated into an inline handler, so an arbitrary agent-posted description
(quotes, newlines) can never break out into the page's JS. */
(function () {
var form = document.getElementById('boardform');
if (!form) return;
var boxes = Array.prototype.slice.call(form.querySelectorAll('.board-check'));
var selall = document.getElementById('board-selall');
var delBtn = document.getElementById('board-del-sel');
var countEl = document.getElementById('board-selcount');
function selected() { return boxes.filter(function (b) { return b.checked; }); }
function refresh() {
var n = selected().length;
if (countEl) countEl.textContent = n;
if (delBtn) delBtn.disabled = n === 0;
if (selall) {
selall.checked = n > 0 && n === boxes.length;
selall.indeterminate = n > 0 && n < boxes.length;
}
}
if (selall) {
selall.addEventListener('change', function () {
boxes.forEach(function (b) { b.checked = selall.checked; });
refresh();
});
}
boxes.forEach(function (b) { b.addEventListener('change', refresh); });
// Bulk delete: confirm with the count. Attached to the button (not the form's
// submit) so the per-row × / ★ submits — which share this form — are unaffected.
if (delBtn) {
delBtn.addEventListener('click', function (ev) {
var n = selected().length;
if (n === 0) { ev.preventDefault(); return; }
if (!confirm('Delete ' + n + ' selected link' + (n === 1 ? '' : 's') + '?\n\nThe rest of the board is untouched.')) {
ev.preventDefault();
}
});
}
form.querySelectorAll('.board-rm-btn').forEach(function (btn) {
btn.addEventListener('click', function (ev) {
var d = btn.getAttribute('data-desc') || '';
var u = btn.getAttribute('data-url') || '';
if (!confirm('Remove this link?\n\n' + d + '\n' + u + '\n\nThe rest of the board is untouched.')) {
ev.preventDefault();
}
});
});
refresh();
})();
</script>
{% endblock %}
+8 -4
View File
@@ -116,11 +116,14 @@
<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">
{# Agent-written URLs: only http(s) becomes a link. Autoescape stops markup,
not a `javascript:` scheme, so anything else renders as plain text. #}
{% set web = b.url.lower().startswith(('http://', 'https://')) %}
<{{ 'a' if web else 'div' }} class="desk-bench is-{{ b.state }}"{% if web %} href="{{ b.url }}" target="_blank" rel="noopener"{% endif %}>
<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>
</{{ 'a' if web else 'div' }}>
{% endfor %}
</section>
{% endif %}
@@ -129,8 +132,9 @@
<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>
{% set web = e.url.lower().startswith(('http://', 'https://')) %}
<{{ 'a' if web else 'div' }} class="desk-mark{% if e.pinned %} is-pinned{% endif %}"{% if web %} href="{{ e.url }}" target="_blank" rel="noopener"{% endif %}>
{{ e.desc }}{% if e.who %}<span class="desk-bench-sub">{{ e.who }}</span>{% endif %}</{{ 'a' if web else 'div' }}>
{% endfor %}
<a class="desk-more" href="{{ board_url }}">all {{ bookmarks_total }} on the board →</a>
</section>
+5 -1
View File
@@ -12,12 +12,16 @@
{# `marks_open` comes from open_marks() — the ONE openness predicate (INV-2).
This used to re-derive it in Jinja as `selectattr('answer', 'none')`, which
read a half-answered pick as closed. #}
<span class="sub">{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}</span>
<span class="region-wrap" data-region="booth-status"><span class="sub">{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}</span></span>
</div>
{# One region around both branches, so answering the last mark away swaps in
the empty state instead of reading as a structural change. #}
<div class="marks-panel" data-region="marks-panel">
{% if marks %}
{% include "_marks.html" %}
{% else %}
<div class="empty">This booth has no marks.</div>
{% include "_marks.html" %}
{% endif %}
</div>
{% endblock %}
+6 -1
View File
@@ -138,7 +138,12 @@
transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)}
.vnav:hover{background:oklch(0.21 0.01 248 / .92);border-color:rgb(255 255 255 / .3);text-decoration:none;
color:oklch(0.91 0.008 216)}
.vprev{left:0}.vnext{right:360px}
/* The next arrow clears the 360px verdict rail only while the rail sits
beside the stage. Scoped to the wide layout: stated bare, this rule came
later in the page than base.html's narrow override and silently won it,
parking the arrow 360px in from the edge of a phone. */
.vprev{left:0}.vnext{right:0}
@media (min-width:901px){.vnext{right:360px}}
.vcap{margin-top:10px;max-height:30vh;overflow-y:auto;font-size:var(--size-sm);line-height:var(--leading-body);
color:var(--text-body);white-space:pre-wrap}
@media print{.vcap{max-height:none;overflow:visible}.vnav{display:none}}