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:
+41
-12
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user