From 77833dc6d4674a2bd0a8e590d8761f36976adc2f Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 10:22:51 -0700 Subject: [PATCH] =?UTF-8?q?fix(r2):=20the=20heid=20bug-hunt=20panel=20(rou?= =?UTF-8?q?nd=20"Nyx",=204/4)=20=E2=80=94=20triaged=20and=20folded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- booth/app.py | 53 ++++++++--- booth/items.py | 43 ++++++++- booth/templates/_marks.html | 22 ++++- booth/templates/base.html | 143 +++++++++++++++++++++-------- booth/templates/booth.html | 69 +++++++++++++- booth/templates/index.html | 12 ++- booth/templates/marks.html | 6 +- booth/templates/view.html | 7 +- docs/contracts/r2_flow.contract.md | 99 ++++++++++++++++---- tests/test_flow.py | 126 ++++++++++++++++++++++++- tests/test_flow_browser.py | 124 +++++++++++++++++++++++++ 11 files changed, 622 insertions(+), 82 deletions(-) diff --git a/booth/app.py b/booth/app.py index c1d2905..f130d57 100644 --- a/booth/app.py +++ b/booth/app.py @@ -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") diff --git a/booth/items.py b/booth/items.py index 8b93a10..5ad0a38 100644 --- a/booth/items.py +++ b/booth/items.py @@ -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]: diff --git a/booth/templates/_marks.html b/booth/templates/_marks.html index 855e4f7..6a10c2a 100644 --- a/booth/templates/_marks.html +++ b/booth/templates/_marks.html @@ -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
, 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 @@
-{% elif tray is not defined and flags %} + {% endif %} + {% if orphan_flags %} +
+
+ ✔ flagged + {{ orphan_flags|length }} on files no longer in this booth +
+ +
+ {% endif %} +{% elif flags %}
✔ flagged diff --git a/booth/templates/base.html b/booth/templates/base.html index f83745c..9d2514c 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -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; }); }); })(); diff --git a/booth/templates/booth.html b/booth/templates/booth.html index 8599281..131f20b 100644 --- a/booth/templates/booth.html +++ b/booth/templates/booth.html @@ -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 %}
@@ -370,7 +371,7 @@ {% else %} -
+
{{ ordinal(it) }} {% if it.blurred %} {# Click-to-reveal is per-viewer and client-side: nothing is persisted, so @@ -406,6 +407,10 @@
⬇ {{ it.caption or it.name }} + {# 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') %}⤢ review{% endif %} {{ blurtoggle(name_url, it) }} {{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
@@ -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(); + })(); {% endblock %} diff --git a/booth/templates/index.html b/booth/templates/index.html index 424f040..0e25f60 100644 --- a/booth/templates/index.html +++ b/booth/templates/index.html @@ -116,11 +116,14 @@

Benches running things

{% for b in benches %} - + {# 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 %}> {{ b.name or b.url }} {% if b.owner %}{{ b.owner }} · {% endif %}{{ b.state }} - + {% endfor %}
{% endif %} @@ -129,8 +132,9 @@

Bookmarks pinned first

{% for e in bookmarks %} - - {{ e.desc }}{% if e.who %}{{ e.who }}{% endif %} + {% 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 %}{{ e.who }}{% endif %} {% endfor %} all {{ bookmarks_total }} on the board →
diff --git a/booth/templates/marks.html b/booth/templates/marks.html index 1ef1668..4ecd97c 100644 --- a/booth/templates/marks.html +++ b/booth/templates/marks.html @@ -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. #} - {% if marks_open %}{{ marks_open }} open · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }} + {% if marks_open %}{{ marks_open }} open · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }} +{# One region around both branches, so answering the last mark away swaps in + the empty state instead of reading as a structural change. #} +
{% if marks %} {% include "_marks.html" %} {% else %}
This booth has no marks.
{% include "_marks.html" %} {% endif %} +
{% endblock %} diff --git a/booth/templates/view.html b/booth/templates/view.html index 5fe6aca..eb74b50 100644 --- a/booth/templates/view.html +++ b/booth/templates/view.html @@ -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}} diff --git a/docs/contracts/r2_flow.contract.md b/docs/contracts/r2_flow.contract.md index 1cfdee2..2ee041c 100644 --- a/docs/contracts/r2_flow.contract.md +++ b/docs/contracts/r2_flow.contract.md @@ -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-"`, 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 ``. @@ -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). diff --git a/tests/test_flow.py b/tests/test_flow.py index 7d7811c..df14bf0 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -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".*?', 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".*?
', 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']*data-item="%s".*?' % 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".*?', body, re.S).group(0) + assert 'href="javascript:' not in panel + assert 'href="https://example.test/"' in panel and "evil" in panel diff --git a/tests/test_flow_browser.py b/tests/test_flow_browser.py index 15a34ea..78daedd 100644 --- a/tests/test_flow_browser.py +++ b/tests/test_flow_browser.py @@ -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) · a · 2026-09-01 10:00\n" + "- [two](http://x/2) · a · 2026-09-01 10:01\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"}