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 fcntl
|
||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
@@ -258,15 +260,16 @@ def _newest_mtime(path: Path) -> float:
|
|||||||
|
|
||||||
def _content_mtime(path: Path) -> float:
|
def _content_mtime(path: Path) -> float:
|
||||||
"""`landed_at` (R2 C4): the newest mtime among the booth's CONTENT — regular
|
"""`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
|
(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.
|
content, so none of them may make a booth read as newly landed.
|
||||||
|
|
||||||
Files only, never directories: creating `.viewed` bumps the booth
|
Files only, never directories: creating `.viewed` bumps the booth
|
||||||
directory's own mtime, and counting that would make the first look at a
|
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
|
booth look like a delivery. An empty booth landed at 0.0. One unreadable
|
||||||
as NOW, the posture `_newest_mtime` takes and for a milder reason here: a
|
ENTRY is skipped; a booth whose walk cannot run at all reads as NOW, the
|
||||||
booth we cannot read is shown as new rather than hidden as old.
|
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
|
newest = 0.0
|
||||||
try:
|
try:
|
||||||
@@ -275,10 +278,15 @@ def _content_mtime(path: Path) -> float:
|
|||||||
if any(part.startswith(".") for part in rel.parts):
|
if any(part.startswith(".") for part in rel.parts):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
st = p.stat()
|
# lstat: a posted SYMLINK counts by its own mtime — when it was
|
||||||
except FileNotFoundError:
|
# 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
|
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
|
newest = st.st_mtime
|
||||||
except OSError:
|
except OSError:
|
||||||
return time.time()
|
return time.time()
|
||||||
@@ -392,10 +400,13 @@ def record_seen(booth: Path, rel: str, items: Sequence[Item]) -> None:
|
|||||||
try:
|
try:
|
||||||
live = {it.rel for it in items}
|
live = {it.rel for it in items}
|
||||||
seen = (read_seen(booth) | {rel}) & live
|
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)
|
fd, tmp = tempfile.mkstemp(prefix=".seen.", suffix=".tmp", dir=booth)
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, "w") as fh:
|
with os.fdopen(fd, "wb") as fh:
|
||||||
fh.write("".join(f"{r}\n" for r in sorted(seen)))
|
fh.write(body)
|
||||||
os.replace(tmp, booth / SEEN_FILE)
|
os.replace(tmp, booth / SEEN_FILE)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
try:
|
try:
|
||||||
@@ -403,7 +414,9 @@ def record_seen(booth: Path, rel: str, items: Sequence[Item]) -> None:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
raise
|
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
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -433,6 +446,9 @@ def wants_json(accept: str | None) -> bool:
|
|||||||
key, _, value = param.partition("=")
|
key, _, value = param.partition("=")
|
||||||
if key.strip().lower() == "q":
|
if key.strip().lower() == "q":
|
||||||
q = float(value.strip())
|
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:
|
if mtype.strip().lower() == "application/json" and q > 0:
|
||||||
wanted = True
|
wanted = True
|
||||||
except ValueError:
|
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.
|
# dated question, because the damage is what needs fixing.
|
||||||
"open_since": (min(stamps) if stamps and hold != HOLD_UNREADABLE
|
"open_since": (min(stamps) if stamps and hold != HOLD_UNREADABLE
|
||||||
else None),
|
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,
|
# Two clocks, named apart (INV-5): `mtime` is activity,
|
||||||
# `landed_at` is content. "New since you looked" reads only the
|
# `landed_at` is content. "New since you looked" reads only the
|
||||||
# second, so a flag or a view never makes a booth look new.
|
# 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.
|
# the wrong group. The rail's jump links do not depend on this.
|
||||||
"inline_groups": bool(rail["groups"]) and _contiguous(
|
"inline_groups": bool(rail["groups"]) and _contiguous(
|
||||||
[it["group"] for it in shown]),
|
[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)],
|
"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))),
|
"ord_width": len(str(len(gallery))),
|
||||||
"uploaded": (booth / UPLOAD_MARKER).exists(),
|
"uploaded": (booth / UPLOAD_MARKER).exists(),
|
||||||
# The same provenance line the index card carries. Deliberate:
|
# The same provenance line the index card carries. Deliberate:
|
||||||
@@ -1705,7 +1732,9 @@ def create_app(
|
|||||||
booth = resolve_booth(name)
|
booth = resolve_booth(name)
|
||||||
try:
|
try:
|
||||||
target = (booth / f).resolve()
|
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")
|
raise HTTPException(status_code=404, detail="no such file")
|
||||||
if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
|
if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
|
||||||
raise HTTPException(status_code=404, detail="no such 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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import stat
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
@@ -108,14 +111,44 @@ class Item:
|
|||||||
SEEN_FILE = ".seen"
|
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]:
|
def read_seen(booth: Path) -> set[str]:
|
||||||
"""Rels seen at full size. Missing or unreadable file -> empty set; a
|
"""Rels seen at full size (R2 C2). A JSON array of strings, because a rel
|
||||||
damaged marker costs the tape its memory, never the page."""
|
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:
|
try:
|
||||||
text = (booth / SEEN_FILE).read_text()
|
fd = os.open(booth / SEEN_FILE, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||||
except (OSError, UnicodeDecodeError):
|
except OSError:
|
||||||
return set()
|
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]:
|
def read_blurred(booth: Path) -> set[str]:
|
||||||
|
|||||||
@@ -130,7 +130,7 @@
|
|||||||
declared R2 change from the click order below — each the original shown
|
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
|
small, blurred if the item is. The standalone marks page has no item
|
||||||
records, so it keeps the list, in `(created, id)` order. #}
|
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):
|
{# 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
|
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
|
1000px with no script. Below it, the question sits above the set and the
|
||||||
@@ -151,7 +151,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</details>
|
</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">
|
<article class="mark mark-flags" id="mark-flags">
|
||||||
<header class="mark-head">
|
<header class="mark-head">
|
||||||
<span class="mark-state mark-state-flag">✔ flagged</span>
|
<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)}
|
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);
|
.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)}
|
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)}
|
.tray-item:hover{text-decoration:none;box-shadow:0 0 0 1px var(--success)}
|
||||||
/* ordinals: the item's number in the whole set (C1) */
|
/* 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);
|
.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);
|
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}
|
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 .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 .blurtoggle{grid-row:2;grid-column:3}
|
||||||
.item figcaption .flagtoggle{grid-row:2;grid-column:4}
|
.item figcaption .flagtoggle{grid-row:2;grid-column:4}
|
||||||
.item figcaption .dl-link{margin:0}
|
.item figcaption .dl-link{margin:0}
|
||||||
@@ -837,11 +841,47 @@
|
|||||||
var st = document.querySelector('[data-region="status"]');
|
var st = document.querySelector('[data-region="status"]');
|
||||||
if (st) { st.textContent = text; st.hidden = false; }
|
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:
|
/* 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
|
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
|
playing, a decoded image does not collapse and jolt the page); the
|
||||||
page), and the per-viewer view state a reload would have reset anyway
|
per-viewer view state a reload would have reset but an in-place save
|
||||||
but an in-place save must not — a revealed blur, a closed doc. */
|
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) {
|
function carry(oldEl, newEl, sent) {
|
||||||
var olds = [].slice.call(oldEl.querySelectorAll('img[src], video[src], audio[src]'));
|
var olds = [].slice.call(oldEl.querySelectorAll('img[src], video[src], audio[src]'));
|
||||||
newEl.querySelectorAll('img[src], video[src], audio[src]').forEach(function (m) {
|
newEl.querySelectorAll('img[src], video[src], audio[src]').forEach(function (m) {
|
||||||
@@ -856,57 +896,75 @@
|
|||||||
['revealed', 'is-closed'].forEach(function (c) {
|
['revealed', 'is-closed'].forEach(function (c) {
|
||||||
if (oldEl.classList.contains(c)) newEl.classList.add(c);
|
if (oldEl.classList.contains(c)) newEl.classList.add(c);
|
||||||
});
|
});
|
||||||
/* A disclosure the reader opened or closed stays that way: the server
|
var freshDetails = detailsMap(newEl);
|
||||||
renders its default, the reader's choice is client state. */
|
var oldDetails = detailsMap(oldEl);
|
||||||
var newDetails = newEl.querySelectorAll('details');
|
Object.keys(oldDetails).forEach(function (k) {
|
||||||
oldEl.querySelectorAll('details').forEach(function (d, i) {
|
var d = oldDetails[k];
|
||||||
/* ...except the one holding the form just sent: an answered pick's
|
|
||||||
form comes back folded on purpose, showing the recorded answer. */
|
|
||||||
if (sent && d.contains(sent)) return;
|
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
|
var freshFields = {};
|
||||||
on one tile must not vanish because a flag landed on another. The
|
newEl.querySelectorAll('textarea, input').forEach(function (el) {
|
||||||
form that was just sent is the exception — its field is supposed to
|
if (el.type !== 'hidden') freshFields[fieldKey(el)] = el;
|
||||||
come back empty. Matched by name and position within the region. */
|
});
|
||||||
var fresh = newEl.querySelectorAll('textarea, input[type=text]');
|
oldEl.querySelectorAll('textarea, input').forEach(function (el) {
|
||||||
oldEl.querySelectorAll('textarea, input[type=text]').forEach(function (f, i) {
|
if (el.type === 'hidden' || (sent && el.form === sent)) return;
|
||||||
if (!f.value || (sent && sent.contains(f))) return;
|
var t = freshFields[fieldKey(el)];
|
||||||
var t = fresh[i];
|
if (!t) return;
|
||||||
if (t && t.name === f.name && !t.value) t.value = f.value;
|
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) {
|
function swap(html, sent) {
|
||||||
var fresh = new DOMParser().parseFromString(html, 'text/html');
|
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');
|
var id = el.getAttribute('data-region');
|
||||||
if (id === 'status') return;
|
if (id === 'status') return;
|
||||||
var next = null;
|
var next = freshById[id];
|
||||||
fresh.querySelectorAll('[data-region]').forEach(function (cand) {
|
|
||||||
if (!next && cand.getAttribute('data-region') === id) next = cand;
|
|
||||||
});
|
|
||||||
if (next) {
|
if (next) {
|
||||||
var node = document.importNode(next, true);
|
var node = document.importNode(next, true);
|
||||||
carry(el, node, sent);
|
carry(el, node, sent);
|
||||||
el.replaceWith(node);
|
el.replaceWith(node);
|
||||||
} else {
|
swapped++;
|
||||||
/* ABSENT from the fresh page — a tile a filter no longer matches,
|
} else if (id.indexOf('item-') === 0) {
|
||||||
say, after un-flagging under ?filter=flagged. Left in place, never
|
/* A TILE absent from the fresh page — one a filter no longer
|
||||||
deleted (deleting would shift every tile after it under the
|
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.
|
reader's eye), and marked stale so it does not pass for current.
|
||||||
The next navigation drops it. */
|
The next navigation drops it. */
|
||||||
el.classList.add('is-stale');
|
el.classList.add('is-stale');
|
||||||
|
} else {
|
||||||
|
ok = false; /* a panel vanished */
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.dispatchEvent(new CustomEvent('booth:swapped'));
|
document.dispatchEvent(new CustomEvent('booth:swapped'));
|
||||||
|
return ok && swapped > 0; /* a page with no regions shows nothing in place */
|
||||||
}
|
}
|
||||||
document.addEventListener('submit', function (ev) {
|
/* SERIALIZED: each save runs its POST, its re-fetch and its swap before
|
||||||
var form = ev.target;
|
the next begins, so an older snapshot can never land after a newer one.
|
||||||
if (!form.matches || !form.matches('form[data-inplace]') || ev.defaultPrevented) return;
|
A form already queued or in flight ignores another submit — a
|
||||||
ev.preventDefault();
|
double-click writes one note, not two. */
|
||||||
var data = new FormData(form);
|
var queue = Promise.resolve();
|
||||||
if (ev.submitter && ev.submitter.name) data.append(ev.submitter.name, ev.submitter.value);
|
function run(form, data) {
|
||||||
fetch(form.action, {
|
return fetch(form.action, {
|
||||||
method: 'POST', body: new URLSearchParams(data),
|
method: 'POST', body: new URLSearchParams(data),
|
||||||
headers: {'Accept': 'application/json'}, credentials: 'same-origin'
|
headers: {'Accept': 'application/json'}, credentials: 'same-origin'
|
||||||
}).then(function (r) {
|
}).then(function (r) {
|
||||||
@@ -915,12 +973,25 @@
|
|||||||
}).then(function (r) {
|
}).then(function (r) {
|
||||||
if (!r.ok) throw new Error('status ' + r.status);
|
if (!r.ok) throw new Error('status ' + r.status);
|
||||||
return r.text();
|
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
|
/* Said, then reloaded after a beat, so the words are readable rather
|
||||||
than a flash before the page goes. */
|
than a flash before the page goes. */
|
||||||
say('Could not save in place — reloading to show what was saved.');
|
say('Could not save in place — reloading to show what was saved.');
|
||||||
setTimeout(function () { window.location.reload(); }, 900);
|
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>
|
</script>
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
note field. Same macro discipline as blurtoggle above — three item branches,
|
note field. Same macro discipline as blurtoggle above — three item branches,
|
||||||
one definition. `marks` here is THIS item's marks, from item_marks. #}
|
one definition. `marks` here is THIS item's marks, from item_marks. #}
|
||||||
{% macro markcontrols(name_url, it, marks, cls='') -%}
|
{% 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>
|
<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="target" value="{{ it.name }}">
|
||||||
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
|
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
|
||||||
@@ -370,7 +371,7 @@
|
|||||||
</details>
|
</details>
|
||||||
</figure>
|
</figure>
|
||||||
{% else %}
|
{% 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) }}
|
{{ ordinal(it) }}
|
||||||
{% if it.blurred %}
|
{% if it.blurred %}
|
||||||
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
|
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
|
||||||
@@ -406,6 +407,10 @@
|
|||||||
<figcaption>
|
<figcaption>
|
||||||
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
|
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
|
||||||
<span class="cap-text">{{ it.caption or it.name }}</span>
|
<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) }}
|
{{ blurtoggle(name_url, it) }}
|
||||||
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
|
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
|
||||||
</figcaption>
|
</figcaption>
|
||||||
@@ -592,5 +597,65 @@
|
|||||||
}
|
}
|
||||||
bindTiles();
|
bindTiles();
|
||||||
document.addEventListener('booth:swapped', 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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -116,11 +116,14 @@
|
|||||||
<section class="desk-panel" data-panel="benches">
|
<section class="desk-panel" data-panel="benches">
|
||||||
<h2 class="desk-panel-head">Benches <span class="desk-rule">running things</span></h2>
|
<h2 class="desk-panel-head">Benches <span class="desk-rule">running things</span></h2>
|
||||||
{% for b in benches %}
|
{% 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-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-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>
|
<span class="desk-bench-sub">{% if b.owner %}{{ b.owner }} · {% endif %}{{ b.state }}</span></span>
|
||||||
</a>
|
</{{ 'a' if web else 'div' }}>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -129,8 +132,9 @@
|
|||||||
<section class="desk-panel" data-panel="bookmarks">
|
<section class="desk-panel" data-panel="bookmarks">
|
||||||
<h2 class="desk-panel-head">Bookmarks <span class="desk-rule">pinned first</span></h2>
|
<h2 class="desk-panel-head">Bookmarks <span class="desk-rule">pinned first</span></h2>
|
||||||
{% for e in bookmarks %}
|
{% for e in bookmarks %}
|
||||||
<a class="desk-mark{% if e.pinned %} is-pinned{% endif %}" href="{{ e.url }}" target="_blank" rel="noopener">
|
{% set web = e.url.lower().startswith(('http://', 'https://')) %}
|
||||||
{{ e.desc }}{% if e.who %}<span class="desk-bench-sub">{{ e.who }}</span>{% endif %}</a>
|
<{{ '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 %}
|
{% endfor %}
|
||||||
<a class="desk-more" href="{{ board_url }}">all {{ bookmarks_total }} on the board →</a>
|
<a class="desk-more" href="{{ board_url }}">all {{ bookmarks_total }} on the board →</a>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -12,12 +12,16 @@
|
|||||||
{# `marks_open` comes from open_marks() — the ONE openness predicate (INV-2).
|
{# `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
|
This used to re-derive it in Jinja as `selectattr('answer', 'none')`, which
|
||||||
read a half-answered pick as closed. #}
|
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>
|
</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 %}
|
{% if marks %}
|
||||||
{% include "_marks.html" %}
|
{% include "_marks.html" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty">This booth has no marks.</div>
|
<div class="empty">This booth has no marks.</div>
|
||||||
{% include "_marks.html" %}
|
{% include "_marks.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -138,7 +138,12 @@
|
|||||||
transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)}
|
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;
|
.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)}
|
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);
|
.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}
|
color:var(--text-body);white-space:pre-wrap}
|
||||||
@media print{.vcap{max-height:none;overflow:visible}.vnav{display:none}}
|
@media print{.vcap{max-height:none;overflow:visible}.vnav{display:none}}
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ no route derives it.
|
|||||||
- It is a **declared change** to the zoom-ring rule. Today's ring is images
|
- It is a **declared change** to the zoom-ring rule. Today's ring is images
|
||||||
only. A booth mixing images and audio now rings through both, in set order.
|
only. A booth mixing images and audio now rings through both, in set order.
|
||||||
- `image_chain` stays for its callers and tests.
|
- `image_chain` stays for its callers and tests.
|
||||||
- **`SEEN_FILE = ".seen"`**: one rel per line, same shape as `.blurred`.
|
- **`SEEN_FILE = ".seen"`**: a UTF-8 JSON array of rels. Not one rel per
|
||||||
|
line, `.blurred`'s shape: a file name may contain a newline, and a line format
|
||||||
|
would split one such rel into two, neither of them real.
|
||||||
- Written by `record_seen(booth, rel, items)` from the review route, below the 404s
|
- Written by `record_seen(booth, rel, items)` from the review route, below the 404s
|
||||||
and gated on the item record — the same gate `record_view` has.
|
and gated on the item record — the same gate `record_view` has.
|
||||||
- Each write rewrites the whole file: the previous set plus `rel`, minus
|
- Each write rewrites the whole file: the previous set plus `rel`, minus
|
||||||
@@ -120,7 +122,12 @@ no route derives it.
|
|||||||
items.
|
items.
|
||||||
- NEVER RAISES, like `record_view`: failing to record a look costs the
|
- NEVER RAISES, like `record_view`: failing to record a look costs the
|
||||||
marker, not the page.
|
marker, not the page.
|
||||||
- `read_seen(booth) -> set[str]` is lenient, like `read_blurred`.
|
- `read_seen(booth) -> set[str]` is lenient and NEVER RAISES. It opens without
|
||||||
|
following a symlink and without blocking, reads only a regular file of at
|
||||||
|
most 1 MiB, and keeps only the array's string members. Anything else — a
|
||||||
|
link, a FIFO, a directory, an oversized, malformed or too-deeply-nested
|
||||||
|
file — reads as the empty set. `.seen` sits in an agent-writable directory, and a planted FIFO
|
||||||
|
must not hang the review route.
|
||||||
- `items` is the route's own `booth_items` result. It is passed in so that the
|
- `items` is the route's own `booth_items` result. It is passed in so that the
|
||||||
prune ("minus rels no longer in `booth_items`") costs no second walk.
|
prune ("minus rels no longer in `booth_items`") costs no second walk.
|
||||||
- **Seen is UI state, not judgment.** It is not exposed in `marks.json` and it
|
- **Seen is UI state, not judgment.** It is not exposed in `marks.json` and it
|
||||||
@@ -142,7 +149,8 @@ exactly `application/json` and whose q-value is absent or greater than 0.
|
|||||||
- A near miss such as `application/jsonx` → False.
|
- A near miss such as `application/jsonx` → False.
|
||||||
- **Every entry is parsed before anything is decided.** One unparseable
|
- **Every entry is parsed before anything is decided.** One unparseable
|
||||||
entry anywhere, before or after a good one, makes the whole header False.
|
entry anywhere, before or after a good one, makes the whole header False.
|
||||||
- Any header that fails to parse → False.
|
- Any header that fails to parse → False. A q-value that is not a finite
|
||||||
|
number (`q=nan`, `q=inf`) fails to parse.
|
||||||
- **It fails toward the 303.**
|
- **It fails toward the 303.**
|
||||||
|
|
||||||
The four mark routes (`/answer`, `/note`, `/flag`, `/unmark`) perform the same
|
The four mark routes (`/answer`, `/note`, `/flag`, `/unmark`) perform the same
|
||||||
@@ -177,24 +185,42 @@ today's zoom flag form carries no `back`, so it lands on the gallery.
|
|||||||
counts change when you flag), and the header's open count and lifetime
|
counts change when you flag), and the header's open count and lifetime
|
||||||
line (`booth-status`).
|
line (`booth-status`).
|
||||||
- On a booth with marks but no set: the panel (`marks-panel`).
|
- On a booth with marks but no set: the panel (`marks-panel`).
|
||||||
|
- On the standalone marks page: the header's open count (`booth-status`)
|
||||||
|
and the panel (`marks-panel`), one region around both its states so
|
||||||
|
answering the last mark away swaps in the empty state.
|
||||||
- On the review: the rail, the filmstrip and the tape.
|
- On the review: the rail, the filmstrip and the tape.
|
||||||
- The stage is never a region: replacing it would restart a playing video
|
- The stage is never a region: replacing it would restart a playing video
|
||||||
or audio track.
|
or audio track.
|
||||||
- A region absent from the response is left alone and never deleted.
|
- A TILE (`item-*`) absent from the response is left alone and never
|
||||||
Deleting it would shift every tile after it under the reader's eye. It
|
deleted. Deleting it would shift every tile after it under the reader's
|
||||||
is marked `is-stale` so it does not pass for current: un-flagging under
|
eye. It is marked `is-stale` so it does not pass for current:
|
||||||
`?filter=flagged` is the case. The next navigation drops it.
|
un-flagging under `?filter=flagged` is the case. The next navigation
|
||||||
|
drops it.
|
||||||
|
- Any OTHER difference in structure — a non-tile region in the response
|
||||||
|
that the page lacks, or one the page has that the response lacks — or a
|
||||||
|
page with no region to swap at all, is not patched: the script reloads
|
||||||
|
with a GET, so what you see is the server's truth.
|
||||||
- The swap also carries the per-viewer state a reload would have reset
|
- The swap also carries the per-viewer state a reload would have reset
|
||||||
but an in-place save must not:
|
but an in-place save must not:
|
||||||
- live media whose src is unchanged;
|
- live media whose src is unchanged;
|
||||||
- a revealed blur;
|
- a revealed blur;
|
||||||
- a closed doc;
|
- a closed doc;
|
||||||
- disclosures the reader opened or closed;
|
- disclosures the reader opened or closed;
|
||||||
- unsaved drafts.
|
- every DIRTY control: a half-typed or edited note, a radio picked and
|
||||||
|
not yet sent.
|
||||||
|
|
||||||
The form just sent is the exception: its field comes back empty, and its
|
All of it is matched by IDENTITY, never by position: a form by its
|
||||||
|
action and its hidden `ask`/`target`/`mark`/`f` fields, a control by its
|
||||||
|
form plus its name (plus its value for a radio or checkbox), a disclosure
|
||||||
|
by the pick or form it holds. A flag that adds a tray row above a draft
|
||||||
|
must not move the draft into the wrong box. The form just sent is the
|
||||||
|
exception: its fields come back as the server rendered them, and its
|
||||||
disclosure comes back folded.
|
disclosure comes back folded.
|
||||||
3. **The script never re-POSTs.** A retry after a lost response would re-apply
|
3. **Saves are SERIALIZED.** Each save runs its POST, its GET and its swap
|
||||||
|
before the next begins, so an older snapshot never lands after a newer one
|
||||||
|
(three quick flags show three flags). A form already queued or in flight
|
||||||
|
ignores another submit: a double-click writes one note, not two.
|
||||||
|
4. **The script never re-POSTs.** A retry after a lost response would re-apply
|
||||||
the judgment: a duplicate note, or a re-dated answer.
|
the judgment: a duplicate note, or a re-dated answer.
|
||||||
- On a non-204 HTTP response, or a network failure, it writes a fixed
|
- On a non-204 HTTP response, or a network failure, it writes a fixed
|
||||||
message into the page's server-rendered status element
|
message into the page's server-rendered status element
|
||||||
@@ -218,20 +244,29 @@ rule — a second renderer in JavaScript would be the same bug in a new language
|
|||||||
legacy-import stamp, sort wrong as text.
|
legacy-import stamp, sort wrong as text.
|
||||||
- An unparseable stamp sorts AFTER every parseable one, and name breaks the
|
- An unparseable stamp sorts AFTER every parseable one, and name breaks the
|
||||||
tie.
|
tie.
|
||||||
- **`flags`**: the number of items carrying a READABLE flag mark, shown on
|
- **`flags`**: the number of CURRENT items carrying a READABLE flag mark,
|
||||||
every Desk row that has any. `flagged_targets(marks)` is the ONE flag
|
shown on every Desk row that has any — `flagged_targets(marks)` intersected
|
||||||
|
with the booth's item rels. `flagged_targets(marks)` is the ONE flag
|
||||||
predicate. The Desk, the tray, the filmstrip, the tape and the review button
|
predicate. The Desk, the tray, the filmstrip, the tape and the review button
|
||||||
all read it, and an unreadable flag entry counts nowhere.
|
all read it, and an unreadable flag entry counts nowhere. A flag whose file
|
||||||
- **`landed_at`**: the newest mtime among the booth's CONTENT — its REGULAR
|
has since been deleted is an ORPHAN: it counts on no Desk row, and the tray
|
||||||
FILES with no dot-component in their path. **Deliberately not
|
lists it (C5) so it can be cleared.
|
||||||
`_newest_mtime`** (INV-5). Three refinements, each load-bearing:
|
- **`landed_at`**: the newest mtime among the booth's CONTENT — its regular
|
||||||
|
files and symlinks with no dot-component in their path, each read by
|
||||||
|
`lstat`. **Deliberately not `_newest_mtime`** (INV-5). Five refinements,
|
||||||
|
each load-bearing:
|
||||||
- **Files only, never directories.** Creating any dotfile (`.viewed`, the
|
- **Files only, never directories.** Creating any dotfile (`.viewed`, the
|
||||||
marks file's temp-and-replace) bumps the booth directory's own mtime, so
|
marks file's temp-and-replace) bumps the booth directory's own mtime, so
|
||||||
counting directories would make the flag you set after looking read as a
|
counting directories would make the flag you set after looking read as a
|
||||||
delivery.
|
delivery.
|
||||||
|
- **A symlink counts by its OWN mtime** — when it was placed — never its
|
||||||
|
target's. A link to a busy file outside the booth must not make the booth
|
||||||
|
read as newly delivered.
|
||||||
- **An empty booth landed at 0.0.**
|
- **An empty booth landed at 0.0.**
|
||||||
- **An unreadable booth reads as NOW.** It is shown as new rather than
|
- **One unreadable entry is skipped.** Reading the whole booth as landed NOW
|
||||||
hidden as old.
|
for one bad entry would pin it in 'new' forever.
|
||||||
|
- **A booth whose walk cannot run at all reads as NOW.** It is shown as new
|
||||||
|
rather than hidden as old.
|
||||||
- **`viewed_at`**: the mtime of `.viewed`, or None.
|
- **`viewed_at`**: the mtime of `.viewed`, or None.
|
||||||
- **`preview`**: up to 4 image items as `(url, blurred)`, first four in item
|
- **`preview`**: up to 4 image items as `(url, blurred)`, first four in item
|
||||||
order. A blurred one renders blurred, the same rule as the cover.
|
order. A blurred one renders blurred, the same rule as the cover.
|
||||||
@@ -270,6 +305,9 @@ The side column holds:
|
|||||||
existing order. Its error return renders as an error line, never as an empty
|
existing order. Its error return renders as an error line, never as an empty
|
||||||
list. This is the booth page's rule: damaged and absent must not render the
|
list. This is the booth page's rule: damaged and absent must not render the
|
||||||
same.
|
same.
|
||||||
|
- **Agent-written URLs become links only when they are `http(s)`.** A bench
|
||||||
|
URL or a bookmark with any other scheme renders as plain text. Autoescape
|
||||||
|
stops markup, not a `javascript:` href.
|
||||||
- **Bookmarks** come from the board the CLI writes: the booth named by
|
- **Bookmarks** come from the board the CLI writes: the booth named by
|
||||||
`BOOTH_LINKS_BOARD`, default `links`. They are read through the same
|
`BOOTH_LINKS_BOARD`, default `links`. They are read through the same
|
||||||
never-raising path as `_board_rows`, which gets factored so both callers
|
never-raising path as `_board_rows`, which gets factored so both callers
|
||||||
@@ -312,6 +350,9 @@ unchanged) remain on every row.
|
|||||||
displayed small (no generated thumbnail), blurred if the item is blurred,
|
displayed small (no generated thumbnail), blurred if the item is blurred,
|
||||||
with its #.
|
with its #.
|
||||||
The order is total with no tie-break, because rels are unique.
|
The order is total with no tie-break, because rels are unique.
|
||||||
|
- **Orphan flags** — flags whose target is no longer an item — follow the
|
||||||
|
tray, by target, each with its unmark form. A flag the page cannot show
|
||||||
|
must still be clearable, or it counts in the rail forever.
|
||||||
- **The rail stays.** Same element, same `.rail` class (booth.html's cursor
|
- **The rail stays.** Same element, same `.rail` class (booth.html's cursor
|
||||||
and base.html's `--rail-h` script both read it), same filter hrefs, same
|
and base.html's `--rail-h` script both read it), same filter hrefs, same
|
||||||
group anchors. When `rail.groups` is non-empty AND every group is one
|
group anchors. When `rail.groups` is non-empty AND every group is one
|
||||||
@@ -327,11 +368,17 @@ unchanged) remain on every row.
|
|||||||
- **Every tile shows `#NN`** (its ordinal, zero-padded to the set's width).
|
- **Every tile shows `#NN`** (its ordinal, zero-padded to the set's width).
|
||||||
Each tile is `data-region="item-<url>"`, so the in-place script can replace
|
Each tile is `data-region="item-<url>"`, so the in-place script can replace
|
||||||
exactly the tile it flagged.
|
exactly the tile it flagged.
|
||||||
|
- **An audio or video tile carries a `review` link** to its review page. On
|
||||||
|
those tiles a click drives the player, so without the link the review is
|
||||||
|
reachable only by key.
|
||||||
|
|
||||||
### C6 — the review (view.html, booth_view_file)
|
### C6 — the review (view.html, booth_view_file)
|
||||||
|
|
||||||
This applies to image, video and audio items. Docs keep `doc.html`.
|
This applies to image, video and audio items. Docs keep `doc.html`.
|
||||||
|
|
||||||
|
A requested rel the filesystem cannot represent (a NUL byte, an over-long
|
||||||
|
path) is a 404, as any other unknown rel is — never a 500.
|
||||||
|
|
||||||
- **The stage**: the artifact at fit size, with a 1:1 toggle for images ONLY.
|
- **The stage**: the artifact at fit size, with a 1:1 toggle for images ONLY.
|
||||||
- The toggle and its script are rendered and bound only when the stage is
|
- The toggle and its script are rendered and bound only when the stage is
|
||||||
an `<img>`.
|
an `<img>`.
|
||||||
@@ -454,6 +501,22 @@ checked:
|
|||||||
- `Wipe now` stays in the booth header;
|
- `Wipe now` stays in the booth header;
|
||||||
- `class="boothhead"` stays.
|
- `class="boothhead"` stays.
|
||||||
|
|
||||||
|
## Accepted risks (named, not fixed)
|
||||||
|
|
||||||
|
- **`.seen` is read-modify-write without a lock.** Two reviews of the same
|
||||||
|
booth racing can drop one rel from `.seen`. The cost is cosmetic — a frame
|
||||||
|
shown unseen on the tape — and the next look repairs it; a lock would buy a
|
||||||
|
cosmetic count at the price of a lock file the lifetime clock must ignore.
|
||||||
|
- **A `.viewed` symlink planted by an agent freezes 'new'.** `viewed_at`
|
||||||
|
reads it by `lstat`, and `record_view` refuses to write through it
|
||||||
|
(`O_NOFOLLOW`), so the marker never moves again: once content lands after
|
||||||
|
it, the booth reads as 'new' however often it is opened. It fails in the
|
||||||
|
visible direction — shown, never hidden — and needs write access to the
|
||||||
|
booth, which already buys worse. The remedy is deleting the link.
|
||||||
|
- **`Item` gains `ordinal` with no default.** `booth_items` is the single
|
||||||
|
construction site, keyword-only; a default would let a second site forget
|
||||||
|
it silently (INV-1).
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
- Compare (r3).
|
- Compare (r3).
|
||||||
|
|||||||
+125
-1
@@ -113,7 +113,7 @@ def test_a_full_size_look_is_recorded_as_seen_and_a_non_item_is_not(tmp_path):
|
|||||||
for f in ("a.png", "c.png", "a.png", ".marks.lock"):
|
for f in ("a.png", "c.png", "a.png", ".marks.lock"):
|
||||||
c.get(f"/b/g/view?f={f}")
|
c.get(f"/b/g/view?f={f}")
|
||||||
assert read_seen(b) == {"a.png", "c.png"}
|
assert read_seen(b) == {"a.png", "c.png"}
|
||||||
assert (b / ".seen").read_text() == "a.png\nc.png\n" # sorted, deduplicated
|
assert (b / ".seen").read_text() == '["a.png", "c.png"]' # sorted, deduplicated, JSON
|
||||||
|
|
||||||
|
|
||||||
def test_seen_is_pruned_to_live_items_at_the_next_write(tmp_path):
|
def test_seen_is_pruned_to_live_items_at_the_next_write(tmp_path):
|
||||||
@@ -673,3 +673,127 @@ def test_a_full_size_look_also_counts_as_looking_at_the_booth(tmp_path):
|
|||||||
assert not (b / ".viewed").exists()
|
assert not (b / ".viewed").exists()
|
||||||
_client(tmp_path).get("/b/g/view?f=a.png")
|
_client(tmp_path).get("/b/g/view?f=a.png")
|
||||||
assert (b / ".viewed").exists() and (b / ".seen").exists()
|
assert (b / ".viewed").exists() and (b / ".seen").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_flag_on_a_file_that_is_gone_stays_visible_and_withdrawable(tmp_path):
|
||||||
|
"""Nyx N3 (2/4): the tray only shows live items, and `tray` being always
|
||||||
|
defined killed the old list fallback — so a flag whose file was deleted
|
||||||
|
rendered NOWHERE on the booth page while the Desk still counted it. It is
|
||||||
|
now listed apart, with its withdraw control; the Desk counts live items."""
|
||||||
|
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
|
||||||
|
set_flag(b, "a.png", True)
|
||||||
|
set_flag(b, "b.png", True)
|
||||||
|
(b / "b.png").unlink()
|
||||||
|
c = _client(tmp_path)
|
||||||
|
aside = _region(c.get("/b/g/").text, "verdict")
|
||||||
|
orphans = re.search(r'class="orphan-flags".*?</ul>', aside, re.S)
|
||||||
|
assert orphans and "b.png" in orphans.group(0)
|
||||||
|
assert 'action="/b/g/unmark"' in orphans.group(0)
|
||||||
|
row = re.search(r'data-booth="g".*?</article>', c.get("/").text, re.S).group(0)
|
||||||
|
assert "1 flagged" in row
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_planted_fifo_or_device_seen_marker_cannot_hang_the_review(tmp_path):
|
||||||
|
"""Nyx N4 (3/4): `.seen` was read with an unbounded, symlink-following
|
||||||
|
read_text(). A FIFO with no writer blocked the worker forever; a symlink to
|
||||||
|
/dev/zero read until memory ran out. The read now refuses anything that is
|
||||||
|
not a small regular file, without following a link."""
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
b = _booth(tmp_path, "g", {"a.png": PNG})
|
||||||
|
os.mkfifo(b / ".seen")
|
||||||
|
out = {}
|
||||||
|
t = threading.Thread(target=lambda: out.setdefault(
|
||||||
|
"r", _client(tmp_path).get("/b/g/view?f=a.png")), daemon=True)
|
||||||
|
t.start()
|
||||||
|
t.join(timeout=5)
|
||||||
|
assert "r" in out, "the review hung on a FIFO .seen"
|
||||||
|
assert out["r"].status_code == 200
|
||||||
|
h = _booth(tmp_path, "h", {"a.png": PNG})
|
||||||
|
(h / ".seen").symlink_to("/dev/zero")
|
||||||
|
assert _client(tmp_path).get("/b/h/view?f=a.png").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_seen_round_trips_names_with_spaces_and_newlines(tmp_path):
|
||||||
|
"""Nyx (groa, hulda): one stripped line per rel lost ` a.png` and split a
|
||||||
|
name holding a newline into two identities. The marker is a JSON array."""
|
||||||
|
from booth.items import read_seen
|
||||||
|
b = _booth(tmp_path, "g", {"a.png": PNG, " a.png": PNG, "x\ny.png": PNG})
|
||||||
|
c = _client(tmp_path)
|
||||||
|
c.get("/b/g/view", params={"f": " a.png"})
|
||||||
|
c.get("/b/g/view", params={"f": "x\ny.png"})
|
||||||
|
assert read_seen(b) == {" a.png", "x\ny.png"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_deeply_nested_seen_marker_reads_as_nothing_seen(tmp_path):
|
||||||
|
"""A JSON array nested past the parser's recursion limit raises
|
||||||
|
RecursionError, which is not a ValueError: a 100 KB file of `[` planted as
|
||||||
|
`.seen` escaped the never-raises read and 500'd every review of the booth.
|
||||||
|
It reads as nothing seen, and the next look rewrites it."""
|
||||||
|
from booth.items import read_seen
|
||||||
|
b = _booth(tmp_path, "g", {"a.png": PNG})
|
||||||
|
(b / ".seen").write_text("[" * 100_000)
|
||||||
|
assert read_seen(b) == set()
|
||||||
|
assert _client(tmp_path).get("/b/g/view?f=a.png").status_code == 200
|
||||||
|
assert read_seen(b) == {"a.png"}
|
||||||
|
|
||||||
|
def test_the_content_clock_reads_the_booth_not_what_its_links_point_at(tmp_path):
|
||||||
|
"""Nyx (groa, regin): stat() followed a symlink, so a link to a busy file
|
||||||
|
outside the booth made the booth read as newly delivered on every load; and
|
||||||
|
one unreadable entry (a symlink loop) made the whole booth read as landed
|
||||||
|
NOW, forever. The link's own mtime counts; an unreadable entry is skipped."""
|
||||||
|
import os
|
||||||
|
t0 = time.time() - 10_000 # in the PAST: a future stamp outranks every real write and hides the bug
|
||||||
|
outside = tmp_path / "busy.log"
|
||||||
|
outside.write_text("x")
|
||||||
|
b = _booth(tmp_path, "g", {"a.png": PNG})
|
||||||
|
(b / "linked.png").symlink_to(outside)
|
||||||
|
(b / "loop.png").symlink_to(b / "loop.png")
|
||||||
|
for p in (b / "a.png", b / "linked.png", b / "loop.png"):
|
||||||
|
os.utime(p, (t0, t0), follow_symlinks=False)
|
||||||
|
_at(b, t0)
|
||||||
|
c = _client(tmp_path)
|
||||||
|
c.get("/b/g/") # look at it
|
||||||
|
os.utime(outside, None) # the outside file keeps moving
|
||||||
|
assert _desk(c.get("/").text).get("rest") == ["g"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_nul_in_the_review_path_is_a_404_not_a_500(tmp_path):
|
||||||
|
"""Nyx (groa, seat-probed): Path raises ValueError on an embedded NUL, and
|
||||||
|
the route caught only OSError. Every other hostile `f` is a 404."""
|
||||||
|
_booth(tmp_path, "g", {"a.png": PNG})
|
||||||
|
assert _client(tmp_path).get("/b/g/view?f=a%00.png").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("q", ["inf", "1e999", "nan", "-inf"])
|
||||||
|
def test_a_non_finite_q_is_malformed(q):
|
||||||
|
"""Nyx (regin): float() parses inf and 1e999, and inf > 0 — a malformed
|
||||||
|
header slipped through to the 204. Non-finite q is malformed: False."""
|
||||||
|
from booth.app import wants_json
|
||||||
|
assert wants_json(f"application/json;q={q}") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_sound_only_booth_can_open_the_review(tmp_path):
|
||||||
|
"""Nyx (groa): only the image tile linked to view?f=, so a booth of tracks
|
||||||
|
had no way into the review, the tape or `.seen`. Every media tile links in
|
||||||
|
(and Enter on the grid cursor follows that link)."""
|
||||||
|
_booth(tmp_path, "g", {"a.mp3": b"ID3", "b.webm": b"\x1aE"})
|
||||||
|
body = _client(tmp_path).get("/b/g/").text
|
||||||
|
for rel in ("a.mp3", "b.webm"):
|
||||||
|
fig = re.search(r'<figure[^>]*data-item="%s".*?</figure>' % re.escape(rel), body, re.S).group(0)
|
||||||
|
assert f'href="view?f={rel}"' in fig, rel
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_desk_never_makes_a_non_web_url_clickable(tmp_path):
|
||||||
|
"""Nyx (kimi): bookmark and bench URLs are agent-written and land in href.
|
||||||
|
Autoescape does nothing about a `javascript:` scheme. The Desk links only
|
||||||
|
http(s) and shows anything else as plain text."""
|
||||||
|
rows = (_link("evil", "javascript:alert`1`")
|
||||||
|
+ _link("fine", "https://example.test/"))
|
||||||
|
board = _booth(tmp_path, "links", {"links.md": rows.encode()})
|
||||||
|
(board / ".forever").write_bytes(b"")
|
||||||
|
body = _client(tmp_path).get("/").text
|
||||||
|
panel = re.search(r'data-panel="bookmarks".*?</section>', body, re.S).group(0)
|
||||||
|
assert 'href="javascript:' not in panel
|
||||||
|
assert 'href="https://example.test/"' in panel and "evil" in panel
|
||||||
|
|||||||
@@ -250,3 +250,127 @@ def test_on_a_narrow_screen_flags_and_notes_fold_and_on_a_wide_one_they_show(bro
|
|||||||
page.locator(".verdict summary.v-fold-head").first.click()
|
page.locator(".verdict summary.v-fold-head").first.click()
|
||||||
assert tray.is_visible()
|
assert tray.is_visible()
|
||||||
ctx.close()
|
ctx.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- fixups from the heid bug-hunt panel (round "Nyx") ------------------------
|
||||||
|
|
||||||
|
def test_the_link_board_still_confirms_before_removing_a_row(browser, live):
|
||||||
|
"""Nyx N1 (2/4): the R2 rewrite of booth.html's scripts deleted the board's
|
||||||
|
multi-select + confirmation script along with the handlers it replaced.
|
||||||
|
Removing a row is destructive; the confirm naming it must still stand in
|
||||||
|
front of the POST, and select-all must still select."""
|
||||||
|
base, root = live
|
||||||
|
board = root / "links"
|
||||||
|
board.mkdir()
|
||||||
|
(board / "links.md").write_text(
|
||||||
|
"- [one](http://x/1) <sub>· a · 2026-09-01 10:00</sub>\n"
|
||||||
|
"- [two](http://x/2) <sub>· a · 2026-09-01 10:01</sub>\n")
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(f"{base}/b/links/", wait_until="networkidle")
|
||||||
|
dialogs = []
|
||||||
|
page.on("dialog", lambda d: (dialogs.append(d.message), d.dismiss()))
|
||||||
|
page.locator(".board-rm-btn").first.click()
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
page.locator("#board-selall").check()
|
||||||
|
ticked = page.eval_on_selector_all(".board-check", "els => els.filter(e => e.checked).length")
|
||||||
|
page.close()
|
||||||
|
assert dialogs and "Remove this link?" in dialogs[0]
|
||||||
|
assert ticked == 2
|
||||||
|
assert (board / "links.md").read_text().count("- [") == 2, "a dismissed confirm removed nothing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unsaved_choice_survives_a_save_elsewhere_and_a_double_click_writes_once(browser, live):
|
||||||
|
"""Nyx N5 (hulda, regin, groa): a picked-but-unsent radio was reset by any
|
||||||
|
other in-place save, drafts were matched by POSITION, and a double-click on
|
||||||
|
Add note wrote two notes. Now: dirty controls carry by identity, and a form
|
||||||
|
already in flight ignores a second submit."""
|
||||||
|
from booth.marks import declare_pick, marks_for
|
||||||
|
base, root = live
|
||||||
|
b = _set(root, 3)
|
||||||
|
declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]})
|
||||||
|
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||||
|
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
||||||
|
page.locator('.verdict input[type=radio][value="y"]').check()
|
||||||
|
page.locator('figure.item[data-item="02.png"] .flagtoggle button').click()
|
||||||
|
page.wait_for_selector('figure.item.is-flagged[data-item="02.png"]', timeout=10000)
|
||||||
|
assert page.locator('.verdict input[type=radio][value="y"]').is_checked()
|
||||||
|
|
||||||
|
page.locator(".verdict .mark-add textarea").fill("once")
|
||||||
|
page.locator(".verdict .mark-add button").dblclick()
|
||||||
|
page.wait_for_timeout(1500)
|
||||||
|
page.close()
|
||||||
|
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["once"]
|
||||||
|
|
||||||
|
|
||||||
|
# The first page refresh after a save is held back 1s in the CLIENT: the server
|
||||||
|
# renders it at once (so it carries only the first flag) and the browser sees
|
||||||
|
# it late. Localhost alone never loses that race, so without the hold the test
|
||||||
|
# passed with sequencing deleted — it has to be forced to be a control.
|
||||||
|
_HOLD_FIRST_REFRESH = """
|
||||||
|
(function () {
|
||||||
|
var real = window.fetch, n = 0;
|
||||||
|
window.fetch = function (u, o) {
|
||||||
|
var p = real.apply(this, arguments);
|
||||||
|
if ((!o || !o.method || o.method === 'GET') && n++ === 0) {
|
||||||
|
return p.then(function (r) {
|
||||||
|
return new Promise(function (res) { setTimeout(function () { res(r); }, 1000); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_quick_successive_flags_all_show(browser, live):
|
||||||
|
"""Nyx (hulda): with no sequencing, an older refresh landing after a newer
|
||||||
|
one showed the newer flag as gone. Saves are serialized."""
|
||||||
|
base, root = live
|
||||||
|
_set(root, 4)
|
||||||
|
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||||
|
page.add_init_script(_HOLD_FIRST_REFRESH)
|
||||||
|
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
||||||
|
for rel in ("01.png", "02.png", "03.png"):
|
||||||
|
page.locator(f'figure.item[data-item="{rel}"] .flagtoggle button').click()
|
||||||
|
page.wait_for_timeout(150)
|
||||||
|
page.wait_for_function(
|
||||||
|
"document.querySelectorAll('figure.item.is-flagged').length === 3", timeout=10000)
|
||||||
|
page.wait_for_timeout(1500)
|
||||||
|
n = page.locator("figure.item.is-flagged").count()
|
||||||
|
page.close()
|
||||||
|
assert n == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_standalone_marks_page_updates_in_place(browser, live):
|
||||||
|
"""Nyx (kimi): the marks page's forms are in-place, but the page had no
|
||||||
|
region, so an answer saved and the page never showed it."""
|
||||||
|
from booth.marks import declare_pick
|
||||||
|
base, root = live
|
||||||
|
b = _set(root, 1)
|
||||||
|
declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]})
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
|
||||||
|
page.evaluate("window.__same_page = 1")
|
||||||
|
page.locator('input[type=radio][value="x"]').check()
|
||||||
|
page.locator(".mark-submit").click()
|
||||||
|
page.wait_for_selector(".mark.is-answered", timeout=10000)
|
||||||
|
# In place, not the reload fallback: the page's own window survived.
|
||||||
|
same = page.evaluate("window.__same_page === 1")
|
||||||
|
page.close()
|
||||||
|
assert same
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_next_arrow_clears_the_rail_only_beside_it(browser, live):
|
||||||
|
"""Nyx: view.html's bare `.vnext{right:360px}` came later in the page than
|
||||||
|
base.html's narrow override and won it, parking the arrow 360px in from
|
||||||
|
the edge of a phone. Wide: it clears the rail. Narrow: it sits at the edge."""
|
||||||
|
base, root = live
|
||||||
|
_set(root, 3)
|
||||||
|
rights = {}
|
||||||
|
for w in (1400, 390):
|
||||||
|
page = browser.new_page(viewport={"width": w, "height": 900})
|
||||||
|
page.goto(f"{base}/b/g/view?f=01.png", wait_until="networkidle")
|
||||||
|
rights[w] = page.evaluate(
|
||||||
|
"getComputedStyle(document.querySelector('.vnav.vnext')).right")
|
||||||
|
page.close()
|
||||||
|
assert rights == {1400: "360px", 390: "0px"}
|
||||||
|
|||||||
Reference in New Issue
Block a user