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

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

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

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

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