merge(r2b): reveal all, and the booth blur control

design-dev's r2b merge 1 (D2 + D2b), merged on the operator's approval after
design-dev's "merge it" with both heid panels folded (code review and bug hunt,
4/4 each).

5ded5ff is the feature: a per-viewer "reveal all" for blurred items, and the
whole-booth fog control on the booth page, the review and the Desk. 75623c7
folds both panels, and two of its edits land in our code. set_booth_blurred no
longer touch()es through a planted .blurbooth symlink: anything already at the
name reads as fogged and nothing is written, otherwise it creates with
O_CREAT|O_EXCL|O_NOFOLLOW (the class record_view was hardened against).
booth_blur_all only redirects back to the review for a member of the review
ring, as the mark routes do.

20f1cb8 and ca0641f are test-only: opt-in Playwright traces for failing browser
tests, then a test browser with no internet in both fixtures, each with a
positive control (an external host fails fast, a Booth page still goes idle).
The flake's cause is NOT confirmed: 0 reds in 24 untraced runs after the change
is consistent with the fix but no trace ever caught the stalled request.
This commit is contained in:
vh
2026-09-23 21:41:46 -07:00
13 changed files with 1358 additions and 11 deletions
+30 -2
View File
@@ -136,7 +136,21 @@ def set_booth_blurred(booth: Path, on: bool) -> bool:
state the caller asked for, exactly as unflagging an unflagged item is.""" state the caller asked for, exactly as unflagging an unflagged item is."""
marker = booth / BOOTH_BLUR_FILE marker = booth / BOOTH_BLUR_FILE
if on: if on:
marker.touch(exist_ok=True) # NEVER through a link (r2b, heid bug-hunt). `touch()` followed a
# planted `.blurbooth` symlink: a click rewrote an outside file's
# mtime, or created a dangling target — the class `record_view` was
# hardened against. Anything already at the name, a link included,
# already reads as fogged (`is_booth_blurred`), so there is nothing to
# write; otherwise create exclusively, never following a link.
try:
os.lstat(marker)
return True
except FileNotFoundError:
pass
try:
os.close(os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644))
except FileExistsError:
pass # lost a race to another fog: still fogged
return True return True
try: try:
marker.unlink() marker.unlink()
@@ -680,6 +694,8 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
"has_index": (child / "index.html").is_file(), "has_index": (child / "index.html").is_file(),
"uploaded": (child / UPLOAD_MARKER).exists(), "uploaded": (child / UPLOAD_MARKER).exists(),
"kept": is_kept(child), "kept": is_kept(child),
# r2b D2b: the whole-booth fog, so a blurred strip says why.
"booth_blurred": is_booth_blurred(child),
"marks_total": len(marks), "marks_total": len(marks),
# `open_marks` and nothing else (INV-2). The count this replaced # `open_marks` and nothing else (INV-2). The count this replaced
# tested `answer is None`, so a half-answered pick read as closed # tested `answer is None`, so a half-answered pick read as closed
@@ -746,6 +762,10 @@ def build_gallery(child: Path) -> list[dict]:
suite reaches for it by name in nine places. suite reaches for it by name in nine places.
""" """
out = [] out = []
# r2b D2b: the item's OWN blur, apart from the booth's. `blurred` is the
# composed fact the surfaces render; the per-item control changes only
# this, and must not claim an un-blur the booth flag would override.
own_blur = read_blurred(child)
for it in booth_items(child): for it in booth_items(child):
body = render_doc_body(child, it) body = render_doc_body(child, it)
rendered, rendered_html = body if body is not None else (None, False) rendered, rendered_html = body if body is not None else (None, False)
@@ -768,6 +788,7 @@ def build_gallery(child: Path) -> list[dict]:
"rendered": rendered, "rendered": rendered,
"rendered_html": rendered_html, "rendered_html": rendered_html,
"blurred": it.blurred, "blurred": it.blurred,
"blurred_self": it.rel in own_blur,
} }
) )
return out return out
@@ -1193,6 +1214,8 @@ def create_app(
# The page could not previously tell keep from release, so it # The page could not previously tell keep from release, so it
# offered neither and you had to go back to the index. # offered neither and you had to go back to the index.
"kept": is_kept(booth), "kept": is_kept(booth),
# r2b D2b: the header's blur-booth control says what IS.
"booth_blurred": is_booth_blurred(booth),
# THE GRID RENDERS `shown`; everything else reads `gallery`. # THE GRID RENDERS `shown`; everything else reads `gallery`.
# Filtering is a VIEW: `shown` is `gallery` with non-matching # Filtering is a VIEW: `shown` is `gallery` with non-matching
# items removed and NOTHING re-sorted, so "the third one" means # items removed and NOTHING re-sorted, so "the third one" means
@@ -1824,6 +1847,8 @@ def create_app(
**base_ctx, **base_ctx,
"name": name, "name": name,
"name_url": quote(name, safe=""), "name_url": quote(name, safe=""),
# r2b D2b: the review's blur-booth control says what IS.
"booth_blurred": is_booth_blurred(booth),
"file": f, "file": f,
"file_url": quote(f, safe="/"), "file_url": quote(f, safe="/"),
# The facts this route never used to carry. # The facts this route never used to carry.
@@ -2132,7 +2157,10 @@ def create_app(
booth = resolve_booth(name) booth = resolve_booth(name)
set_booth_blurred(booth, on not in ("0", "false", "")) set_booth_blurred(booth, on not in ("0", "false", ""))
landing = f"/b/{quote(name, safe='')}/" landing = f"/b/{quote(name, safe='')}/"
if back: # The review only for an item of the review ring (r2b, heid bug-hunt),
# exactly as the mark routes' back=view: a stale or foreign `back`
# would otherwise land on a 404. Built from the ring, never echoed.
if back and back in review_chain(booth_items(booth)):
landing += f"view?f={quote(back, safe='/')}" landing += f"view?f={quote(back, safe='/')}"
return RedirectResponse(url=landing, status_code=303) return RedirectResponse(url=landing, status_code=303)
+75 -1
View File
@@ -1,8 +1,22 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en"{% block html_attrs %}{% endblock %}>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
{# r2b: per-browser state applied BEFORE FIRST PAINT, so a revealed booth does
not flash blurred on the next page of the reel. Every change of page is a
full load (the in-place client re-fetches the current URL and never
navigates), so this runs on every page and decides afresh from THIS page's
`data-booth` — booth A's reveal cannot follow you into booth B. Storage that
throws reads as the default and never raises. #}
<script>
(function () {
var d = document.documentElement, b = d.getAttribute('data-booth');
try {
if (b !== null && sessionStorage.getItem('booth.reveal:' + b) === '1') d.classList.add('reveal-all');
} catch (e) {}
})();
</script>
<title>{% block title %}The Booth{% endblock %}</title> <title>{% block title %}The Booth{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2315191d'/%3E%3Cpath d='M7 12V7h5M20 7h5v5M7 20v5h5M25 20v5h-5' fill='none' stroke='%23b2cd12' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23b2cd12'/%3E%3C/svg%3E"> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2315191d'/%3E%3Cpath d='M7 12V7h5M20 7h5v5M7 20v5h5M25 20v5h-5' fill='none' stroke='%23b2cd12' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23b2cd12'/%3E%3C/svg%3E">
{# The two SVOS voices. display=swap and the system stacks in --font-sans / {# The two SVOS voices. display=swap and the system stacks in --font-sans /
@@ -535,6 +549,9 @@
.flagtoggle:has(input[name="on"][value="0"]) button{border-color:var(--success);color:var(--success-text); .flagtoggle:has(input[name="on"][value="0"]) button{border-color:var(--success);color:var(--success-text);
background:var(--success-soft);font-weight:600} background:var(--success-soft);font-weight:600}
.blurtoggle:has(input[name="on"][value="0"]) button{color:var(--text-heading);border-style:dashed} .blurtoggle:has(input[name="on"][value="0"]) button{color:var(--text-heading);border-style:dashed}
/* blurred with the booth, not on its own (r2b D2b): a label, never a button */
.blur-by-booth{font-family:var(--font-mono);font-size:var(--size-micro);padding:4px 7px;
border:1px dashed var(--border-default);border-radius:var(--radius-sm);color:var(--text-muted);cursor:help}
/* A flag is the operator's stamp. Sage, not green: judged, not armed. The /* A flag is the operator's stamp. Sage, not green: judged, not armed. The
stamp is drawn by the tile itself, so no markup is added. */ stamp is drawn by the tile itself, so no markup is added. */
@@ -579,6 +596,33 @@
/* Cover thumbs on the index inherit the blur so the front page cannot undo it. */ /* Cover thumbs on the index inherit the blur so the front page cannot undo it. */
.blurred-thumb{filter:blur(16px)} .blurred-thumb{filter:blur(16px)}
/* REVEAL ALL (r2b D2) — one class on <html>, per booth, per tab. It lifts the
blur on every booth surface; the index never carries data-booth, so the
Desk strip (.blurred-thumb) is deliberately NOT in this list. The per-item
reveal buttons stand down BY STYLESHEET, so tiles swapped in after a save
obey it with no script; an item's own `revealed` class is never touched,
so "blur again" returns each item exactly as it was. */
.reveal-all .item.blurred img,.reveal-all .item.blurred video,
.reveal-all .item.blurred .doc-body,.reveal-all .item.blurred .textview,
.reveal-all .tray-item.is-blurred img,.reveal-all .film-f.is-blurred img,
.reveal-all .review .vstage.is-blurred img,.reveal-all .review .vstage.is-blurred video{filter:none}
.reveal-all .item.blurred .reveal,.reveal-all #vreveal{display:none}
/* The two booth-wide blur controls: the fog (server state, a form) and the
reveal (this tab only, a button). Same quiet chip as the rest of the chrome. */
.blur-all{display:inline-block;margin:0}
.blur-all button,.reveal-all-btn{font-family:var(--font-mono);font-size:var(--size-caption);
padding:5px 9px;border-radius:var(--radius-md);border:1px solid var(--border-default);
background:none;color:var(--text-muted);cursor:pointer;-webkit-backdrop-filter:none;backdrop-filter:none}
.blur-all button:hover,.reveal-all-btn:hover{color:var(--text-body);border-color:var(--border-strong)}
.blur-all.is-on button{color:var(--text-body);border-color:var(--border-strong)}
.reveal-all-btn[aria-pressed="true"]{color:var(--text-body);border-color:var(--border-strong)}
/* One line each, always: a top bar at phone width squeezed these into
four-line stacks. Below 600px the reveal drops its "— blur is cosmetic"
tail; the title still says it, as does every per-item reveal. */
.blur-all button,.reveal-all-btn{white-space:nowrap}
@media (max-width:600px){.reveal-all-btn .ra-note{display:none}}
.badge-blur{color:var(--text-muted)}
/* ---- inline docs ------------------------------------------------------ /* ---- inline docs ------------------------------------------------------
A .md/.txt/.log shows in place, collapsible and closable, spanning the A .md/.txt/.log shows in place, collapsible and closable, spanning the
full grid width so prose has a readable measure. */ full grid width so prose has a readable measure. */
@@ -1030,6 +1074,36 @@
if (window.ResizeObserver) new ResizeObserver(set).observe(rail); if (window.ResizeObserver) new ResizeObserver(set).observe(rail);
})(); })();
</script> </script>
<script>
/* REVEAL ALL (r2b D2). Every [data-reveal-all] control on the page shows the
one state of this booth; all of them sit OUTSIDE every data-region, so an
in-place swap never replaces or unbinds them. The click applies to the
page first and is REMEMBERED second: storage that throws (a private
window) costs the memory, never the click. */
(function () {
var d = document.documentElement, b = d.getAttribute('data-booth');
var ctl = document.querySelectorAll('[data-reveal-all]');
if (b === null || !ctl.length) return;
var key = 'booth.reveal:' + b;
function show(btn) {
var on = d.classList.contains('reveal-all');
btn.querySelector('.ra-label').textContent = on ? '🙈 blur again' : '👁 reveal all';
btn.querySelector('.ra-note').hidden = on;
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
}
ctl.forEach(function (btn) {
btn.hidden = false;
show(btn);
btn.addEventListener('click', function () {
var on = d.classList.toggle('reveal-all');
try {
if (on) sessionStorage.setItem(key, '1'); else sessionStorage.removeItem(key);
} catch (e) {}
ctl.forEach(show);
});
});
})();
</script>
<footer class="foot"> <footer class="foot">
drop a folder into <code>{{ data_dir }}</code>{% if host %} · {{ host }}{% endif %} drop a folder into <code>{{ data_dir }}</code>{% if host %} · {{ host }}{% endif %}
</footer> </footer>
+29 -4
View File
@@ -6,13 +6,20 @@
them, so docs rendered with no control at all. A macro makes "patched two of them, so docs rendered with no control at all. A macro makes "patched two of
three" impossible rather than merely unlikely. #} three" impossible rather than merely unlikely. #}
{% macro blurtoggle(name_url, it, cls='') -%} {% macro blurtoggle(name_url, it, cls='') -%}
{# Blurred only because the whole booth is (r2b D2b): say so, and offer no
per-item un-blur — the booth flag would keep it blurred, so the control
would do nothing visible. The header un-blurs the booth. #}
{% if it.blurred and not it.blurred_self %}
<span class="blurtoggle blur-by-booth {{ cls }}" title="blurred with the whole booth — un-blur the booth in the header">◉ booth</span>
{% else %}
<form class="blurtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/blur"> <form class="blurtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/blur">
<input type="hidden" name="f" value="{{ it.name }}"> <input type="hidden" name="f" value="{{ it.name }}">
<input type="hidden" name="on" value="{{ '0' if it.blurred else '1' }}"> <input type="hidden" name="on" value="{{ '0' if it.blurred_self else '1' }}">
<button title="{{ 'un-blur this item' if it.blurred else 'blur this item — cosmetic only, the file is still served' }}" <button title="{{ 'un-blur this item' if it.blurred_self else 'blur this item — cosmetic only, the file is still served' }}"
aria-label="{{ 'un-blur' if it.blurred else 'blur' }} {{ it.name }}" aria-label="{{ 'un-blur' if it.blurred_self else 'blur' }} {{ it.name }}"
>{{ '◉ blurred' if it.blurred else '◌ blur' }}</button> >{{ '◉ blurred' if it.blurred_self else '◌ blur' }}</button>
</form> </form>
{% endif %}
{%- endmacro %} {%- endmacro %}
{# The per-item MARK controls: flag (the operator pointing at this one) and a {# The per-item MARK controls: flag (the operator pointing at this one) and a
@@ -62,6 +69,7 @@
{%- endmacro %} {%- endmacro %}
{% block title %}{{ name }} · The Booth{% endblock %} {% block title %}{{ name }} · The Booth{% endblock %}
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
{% block content %} {% block content %}
<div class="boothhead"> <div class="boothhead">
<a class="back" href="/">‹ all booths</a> <a class="back" href="/">‹ all booths</a>
@@ -94,6 +102,23 @@
<input type="hidden" name="next" value="/b/{{ name_url }}/"> <input type="hidden" name="next" value="/b/{{ name_url }}/">
<button title="keep — exempt from the TTL sweep">☆ keep</button> <button title="keep — exempt from the TTL sweep">☆ keep</button>
</form> </form>
{% endif %}
{# r2b D2b + D2: the booth-wide blur controls, outside every data-region.
The fog is server state for every viewer and a plain form (works with
scripts off); its label says what IS. Reveal all lifts it for this tab
only, and is markup only when something here is blurred. A BOARD gets
them too when it holds files: only the one-click wipe is board-suppressed,
and an item's "◉ booth" label points here. #}
{% if all_items %}
{# The fog form IS a region: its label is server state, so an in-place save
refreshes it with everything else (a fog set elsewhere since this page
loaded would otherwise leave it saying "blur booth"). Reveal all is not:
its state lives in this tab, and a swap must never reset it. #}
<span class="region-wrap" data-region="blur-booth"><form class="blur-all{% if booth_blurred %} is-on{% endif %}" method="post" action="/b/{{ name_url }}/blurbooth">
<input type="hidden" name="on" value="{{ '0' if booth_blurred else '1' }}">
<button title="{{ 'un-blur the whole booth — per-item blur stays as it was' if booth_blurred else 'blur every image and video in this booth — cosmetic only, the files are still served' }}">{{ '◉ booth blurred' if booth_blurred else '◌ blur booth' }}</button>
</form></span>
{% if all_items | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
{% endif %} {% endif %}
{% if not board %} {% if not board %}
<form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete" <form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete"
+26
View File
@@ -1,11 +1,15 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %} {% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
{% block content %} {% block content %}
<div class="docview"> <div class="docview">
<div class="vbar"> <div class="vbar">
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)">✕</a> <a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)">✕</a>
<span class="vname">{{ file }}</span> <span class="vname">{{ file }}</span>
<span class="vspacer"></span> <span class="vspacer"></span>
{# Reveal all can lift this page's blur, so this page must be able to put it
back (r2b, heid bug-hunt). #}
{% if blurred %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
<a class="vbtn" href="{{ file_url }}?dl=1" title="download {{ file }}">⬇</a> <a class="vbtn" href="{{ file_url }}?dl=1" title="download {{ file }}">⬇</a>
</div> </div>
{# Same record, same reason as the image viewer: the sidecar that says what {# Same record, same reason as the image viewer: the sidecar that says what
@@ -16,11 +20,17 @@
{% for m in marks if m.shape == 'note' %}<pre class="vnote">{{ m.text }}</pre>{% endfor %} {% for m in marks if m.shape == 'note' %}<pre class="vnote">{{ m.text }}</pre>{% endfor %}
</div> </div>
{% endif %} {% endif %}
{# Blur honesty reaches the full page too (r2b, heid code-review): a blurred
doc's own page rendered clear. Its reveal is per-page and JS-only, like the
review stage's; Reveal all lifts it by the same <html> class. #}
<div class="docbody{% if blurred %} is-blurred{% endif %}" id="docbody">
{% if blurred %}<button type="button" class="reveal" id="docreveal" hidden>👁 reveal — blur is cosmetic</button>{% endif %}
{% if is_html %} {% if is_html %}
<article class="markdown-body">{{ body|safe }}</article> <article class="markdown-body">{{ body|safe }}</article>
{% else %} {% else %}
<pre class="textview">{{ body }}</pre> <pre class="textview">{{ body }}</pre>
{% endif %} {% endif %}
</div>
</div> </div>
<style> <style>
/* .markdown-body and .textview live in base.html (shared with the inline /* .markdown-body and .textview live in base.html (shared with the inline
@@ -31,6 +41,14 @@
.doccap{margin:0 0 20px;padding:8px 14px;font-size:var(--size-body);line-height:var(--leading-body); .doccap{margin:0 0 20px;padding:8px 14px;font-size:var(--size-body);line-height:var(--leading-body);
color:var(--text-body);border-left:3px solid var(--border-strong);white-space:pre-wrap} color:var(--text-body);border-left:3px solid var(--border-strong);white-space:pre-wrap}
.docmarks{display:flex;flex-direction:column;gap:8px;margin:0 0 20px} .docmarks{display:flex;flex-direction:column;gap:8px;margin:0 0 20px}
.docbody{position:relative}
.docbody.is-blurred .markdown-body,.docbody.is-blurred .textview{filter:blur(22px);transition:filter var(--dur-2)}
.docbody.is-blurred.revealed .markdown-body,.docbody.is-blurred.revealed .textview,
.reveal-all .docbody.is-blurred .markdown-body,.reveal-all .docbody.is-blurred .textview{filter:none}
.reveal-all #docreveal{display:none}
#docreveal{position:absolute;top:10px;left:10px;z-index:2;cursor:pointer;font-family:var(--font-mono);
font-size:var(--size-micro);line-height:1;padding:6px 9px;border-radius:var(--radius-md);
border:1px solid rgb(255 255 255 / .16);background:oklch(0.17 0.01 250 / .86);color:oklch(0.91 0.008 216)}
</style> </style>
<script> <script>
(function () { (function () {
@@ -41,6 +59,14 @@
return !!(el && (el.isContentEditable || return !!(el && (el.isContentEditable ||
/^(input|textarea|select)$/i.test(el.tagName || ''))); /^(input|textarea|select)$/i.test(el.tagName || '')));
} }
var rv = document.getElementById('docreveal');
if (rv) {
rv.hidden = false;
rv.addEventListener('click', function () {
var on = document.getElementById('docbody').classList.toggle('revealed');
rv.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic';
});
}
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (isEditable(e.target)) return; if (isEditable(e.target)) return;
if (e.key === 'Escape') window.location.href = {{ ('/b/' ~ name_url ~ '/')|tojson }}; if (e.key === 'Escape') window.location.href = {{ ('/b/' ~ name_url ~ '/')|tojson }};
+3 -1
View File
@@ -68,12 +68,14 @@
</div> </div>
{# Badges only: a row with none renders no side column, so it reserves {# Badges only: a row with none renders no side column, so it reserves
no room (the row is flex — an absent item costs no gap). #} no room (the row is flex — an absent item costs no gap). #}
{% if b.marks_open or b.hold == "unreadable" or section == 'new' or b.uploaded %} {% if b.marks_open or b.hold == "unreadable" or section == 'new' or b.uploaded or b.booth_blurred %}
<div class="desk-side"> <div class="desk-side">
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span> {% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>
{% elif b.hold == "unreadable" %}<span class="badge badge-broken">marks unreadable</span> {% elif b.hold == "unreadable" %}<span class="badge badge-broken">marks unreadable</span>
{% elif section == 'new' %}<span class="badge badge-new">new</span>{% endif %} {% elif section == 'new' %}<span class="badge badge-new">new</span>{% endif %}
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %} {% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
{# r2b D2b: a fogged strip says why. Information, not the control. #}
{% if b.booth_blurred %}<span class="badge badge-blur" title="the whole booth is blurred — cosmetic only">◉ blurred</span>{% endif %}
</div> </div>
{% endif %} {% endif %}
</article> </article>
+1
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_lifetime.html" import lifetime %} {% from "_lifetime.html" import lifetime %}
{% block title %}{{ name }} · marks · The Booth{% endblock %} {% block title %}{{ name }} · marks · The Booth{% endblock %}
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
{% block content %} {% block content %}
{# The marks page for a booth whose own index.html is served VERBATIM. That page {# The marks page for a booth whose own index.html is served VERBATIM. That page
cannot render the panel inline (it is returned untouched by design), so the cannot render the panel inline (it is returned untouched by design), so the
+13 -1
View File
@@ -1,5 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %} {% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
{# THE REVIEW (R2 C6). One media item at full size — image, video or audio — {# THE REVIEW (R2 C6). One media item at full size — image, video or audio —
with the judgment on screen beside it, the whole set as a filmstrip below and with the judgment on screen beside it, the whole set as a filmstrip below and
the tape above. Docs keep doc.html. Everything a mark can change is a the tape above. Docs keep doc.html. Everything a mark can change is a
@@ -20,6 +21,14 @@
<button type="button" class="vseg on" id="btn-fit">Fit</button><button type="button" class="vseg" id="btn-one">1:1</button> <button type="button" class="vseg on" id="btn-fit">Fit</button><button type="button" class="vseg" id="btn-one">1:1</button>
</span> </span>
{% endif %} {% endif %}
{# r2b D2b + D2, in the top bar: outside every data-region, so no swap
replaces them. The fog form carries `back` and lands on this item. #}
<span class="region-wrap" data-region="blur-booth"><form class="blur-all{% if booth_blurred %} is-on{% endif %}" method="post" action="/b/{{ name_url }}/blurbooth">
<input type="hidden" name="on" value="{{ '0' if booth_blurred else '1' }}">
<input type="hidden" name="back" value="{{ file }}">
<button title="{{ 'un-blur the whole booth' if booth_blurred else 'blur every image and video in this booth — cosmetic only' }}">{{ '◉ booth blurred' if booth_blurred else '◌ blur booth' }}</button>
</form></span>
{% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a> <a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a>
</div> </div>
@@ -221,7 +230,10 @@
if (e.key === 'Escape') window.location.href = BACK; if (e.key === 'Escape') window.location.href = BACK;
else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV; else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV;
else if (e.key === 'ArrowRight' && NEXT) window.location.href = NEXT; else if (e.key === 'ArrowRight' && NEXT) window.location.href = NEXT;
else if (e.key === ' ' && NEXT && e.target !== player) { e.preventDefault(); window.location.href = e.shiftKey && PREV ? PREV : NEXT; } /* ...and never from a focused control: Space is how a keyboard presses a
button or follows a link (r2b, heid bug-hunt — Reveal all and the fog
control could not be pressed). */
else if (e.key === ' ' && NEXT && e.target !== player && !(e.target.closest && e.target.closest('button, a, summary'))) { e.preventDefault(); window.location.href = e.shiftKey && PREV ? PREV : NEXT; }
else if (e.key === 'f' || e.key === 'F') { else if (e.key === 'f' || e.key === 'F') {
var b = document.getElementById('vflag-btn'); /* re-read: the rail may have been swapped */ var b = document.getElementById('vflag-btn'); /* re-read: the rail may have been swapped */
if (b) { e.preventDefault(); b.click(); } if (b) { e.preventDefault(); b.click(); }
@@ -0,0 +1,320 @@
---
contract_version: "0.1"
status: "PROPOSED 2026-09-23 by design-dev, from operator rulings relayed by booth-dev the same day (thread 01M38BJ30WVQT870MS6WGM49EK): blur=A; three Desk-row rulings; a theme toggle. Contract panel folded. Both open points answered by the operator (thread 01M38CT9DH2N3Z4FSJ0MNE4DR1): × hides (A), and the theme reaches inside verbatim pages. DELIVERED IN TWO MERGES, blur first (operator: 'per booth blurring is now important since we are showing up to 4 images'): merge 1 = D2 + D2b, merge 2 = D1 + D3."
module: "templates + base.html CSS/JS + the vendored token sheet (the Desk row, Reveal all, the theme toggle)"
purpose: "Three operator rulings, one contract. THE DESK ROW: kept vs ephemeral reads at a glance; download/keep/release appear only on hover, at no space cost; the zip link leaves the middle. REVEAL ALL: one control reveals every blurred item in a booth for the life of the tab. THE THEME TOGGLE: System / Light / Dark at the top of every page."
depends_on:
- "booth.items.booth_items + Item.blurred (INV-1 of r2: the one resolver). Reveal all reads Item.blurred and nothing else. booth-dev is adding a booth-level blur flag that feeds Item.blurred (composes with `.blurred`, never overrides); this contract needs no change when it lands."
- "templates/_lifetime.html `lifetime(kept, hold, expires_in)` — its OUTPUT is unchanged; the Desk wraps it."
- "booth.app.index / list_booths row fields `kept`, `hold`, `expires_in`, `name`, `name_url`, `count`, `flags`, `marks_open`, `uploaded` (unchanged)."
- "booth.app.booth_view / booth_view_file contexts (`name`, `items`, the review ring)."
- "the in-place client in base.html (r2 C3): POSTs a form, re-fetches the CURRENT URL and swaps its `data-region` elements. It never navigates — every change of page, booth to booth included, is a full load — and it never touches <html>, the top bar, or anything outside a region."
- "booth.items.is_booth_blurred(booth) + BOOTH_BLUR_FILE `.blurbooth` (booth-dev, c1108a1): the whole-booth blur marker. Fails toward BLURRED on an unreadable read."
- "POST /b/{name}/blurbooth with `on=1|0` and optional `back=<rel>` → 303 to the booth, or to `view?f=<rel>` (booth-dev, c1108a1). Not a mark route: no 204, always the 303."
language: "jinja + css + a little javascript"
complexity: "medium"
estimated_loc: 350
confidence: 0.7
touches:
- "booth/templates/index.html (the row: facts line, lifetime pill, the hover cluster, the `blurred` badge; the confirm script unchanged)"
- "booth/app.py (READS only, no new route: `booth_blurred` in the booth_view and booth_view_file contexts and on each list_booths row; `blurred_self` on each gallery dict from build_gallery's one `read_blurred`)"
- "booth/templates/base.html (Desk row CSS; reveal-all CSS; the theme toggle markup in the top bar; the early <head> script; the toggle script)"
- "booth/templates/booth.html (Reveal all in the booth header; per-tile reveal defers to it)"
- "booth/templates/view.html (Reveal all in the review; the stage reveal defers to it)"
- "booth/templates/_svos_tokens.css (RE-VENDORED at the same SVOS SHA ed2f8d8 with a new scoping transform; no value changes)"
- "booth/static/embed.js (the `.bk-ask` colours follow the theme choice; D3)"
- "tests/test_flow_browser.py, tests/test_flow.py (new tests; two assertions change, see below)"
- "tests/mutations/r2_flow.toml (rows whose anchors this moves are re-aimed, never deleted without a replacement)"
assumptions:
- "ONE VIEWER, per r2. A reveal and a theme are per-browser; the server stores neither."
- "EVERY JUDGMENT WORKS WITH JAVASCRIPT OFF (INV-3 of r2). Keep, release, wipe and zip stay plain forms and a link. Reveal all and the toggle are JS-only affordances and do not render without JS."
resolved_questions:
- "OPEN-1: does × (wipe) hide until hover with download/keep/release? ANSWERED YES by the operator (2026-09-23), the recommendation."
- "Does the theme toggle reach the ask chrome (`.bk-ask`) inside verbatim pages? ANSWERED YES by the operator: 'theme toggle reaches inside'."
---
# R2b — the Desk row, Reveal all, the theme toggle
## D1 — the Desk row
The operator, verbatim: *"let's make it obvious which are kept and which are
ephemeral"*; *"the zip download button is in between keep/release and wipe, and
looks awkward"*; *"let's have the download, keep and release buttons only appear
on mouseover"*.
- **The lifetime is a pill in the row's right column, always visible.** It is
state, not a control, so it stays when the controls hide, and the right column
scans down the Desk as one column of state.
- `life-kept` when `b.kept`: sage (SVOS: a judgment made), prefixed `★`.
- `life-held` when not kept and `b.hold` is `open` or `unreadable`: amber.
- `life-count` otherwise: neutral outline, prefixed `◷`.
- The pill wraps `lifetime(...)`, whose output is unchanged; the class is
chosen from `kept`/`hold` alone.
- The badges (open count, new, pickup, marks unreadable) stay in the same
column, above the pill. The column always renders now, because every row
has a lifetime.
- **The facts line is facts only**: item count and flag count. The lifetime and
every control leave it.
- **The controls are one cluster, `.desk-acts`, in this order:** `⬇ zip`, then
`★ keep` or `release`, then `× wipe`, set apart from the other two. Zip leaves
the middle; release stays next to × (the operator's earlier "x next to
release").
- **Where a real hover exists, the cluster takes no room.** "Real hover" is
`(hover: hover) and (pointer: fine)` with NO coarse pointer present
(`any-pointer: coarse` does not match). A touch laptop reports a mouse, but a
finger on it cannot hover, so it gets the touch treatment below.
- At rest the cluster is absolutely positioned over the top-right corner of
the row's preview strip, at opacity 0 and `pointer-events: none`. On row
`:hover` or `:focus-within`, BOTH are restored: opacity 1 and
`pointer-events: auto`. A visible control that cannot be clicked is a
defect.
- It covers pictures, never information: its box never intersects
`.desk-main` or `.desk-side` at any width.
- Keyboard: the controls stay in the tab order while hidden (opacity, never
`visibility`/`display`), and focusing one reveals the cluster.
- At ≤700px the strip is the row's first line, full width, and the text
column, pill and badges wrap below it. The cluster sits at the strip's
top-right, which is still picture.
- **Everywhere else (no hover, a coarse primary pointer, or any coarse pointer
present), the cluster is visible and in flow**, on its own line at the
bottom of the row. Hover-only would mean no
controls at all on touch. Every control there is at least 28px square
(r2's Slate T2 floor).
- **× hides with the others** (OPEN-1, answered yes). A visible × on every row
would be a standing invitation to the one irreversible action. A visible × on
every row is a standing invitation to the one irreversible action, and hiding
the safe controls while the destructive one stays inverts the priority. It
appears with the cluster, last, set apart.
- Unchanged: the forms, their POST targets, `data-confirm`, `data-booth`, the
confirm script and its `shown()`, the ≥28px coarse-pointer floor, and "no page
scrolls sideways at any width".
### D1b — dates (operator, added 2026-09-23: "I think I want creation and update dates on the booths now too")
Merge 2, with the row. booth-dev has put both on the record (thread
01M38D39ANKF2TW2F15TEYJ1GT):
- `created_at` is the directory's birth time via `statx`, a float epoch, or
None when the filesystem cannot say. None renders as NOTHING, never a guess.
- "Updated" is `landed_at`, the content clock the "new" section already reads.
They render on the Desk row and in the booth header as dated FACTS, a
different kind of thing from the lifetime pill (state) and the controls
(actions). Their exact form is settled in merge 2 against the built row.
## D2 — Reveal all (blur ruling A)
- **One STATE per booth, shown by a control in two places: "👁 reveal all —
blur is cosmetic" / "🙈 blur again".** It appears in the booth header, the
review's top bar and a blurred doc's own top bar. Every instance sits OUTSIDE
every `data-region`, so no in-place swap replaces it: its state lives in the
tab, and a swap must never reset it. Below 600px it reads "👁 reveal all";
its title still says the blur is cosmetic. The server puts the control in the markup only
where it can act, and always with the `hidden` attribute: in the header when
any item of the booth is blurred (`Item.blurred`), and in the review when any
item of the review RING is (a blurred doc is not on the review page, so a
control there would act on nothing). The script removes `hidden` and binds it. Without
JS it is in the markup but never shown.
- **State: `sessionStorage["booth.reveal:" + <booth name>] = "1"`.** Per booth,
per tab, gone when the tab closes, so a blurred booth is blurred again next
time. Nothing reaches the server.
- A READ that throws (a private window, blocked site data) reads as "not
revealed".
- A WRITE that throws still applies the click to the page in front of you:
it is only not remembered for the next page. A control that does nothing
when clicked is a defect. Neither case ever raises.
- **The mechanism is one class on `<html>`, `reveal-all`.** CSS lifts the blur
under it on every booth surface: tiles, the flag tray, the filmstrip and the
review stage. `<html>` is outside every `data-region`, so an in-place swap
can never drop it.
- `<html data-booth="<name>">` is on EVERY page rendered for one booth: the
booth page, the review, the doc view and the marks page. It is an
autoescaped attribute, read by `getAttribute` and never templated into
script. An early `<head>` script adds `reveal-all` before first paint when
that booth's key is set, so a revealed booth does not flash blurred on the
next page of the reel.
- Because every change of page is a full load, the class is re-decided per
page, from that page's `data-booth`. Booth A's reveal cannot follow you
into booth B.
- The index's `<html>` carries no `data-booth` (its rows' own `data-booth`
attributes are unrelated), so **nothing on the index is revealed by D2**,
the Desk strip included.
- **A board holding files gets both controls.** Only the one-click wipe is
board-suppressed; an item's "◉ booth" label points at the header control,
so the control must be there.
- **The full-page doc view is blurred honestly.** A blurred doc's own page
renders its body blurred, with its own JS-only reveal; Reveal all lifts it
by the same `<html>` class.
- **The per-item reveal defers to it, BY STYLESHEET.** Under `.reveal-all` the
per-tile and stage reveal buttons are `display: none`. That is a CSS
consequence of the class, so markup swapped in after a save obeys it with no
script. Reveal all never touches an item's own `revealed` class: "blur
again" returns every item to exactly the per-item state it had, an item
revealed on its own staying revealed.
- INV-8 of r2 holds: the server renders every blurred item blurred; the reveal
stays per-browser and client-side; the copy keeps saying it is cosmetic.
## D2b — the booth blur toggle (added after the contract panel was dispatched)
booth-dev landed the whole-booth marker and its route while the panel was
reading; this is the control the operator uses, which the blur ruling assumed.
- **"◌ blur booth" / "◉ booth blurred" in the booth header and the review's
top bar (`.vbar`)**, each wrapped in its OWN region, `blur-booth`. Its label is
server state, so an in-place save refreshes it with everything else; a fog
set elsewhere since the page loaded would otherwise leave it saying "blur
booth". It is a plain `<form method=post action=/b/<name>/blurbooth>` with
`on=1|0`, so it works with scripts off (INV-2). From the review it carries
`back=<rel>`. The route lands on the review only when `back` is an item of
the review ring, and otherwise on the booth page; the landing is built from
the ring, never echoed.
- **Fogging never writes through a link.** booth-dev's `set_booth_blurred` used
`touch()`, which followed a planted `.blurbooth` symlink: a click of this
control rewrote an outside file's mtime, or created a dangling target. Any
entry already at the name reads as fogged, so nothing is written; otherwise
the marker is created with `O_CREAT | O_EXCL | O_NOFOLLOW`.
- **Space never hijacks a focused control.** The review's Space-to-advance
ignores a focused button, link or summary, so a keyboard can press these
controls.
- **Its state comes from the server**, never from the client:
`booth_blurred = is_booth_blurred(booth)` in both contexts. The label says
what IS, and pressing it flips it.
- **The Desk row carries a `blurred` badge** when `booth_blurred`, so a fogged
strip says why. It is one `is_booth_blurred` call per booth in the pass
`list_booths` already makes. The badge is information: it is not the
control, and it does not hide on hover.
- It is independent of Reveal all. Fogging a booth sets server state for every
viewer; Reveal all lifts the fog for one tab.
- **Each item's own blur control tells the truth under a fogged booth.**
`Item.blurred` is the COMPOSED fact (own OR booth). The per-item form changes
only the item's own entry in `.blurred`, so the gallery also carries
`blurred_self`, read from the same single `read_blurred`.
- An item blurred only because the booth is shows "◉ booth", a label with no
form, pointing at the header. A per-item un-blur there would be overridden
by the booth flag and visibly do nothing.
- An item blurred on its own keeps its "◉ blurred" un-blur.
- Found by rendering the built page, not by any review.
## D3 — the theme toggle
- **System · Light · Dark, in the top bar of every page.** A segmented control
of three buttons with `aria-pressed`. The top bar is outside every
`data-region`, so no swap replaces it. It is in the markup with `hidden`,
and the script removes that and binds it.
- **State: `localStorage["booth.theme"]` ∈ {`light`, `dark`}; absent = System.**
The opposite lifetime to Reveal all, deliberately: a theme should outlive the
tab, a reveal must not. A READ that throws reads as System. A WRITE that
throws still applies the choice to this page, and it is only not
remembered.
- **Mechanism: `data-theme` on `<html>`.** Absent = the OS preference, exactly
today's sheet. An early `<head>` script sets it before first paint, so a
forced theme never flashes the other one.
- **System is live-following BY CONSTRUCTION.** Choosing System removes
`data-theme`, and the `prefers-color-scheme` media query takes over. A media
query tracks the OS live, so no `matchMedia` listener is needed: JS never
computes the theme.
- **The token sheet is re-vendored at the same SVOS SHA (ed2f8d8) with a new
scoping transform, and no value changes.** The complete selector list:
| block | selector |
|---|---|
| primitives, dark, art layer | `:root` (unconditional: dark is the default, and what forced dark leaves standing) |
| light + art-light | `@media (prefers-color-scheme: light)` → `:root:not([data-theme="dark"])` |
| light + art-light | `:root[data-theme="light"]` |
| dark-hc | `@media (prefers-contrast: more)` → `:root` |
| light-hc | `@media (prefers-contrast: more) and (prefers-color-scheme: light)` → `:root:not([data-theme="dark"])` |
| light-hc | `@media (prefers-contrast: more)` → `:root[data-theme="light"]` |
- **Forced dark** excludes both light rows, so the unconditional dark block
stands, with dark-hc under more contrast.
- **Forced light** matches the bare light row at specificity (0,2,0), which
beats dark-hc's `:root` (0,1,0). The light-hc row then applies under more
contrast.
- **The preservation check runs in BOTH directions at vendoring time**:
every declaration of the old sheet is in the new, and the new has none the
old lacked. The committed test checks each re-scoped copy against the
UNMOVED dark block. SVOS's light and dark declare the same 42 properties,
and its dark-hc and light-hc the same 41, so a declaration the transform
drops fails it.
- **No JS: no toggle, and the page follows the OS**, as today.
- **The toggle reaches inside verbatim pages** (operator: "theme toggle reaches
inside"). `embed.js` reads the same `localStorage["booth.theme"]` (the same
origin) and marks each `.bk-ask` it injects with `data-bk-theme`. Its
colours follow that attribute exactly as the Booth's own sheet follows
`data-theme`: forced when set, OS when absent. It follows a change made in
another tab through the `storage` event. It sets nothing on the host page's
own `<html>`: the author's page is not ours to theme, only our guest chrome
inside it. If a forced theme makes the chrome look actively broken against
a host page, that goes back to the operator rather than being absorbed.
## Invariants
- **INV-1 — nothing new on the server beyond READS.** No route and no file are
added: `booth_blurred` in two contexts and on the Desk row (`is_booth_blurred`),
and `blurred_self` per gallery item (`read_blurred`, once per page). D2 and D3 are per-browser state; D1 is markup and CSS.
- **INV-2 — JS-off parity (r2 INV-3).** Every control on the row works with
scripts off. Reveal all and the toggle do not render without JS. The page
follows the OS.
- **INV-3 — no reserved room for a hidden control** where a real hover exists.
The box of every element in the row other than the cluster — the strip, each
preview image, the text column, the side column, the pill — is identical
with the cluster present or removed.
- **INV-4 — blur honesty (r2 INV-8).** Nothing on the index is revealed by D2.
- **INV-5 — autoescape.** The booth name reaches the reveal-state machinery
only as an escaped attribute value (`data-booth`), read by `getAttribute` and
never templated into a script.
- **INV-6 — no flash.** A forced theme and a set reveal are applied before
first paint.
## TESTS
- `the_row_controls_take_no_room_where_a_hover_exists` [tracer]: at rest the
cluster is at opacity 0 and cannot be clicked. On row hover it is at opacity 1
and a click on keep reaches the server. The box of every other element in
the row is identical with the cluster removed. The cluster's box never
intersects the text or side column, at 390 / 720 / 1000 / 1400px.
- `on_touch_the_row_controls_are_visible_in_flow_and_at_least_28px`: a touch
context (no hover, coarse pointer).
- `the_lifetime_pill_class_is_kept_held_or_counting`: the class is chosen by
state, the lifetime words are unchanged, and the pill is visible with no
hover.
- `the_row_controls_run_zip_keep_or_release_then_wipe`.
- `reveal_all_reveals_every_blurred_surface_and_survives_the_next_page`: tiles
and tray on the booth page, stage and filmstrip on the review, across a
navigation in the same tab; a fresh tab (new context) is blurred again.
- `reveal_all_never_reaches_the_desk`.
- `the_booth_blur_toggle_works_without_js_and_lands_back_on_the_review`: header
and review forms POST `/blurbooth`; the label follows `is_booth_blurred`; the
review form carries `back`; the Desk row shows `blurred`.
- `reveal_all_survives_an_in_place_save`: after a save, the blur is still
lifted, the control still reads "blur again" and still works, and the
per-tile buttons are still hidden.
- `reveal_all_on_booth_a_does_not_reveal_booth_b`.
- `blur_again_restores_each_items_own_reveal`.
- `reveal_all_is_absent_without_blurred_items_and_hidden_without_js`: no markup
when nothing is blurred; with blurred items, the markup carries `hidden` and
a JS-disabled context never shows it.
- `a_storage_failure_still_applies_the_click`: sessionStorage and localStorage
throwing on write; the reveal and the theme still apply to the page.
- `the_theme_toggle_forces_light_and_dark_and_system_follows_the_os_live`:
pressing Light/Dark changes `--surface-base` and survives a reload (new page,
same context); System plus an emulated OS scheme flip changes it WITHOUT a
reload.
- `a_forced_theme_follows_high_contrast`: forced dark + `prefers-contrast:
more` resolves dark-hc's surface token.
- `a_forced_theme_and_the_os_theme_are_the_same_declarations`: each light copy
equals the other and declares exactly the dark block's property set; each
light-hc copy equals the other and declares exactly the dark-hc block's.
## Assertions that change (declared before the code)
| test | today | after | why |
|---|---|---|---|
| test_flow_browser `test_a_rows_keep_release_and_wipe_take_no_room_of_their_own` | controls visible at rest; a row with no badge has no side column (gap ≤14px) | replaced by `the_row_controls_take_no_room_where_a_hover_exists` | the operator ruled hover-reveal; the side column now always holds the lifetime pill |
| test_flow_browser `test_on_a_touch_screen_the_row_controls_keep_their_tap_floor` | measures `.desk-facts form button` | the same floor, measured on `.desk-acts` controls | the controls moved; the floor did not |
## Out of scope
- The booth page header's keep / release / wipe (`.keep-lg`, `.wipe-lg`) — the
rulings named the Desk row.
- A site-wide blur switch (ruling A, not B).
- The tagline copy.
+135
View File
@@ -0,0 +1,135 @@
"""Browser-test failure artefacts: a Playwright trace kept for every browser
test that FAILS, captured from the run that failed.
Why this exists: the browser tests flake under FULL-SUITE load only — three
different tests have each failed once, every one passes in isolation, and a
narrowed repro that passes is the trap (operator, 2026-09-23: "let him diagnose
it properly"). Pass/fail counts cannot say why; a trace — screenshots, DOM
snapshots, console and network per action — can.
OPT-IN, because tracing is not free and the harness is part of the number:
with it on, every page does more work, so the suite's timing (the very thing
under suspicion) moves. Default runs are untouched.
BOOTH_TRACE=1 .venv/bin/python -m pytest -q # screenshots + DOM snapshots
BOOTH_TRACE=light .venv/bin/python -m pytest -q # actions + network only
LIGHT exists because the full mode perturbs the thing it watches: 8 traced
full-suite runs went 8/8 green while untraced runs on the same tree went red.
Network and action records are nearly free, and a goto that never reaches
"networkidle" is answered by the network record alone — which request never
finished.
Traces land in $BOOTH_TRACE_DIR (default: <tmp>/booth-test-traces/<run>/),
named after the test; open one with `playwright show-trace <file>`. The
terminal summary lists every trace kept.
"""
from __future__ import annotations
import os
import shutil
import tempfile
import time
from pathlib import Path
import pytest
TRACE_MODE = os.environ.get("BOOTH_TRACE", "")
TRACE = TRACE_MODE in ("1", "light")
_KEPT: list[Path] = []
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
setattr(item, "rep_" + rep.when, rep)
def _trace_dir() -> Path:
root = os.environ.get("BOOTH_TRACE_DIR") or os.path.join(tempfile.gettempdir(), "booth-test-traces")
d = Path(root) / time.strftime("%Y%m%d-%H%M%S", time.localtime(_RUN_STARTED))
d.mkdir(parents=True, exist_ok=True)
return d
_RUN_STARTED = time.time()
@pytest.fixture(autouse=True)
def _trace_browser_tests(request):
"""Wrap the module's `browser` so every context it opens is traced.
A test usually closes its page BEFORE asserting (it collects, closes, then
checks), and a closed context can no longer write its trace — so each
context's trace is written at close time, to a scratch file, and only moved
to the kept set if the test then fails. A page from `browser.new_page` owns
its context, as Playwright's own does: closing the page closes it."""
if not TRACE or "browser" not in request.fixturenames:
yield
return
browser = request.getfixturevalue("browser")
scratch = Path(tempfile.mkdtemp(prefix="booth-trace-"))
written: list[Path] = []
opened: list = []
real_new_context = browser.new_context
def stop(ctx, n=[0]):
if getattr(ctx, "_booth_traced", False):
ctx._booth_traced = False
n[0] += 1
path = scratch / f"{n[0]}.zip"
try:
ctx.tracing.stop(path=str(path))
written.append(path)
except Exception: # noqa: BLE001 - a lost trace must not fail the test
pass
def new_context(*args, **kwargs):
ctx = real_new_context(*args, **kwargs)
heavy = TRACE_MODE == "1"
ctx.tracing.start(screenshots=heavy, snapshots=heavy)
ctx._booth_traced = True
real_close = ctx.close
def close(*a, **k):
stop(ctx)
return real_close(*a, **k)
ctx.close = close
opened.append(ctx)
return ctx
def new_page(*args, **kwargs):
ctx = new_context(*args, **kwargs)
page = ctx.new_page()
page.close = lambda *a, **k: ctx.close()
return page
browser.new_context, browser.new_page = new_context, new_page
try:
yield
finally:
del browser.new_context, browser.new_page
for ctx in opened:
stop(ctx)
try:
ctx.close()
except Exception: # noqa: BLE001
pass
rep = getattr(request.node, "rep_call", None)
if rep is not None and rep.failed and written:
dest = _trace_dir()
for i, path in enumerate(written, 1):
kept = dest / f"{request.node.name}-{i}.zip"
shutil.move(str(path), kept)
_KEPT.append(kept)
shutil.rmtree(scratch, ignore_errors=True)
def pytest_terminal_summary(terminalreporter):
if _KEPT:
terminalreporter.section("browser traces kept for failed tests")
for p in _KEPT:
terminalreporter.write_line(str(p))
+284
View File
@@ -0,0 +1,284 @@
# R2b — the Desk row, Reveal all, the theme toggle: every falsifier the
# contract claims (docs/contracts/r2b_desk_reveal_theme.contract.md), and the
# change each forbids. Merge 1 is D2 + D2b (the blur half); merge 2 adds D1 + D3.
unit = "reveal all, the booth blur toggle (merge 1)"
[[mutation]]
label = "D2 reveal all does not lift the tile's blur"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_reveal_all_reveals_every_blurred_surface_and_survives_the_next_page"
old = '''
.reveal-all .item.blurred img,.reveal-all .item.blurred video,'''
new = '''
.reveal-all-OFF .item.blurred img,.reveal-all .item.blurred video,'''
[[mutation]]
label = "D2 no pre-paint re-application: the next page of the reel is blurred again"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_reveal_all_reveals_every_blurred_surface_and_survives_the_next_page"
old = '''
if (b !== null && sessionStorage.getItem('booth.reveal:' + b) === '1') d.classList.add('reveal-all');'''
new = '''
if (false) d.classList.add('reveal-all');'''
[[mutation]]
label = "D2 the reveal is not scoped to the booth (any reveal in the tab reveals every booth)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_reveal_all_on_booth_a_does_not_reveal_booth_b_or_the_desk"
old = '''
if (b !== null && sessionStorage.getItem('booth.reveal:' + b) === '1') d.classList.add('reveal-all');'''
new = '''
if (b !== null && sessionStorage.length > 0) d.classList.add('reveal-all');'''
[[mutation]]
label = "D2 per-tile reveal buttons do not stand down under reveal all"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_reveal_all_survives_an_in_place_save"
old = '''
.reveal-all .item.blurred .reveal,.reveal-all #vreveal{display:none}'''
new = '''
.reveal-all-OFF .item.blurred .reveal,.reveal-all #vreveal{display:none}'''
[[mutation]]
label = "D2 blur again wipes each item's own reveal"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_blur_again_restores_each_items_own_reveal"
old = '''
var on = d.classList.toggle('reveal-all');
try {'''
new = '''
var on = d.classList.toggle('reveal-all');
document.querySelectorAll('.item.revealed').forEach(function (i) { i.classList.remove('revealed'); });
try {'''
[[mutation]]
label = "D2 a storage write that throws swallows the click"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_reveal_all_never_shows_without_js_and_a_storage_failure_still_applies_the_click"
old = '''
var on = d.classList.toggle('reveal-all');
try {'''
new = '''
sessionStorage.setItem(key, '1');
var on = d.classList.toggle('reveal-all');
try {'''
[[mutation]]
label = "D2 the control shows without JS (server markup not hidden)"
file = "booth/templates/booth.html"
test = "tests/test_flow_browser.py::test_reveal_all_never_shows_without_js_and_a_storage_failure_still_applies_the_click"
old = '''
{% if all_items | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title='''
new = '''
{% if all_items | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all title='''
[[mutation]]
label = "D2 the control is markup even when nothing is blurred"
file = "booth/templates/booth.html"
test = "tests/test_flow.py::test_reveal_all_is_in_the_markup_only_when_something_is_blurred_and_always_hidden"
old = '''
{% if all_items | selectattr('blurred') | list %}<button'''
new = '''
{% if true %}<button'''
[[mutation]]
label = "D2 the review page does not carry data-booth"
file = "booth/templates/view.html"
test = "tests/test_flow.py::test_reveal_all_is_in_the_markup_only_when_something_is_blurred_and_always_hidden"
old = '''
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}'''
new = '''
{% block html_attrs %}{% endblock %}'''
[[mutation]]
label = "D2b the header control's label does not follow the server's fog state"
file = "booth/templates/booth.html"
test = "tests/test_flow.py::test_the_booth_blur_toggle_works_without_js_and_lands_back_on_the_review"
old = '''
<input type="hidden" name="on" value="{{ '0' if booth_blurred else '1' }}">
<button title="{{ 'un-blur the whole booth'''
new = '''
<input type="hidden" name="on" value="1">
<button title="{{ 'un-blur the whole booth'''
[[mutation]]
label = "D2b the review's control drops `back` (fogging ejects you from the review)"
file = "booth/templates/view.html"
test = "tests/test_flow.py::test_the_booth_blur_toggle_works_without_js_and_lands_back_on_the_review"
old = '''
<input type="hidden" name="back" value="{{ file }}">'''
new = '''
'''
[[mutation]]
label = "D2b the Desk row does not say a booth is fogged"
file = "booth/templates/index.html"
test = "tests/test_flow.py::test_the_booth_blur_toggle_works_without_js_and_lands_back_on_the_review"
old = '''
{% if b.booth_blurred %}<span class="badge badge-blur"'''
new = '''
{% if false %}<span class="badge badge-blur"'''
[[mutation]]
label = "D2b an item blurred only by the booth offers a per-item un-blur that does nothing"
file = "booth/templates/booth.html"
test = "tests/test_flow.py::test_under_a_fogged_booth_each_items_blur_control_tells_the_truth"
old = '''
{% if it.blurred and not it.blurred_self %}'''
new = '''
{% if false %}'''
[[mutation]]
label = "D2b the per-item control reads the composed blur, not the item's own"
file = "booth/app.py"
test = "tests/test_flow.py::test_under_a_fogged_booth_each_items_blur_control_tells_the_truth"
old = '''
"blurred_self": it.rel in own_blur,'''
new = '''
"blurred_self": it.blurred,'''
# ---- folds: the heid code-review ("BLITZ-2") and bug-hunt ("FENRIR-6") panels on merge 1
[[mutation]]
label = "a board holding files loses the blur controls its labels point at"
file = "booth/templates/booth.html"
test = "tests/test_flow.py::test_a_board_with_files_gets_the_blur_controls_its_labels_point_at"
old = '''
{% if all_items %}
{# The fog form IS a region'''
new = '''
{% if not board %}
{# The fog form IS a region'''
[[mutation]]
label = "a blurred doc's own page renders clear"
file = "booth/templates/doc.html"
test = "tests/test_flow.py::test_a_blurred_docs_own_page_is_blurred_too"
old = '''
<div class="docbody{% if blurred %} is-blurred{% endif %}" id="docbody">'''
new = '''
<div class="docbody" id="docbody">'''
[[mutation]]
label = "the review offers Reveal all when only a doc (off the ring) is blurred"
file = "booth/templates/view.html"
test = "tests/test_flow.py::test_reveal_all_renders_where_it_can_act"
old = '''
{% if film | selectattr('blurred') | list %}<button'''
new = '''
{% if true %}<button'''
[[mutation]]
label = "the fog form is a GET (changes nothing with scripts off)"
file = "booth/templates/booth.html"
test = "tests/test_flow.py::test_the_booth_blur_toggle_works_without_js_and_lands_back_on_the_review"
old = '''<form class="blur-all{% if booth_blurred %} is-on{% endif %}" method="post" action="/b/{{ name_url }}/blurbooth">
<input type="hidden" name="on" value="{{ '0' if booth_blurred else '1' }}">
<button title="{{ 'un-blur the whole booth — per-item'''
new = '''<form class="blur-all{% if booth_blurred %} is-on{% endif %}" method="get" action="/b/{{ name_url }}/blurbooth">
<input type="hidden" name="on" value="{{ '0' if booth_blurred else '1' }}">
<button title="{{ 'un-blur the whole booth — per-item'''
[[mutation]]
label = "the swap stops carrying an item's own reveal"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_an_items_own_reveal_survives_an_in_place_save"
old = '''
['revealed', 'is-closed'].forEach(function (c) {'''
new = '''
['is-closed'].forEach(function (c) {'''
[[mutation]]
label = "a storage READ that throws raises out of the pre-paint script"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_reveal_all_never_shows_without_js_and_a_storage_failure_still_applies_the_click"
old = '''
try {
if (b !== null && sessionStorage.getItem('booth.reveal:' + b) === '1') d.classList.add('reveal-all');
} catch (e) {}'''
new = '''
if (b !== null && sessionStorage.getItem('booth.reveal:' + b) === '1') d.classList.add('reveal-all');'''
[[mutation]]
label = "fogging writes through a planted marker link"
file = "booth/app.py"
test = "tests/test_flow.py::test_fogging_never_writes_through_a_planted_marker_link"
old = '''
try:
os.lstat(marker)
return True
except FileNotFoundError:
pass
try:
os.close(os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644))
except FileExistsError:
pass # lost a race to another fog: still fogged
return True'''
new = '''
marker.touch(exist_ok=True)
return True'''
[[mutation]]
label = "the fog landing echoes `back` unchecked"
file = "booth/app.py"
test = "tests/test_flow.py::test_the_fog_landing_is_built_from_the_ring_never_echoed"
old = '''
if back and back in review_chain(booth_items(booth)):'''
new = '''
if back:'''
[[mutation]]
label = "the fog form is outside every region (a swap leaves its label stale)"
file = "booth/templates/booth.html"
test = "tests/test_flow.py::test_the_booth_blur_toggle_works_without_js_and_lands_back_on_the_review"
old = '''
<span class="region-wrap" data-region="blur-booth"><form class="blur-all'''
new = '''
<span class="region-wrap"><form class="blur-all'''
[[mutation]]
label = "Space on a focused review button moves to the next item"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_space_on_a_focused_review_button_presses_it_and_does_not_move_on"
old = '''e.target !== player && !(e.target.closest && e.target.closest('button, a, summary'))) {'''
new = '''e.target !== player) {'''
[[mutation]]
label = "the top-bar controls squeeze into multi-line stacks at phone width"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_review_and_doc_top_bars_fit_a_phone"
old = '''
.blur-all button,.reveal-all-btn{white-space:nowrap}
@media (max-width:600px){.reveal-all-btn .ra-note{display:none}}'''
new = '''
'''
[[mutation]]
label = "the Desk strip under another booth's reveal is lifted by a whisker (blur(0px) is not blurred)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_reveal_all_on_booth_a_does_not_reveal_booth_b_or_the_desk"
old = '''
.blurred-thumb{filter:blur(16px)}'''
new = '''
.blurred-thumb{filter:blur(0px)}'''
# ---- the flake: the test browser has no internet (positive control per file)
[[mutation]]
label = "the flow test browser can reach the internet (Google Fonts can stall networkidle)"
file = "tests/test_flow_browser.py"
test = "tests/test_flow_browser.py::test_the_test_browser_has_no_internet"
old = '''
b = pw.chromium.launch(args=OFFLINE)'''
new = '''
b = pw.chromium.launch()'''
[[mutation]]
label = "the embed test browser can reach the internet"
file = "tests/test_embed_browser.py"
test = "tests/test_embed_browser.py::test_the_test_browser_has_no_internet"
old = '''
b = pw.chromium.launch(args=OFFLINE)'''
new = '''
b = pw.chromium.launch()'''
+33 -1
View File
@@ -31,11 +31,22 @@ playwright_api = pytest.importorskip(
) )
# NO INTERNET for the test browser. Every Booth page asks fonts.googleapis.com
# for its faces, and "networkidle" waits for that request — so a stalled request
# to Google hung the page until goto's 30s timeout, the failure mode of the
# full-suite flake (Page.goto timeouts in tests far apart in one run; a stalled
# font request reproduces it exactly). Whether that was THE cause is unproven;
# a test that depends on Google being reachable is wrong regardless. Every
# hostname but 127.0.0.1 now fails DNS at once, and the pages fall back to the
# system stacks the tokens declare. Positive control: test_*_has_no_internet.
OFFLINE = ["--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE 127.0.0.1"]
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def browser(): def browser():
with playwright_api.sync_playwright() as pw: with playwright_api.sync_playwright() as pw:
try: try:
b = pw.chromium.launch() b = pw.chromium.launch(args=OFFLINE)
except Exception as exc: # noqa: BLE001 - any launch failure is a skip except Exception as exc: # noqa: BLE001 - any launch failure is a skip
pytest.skip(f"no usable chromium: {exc}") pytest.skip(f"no usable chromium: {exc}")
yield b yield b
@@ -638,3 +649,24 @@ def test_the_keyboard_flag_actually_submits(browser, live):
assert flagged == 1, f"the f key flagged {flagged} items, expected 1" assert flagged == 1, f"the f key flagged {flagged} items, expected 1"
assert survived, "the flag reloaded the page; in-place judgment must not" assert survived, "the flag reloaded the page; in-place judgment must not"
def test_the_test_browser_has_no_internet(browser, live):
"""Positive control for OFFLINE (booth-dev's ask: see the fix in force,
don't assume it). An external host fails at once, and a Booth page — whose
fonts are external — still goes idle in well under the goto timeout."""
base, root = live
(root / "g").mkdir()
page = browser.new_page()
t = time.time()
with pytest.raises(Exception) as err:
page.goto("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans", timeout=10000)
external = time.time() - t
page.close()
page = browser.new_page()
t = time.time()
page.goto(f"{base}/b/g/", wait_until="networkidle")
local = time.time() - t
page.close()
assert "ERR_NAME_NOT_RESOLVED" in str(err.value) and external < 3, (str(err.value)[:80], external)
assert local < 10, local
+153
View File
@@ -844,3 +844,156 @@ def test_the_desk_never_makes_a_non_web_url_clickable(tmp_path):
benches = re.search(r'data-panel="benches".*?</section>', body, re.S).group(0) benches = re.search(r'data-panel="benches".*?</section>', body, re.S).group(0)
assert 'href="javascript:' not in benches assert 'href="javascript:' not in benches
assert 'href="http://h:1/"' in benches and "evil bench" in benches assert 'href="http://h:1/"' in benches and "evil bench" in benches
def _regions(body: str) -> list[str]:
"""Every data-region element's full markup."""
return [_region(body, rid) for rid in dict.fromkeys(re.findall(r'data-region="([^"]+)"', body))]
def test_the_booth_blur_toggle_works_without_js_and_lands_back_on_the_review(tmp_path):
"""r2b D2b: the operator's control for booth-dev's whole-booth marker. A
plain form — scripts off, it still works — whose label says what IS
(read from the server), in the booth header and the review's top bar; the
review's carries `back` and lands on the same item. The Desk row says
`blurred` so a fogged strip says why."""
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
c = _client(tmp_path)
page = c.get("/b/g/").text
form = re.search(r'<form[^>]*action="/b/g/blurbooth".*?</form>', page, re.S).group(0)
assert 'method="post"' in form, "a GET form would not change anything with scripts off"
assert 'name="on" value="1"' in form and "blur booth" in form
# a REGION: its label is server state, so an in-place save refreshes it
assert form in _region(page, "blur-booth")
assert "badge-blur" not in re.search(r'data-booth="g".*?</article>', c.get("/").text, re.S).group(0)
r = c.post("/b/g/blurbooth", data={"on": "1"}, follow_redirects=False)
assert r.status_code == 303 and (b / ".blurbooth").exists()
page = c.get("/b/g/").text
form = re.search(r'<form[^>]*action="/b/g/blurbooth".*?</form>', page, re.S).group(0)
assert 'name="on" value="0"' in form and "booth blurred" in form
row = re.search(r'data-booth="g".*?</article>', c.get("/").text, re.S).group(0)
assert re.search(r'<span class="badge[^"]*badge-blur[^"]*"[^>]*>[^<]*blurred</span>', row)
view = c.get("/b/g/view?f=b.png").text
vbar = re.search(r'<div class="vbar">.*?</div>\s*\n', view, re.S).group(0)
vform = re.search(r'<form[^>]*action="/b/g/blurbooth".*?</form>', vbar, re.S).group(0)
assert 'method="post"' in vform
assert 'name="back" value="b.png"' in vform and 'name="on" value="0"' in vform
r = c.post("/b/g/blurbooth", data={"on": "0", "back": "b.png"}, follow_redirects=False)
assert r.headers["location"] == "/b/g/view?f=b.png" and not (b / ".blurbooth").exists()
def test_reveal_all_is_in_the_markup_only_when_something_is_blurred_and_always_hidden(tmp_path):
"""r2b D2: the control is server markup only when the booth has a blurred
item, always with `hidden` (the script unhides it; with JS off it never
shows), and always OUTSIDE every data-region so no in-place swap replaces
it. Every page rendered for one booth carries `data-booth` on <html>; the
index carries none, so nothing there can be revealed."""
from booth.app import set_blurred
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG, "n.md": b"# n"})
c = _client(tmp_path)
assert not re.search(r"<button[^>]*data-reveal-all", c.get("/b/g/").text)
set_blurred(b, "a.png", True)
for url in ("/b/g/", "/b/g/view?f=a.png", "/b/g/view?f=b.png"):
page = c.get(url).text
ctl = re.findall(r'<button[^>]*data-reveal-all[^>]*>', page)
assert len(ctl) == 1 and " hidden" in ctl[0], (url, ctl)
assert all(not re.search(r"<button[^>]*data-reveal-all", r) for r in _regions(page)), url
for url in ("/b/g/", "/b/g/view?f=a.png", "/b/g/view?f=n.md", "/b/g/marks"):
assert re.search(r'<html lang="en" data-booth="g">', c.get(url).text), url
assert re.search(r'<html lang="en">', c.get("/").text)
def test_under_a_fogged_booth_each_items_blur_control_tells_the_truth(tmp_path):
"""r2b D2b, found rendering it: in a fogged booth every item reports
`blurred`, so an item blurred ONLY by the booth offered "◉ blurred" and an
un-blur that visibly did nothing (the booth still fogged it). Its control
now says it is blurred with the booth and offers no per-item action; an item
blurred in its own right keeps its own un-blur."""
from booth.app import set_blurred
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
set_blurred(b, "a.png", True)
(b / ".blurbooth").write_bytes(b"")
page = _client(tmp_path).get("/b/g/").text
fig = lambda rel: re.search(r'<figure[^>]*data-item="%s".*?</figure>' % re.escape(rel), page, re.S).group(0)
own, booth = fig("a.png"), fig("b.png")
assert re.search(r'action="/b/g/blur".*?name="on" value="0".*?◉ blurred', own, re.S)
assert 'action="/b/g/blur"' not in booth
assert "◉ booth" in booth
def test_a_board_with_files_gets_the_blur_controls_its_labels_point_at(tmp_path):
"""heid code-review (4/4): both booth-wide controls sat inside the board
suppression meant for the one-click wipe, so a links board holding a fogged
picture showed "◉ booth — un-blur the booth in the header" with no such
control in the header. Only the wipe is board-suppressed."""
b = _booth(tmp_path, "links", {"links.md": b"- [x](https://example.test/)\n", "a.png": PNG})
(b / ".blurbooth").write_bytes(b"")
page = _client(tmp_path).get("/b/links/").text
assert re.search(r'<form[^>]*action="/b/links/blurbooth"', page)
assert re.search(r"<button[^>]*data-reveal-all", page)
def test_reveal_all_renders_where_it_can_act(tmp_path):
"""heid code-review (3/4): the header offers Reveal all when ANY item is
blurred; the review only when an item of the review RING is — a blurred doc
is not on the review page, so a control there would act on nothing."""
from booth.app import set_blurred
b = _booth(tmp_path, "g", {"a.png": PNG, "n.md": b"# n"})
set_blurred(b, "n.md", True)
c = _client(tmp_path)
assert re.search(r"<button[^>]*data-reveal-all", c.get("/b/g/").text)
assert not re.search(r"<button[^>]*data-reveal-all", c.get("/b/g/view?f=a.png").text)
def test_a_blurred_docs_own_page_is_blurred_too(tmp_path):
"""heid code-review (hulda): the full-page doc view never read `blurred`,
so a blurred doc rendered clear at the size where it is most readable.
Its body is blurred there too, with its own JS-only reveal."""
from booth.app import set_blurred
b = _booth(tmp_path, "g", {"n.md": b"# secret", "o.md": b"# open"})
set_blurred(b, "n.md", True)
c = _client(tmp_path)
blurred, clear = c.get("/b/g/view?f=n.md").text, c.get("/b/g/view?f=o.md").text
assert re.search(r'class="docbody is-blurred"', blurred)
assert re.search(r'<button[^>]*id="docreveal"[^>]*hidden', blurred)
assert 'class="docbody"' in clear and not re.search(r'<button[^>]*id="docreveal"', clear)
def test_fogging_never_writes_through_a_planted_marker_link(tmp_path):
"""heid bug-hunt (kimi, hulda): `marker.touch()` followed a planted
`.blurbooth` symlink — a click of the new browser control rewrote an
outside file's mtime, or CREATED a dangling target. The same class
`record_view` was hardened against. A link already there reads as fogged
(is_booth_blurred counts it), so fogging has nothing to write."""
import os
outside = tmp_path / "outside.txt"
outside.write_text("x")
os.utime(outside, (1_000_000, 1_000_000))
b = _booth(tmp_path, "g", {"a.png": PNG})
(b / ".blurbooth").symlink_to(outside)
h = _booth(tmp_path, "h", {"a.png": PNG})
(h / ".blurbooth").symlink_to(tmp_path / "created-by-a-click")
c = _client(tmp_path)
assert c.post("/b/g/blurbooth", data={"on": "1"}, follow_redirects=False).status_code == 303
assert c.post("/b/h/blurbooth", data={"on": "1"}, follow_redirects=False).status_code == 303
assert outside.stat().st_mtime == 1_000_000
assert not (tmp_path / "created-by-a-click").exists()
c.post("/b/g/blurbooth", data={"on": "0"}, follow_redirects=False)
assert not (b / ".blurbooth").is_symlink() and outside.exists() # unlinked, target untouched
def test_the_fog_landing_is_built_from_the_ring_never_echoed(tmp_path):
"""heid bug-hunt (kimi, regin, groa): `back` went into the 303 unchecked, so
a stale or foreign value landed on a 404. Like the mark routes' back=view:
the review only for an item of the review ring, else the booth page."""
_booth(tmp_path, "g", {"a.png": PNG, "n.md": b"# n"})
c = _client(tmp_path)
loc = lambda back: c.post("/b/g/blurbooth", data={"on": "1", "back": back},
follow_redirects=False).headers["location"]
assert loc("a.png") == "/b/g/view?f=a.png"
assert loc("gone.png") == "/b/g/"
assert loc("n.md") == "/b/g/"
assert loc("") == "/b/g/"
+256 -1
View File
@@ -24,11 +24,22 @@ playwright_api = pytest.importorskip("playwright.sync_api", reason="playwright i
PNG = b"\x89PNG\r\n\x1a\n" PNG = b"\x89PNG\r\n\x1a\n"
# NO INTERNET for the test browser. Every Booth page asks fonts.googleapis.com
# for its faces, and "networkidle" waits for that request — so a stalled request
# to Google hung the page until goto's 30s timeout, the failure mode of the
# full-suite flake (Page.goto timeouts in tests far apart in one run; a stalled
# font request reproduces it exactly). Whether that was THE cause is unproven;
# a test that depends on Google being reachable is wrong regardless. Every
# hostname but 127.0.0.1 now fails DNS at once, and the pages fall back to the
# system stacks the tokens declare. Positive control: test_*_has_no_internet.
OFFLINE = ["--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE 127.0.0.1"]
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def browser(): def browser():
with playwright_api.sync_playwright() as pw: with playwright_api.sync_playwright() as pw:
try: try:
b = pw.chromium.launch() b = pw.chromium.launch(args=OFFLINE)
except Exception as exc: # noqa: BLE001 - any launch failure is a skip except Exception as exc: # noqa: BLE001 - any launch failure is a skip
pytest.skip(f"no usable chromium: {exc}") pytest.skip(f"no usable chromium: {exc}")
yield b yield b
@@ -550,3 +561,247 @@ def test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open(browser,
shown = said[0].split("\n\n")[0] shown = said[0].split("\n\n")[0]
assert not any(c in shown for c in "‮\n"), repr(shown) assert not any(c in shown for c in "‮\n"), repr(shown)
assert "safe�gnp.xe�line2" in shown, repr(shown) assert "safe�gnp.xe�line2" in shown, repr(shown)
_FILTER = "sel => { const e = document.querySelector(sel); return e ? getComputedStyle(e).filter : 'MISSING'; }"
def _blurred_set(root: pathlib.Path, name: str = "g", blur=("a.png",), flag=("a.png",)) -> pathlib.Path:
from booth.app import set_blurred
from booth.marks import set_flag
b = root / name
b.mkdir()
for rel in ("a.png", "b.png", "c.png"):
(b / rel).write_bytes(PNG)
for rel in blur:
set_blurred(b, rel, True)
for rel in flag:
set_flag(b, rel, True)
return b
def _settle(page) -> None:
page.wait_for_timeout(400) # the filter transition (--dur-2)
def test_reveal_all_reveals_every_blurred_surface_and_survives_the_next_page(browser, live):
"""r2b D2 (blur ruling A): one click lifts the blur on the tile, the tray,
the review stage and the filmstrip, and it holds on the next page of the
same tab. A fresh tab is blurred again — per tab, never persisted."""
base, root = live
_blurred_set(root)
ctx = browser.new_context(viewport={"width": 1400, "height": 900})
page = ctx.new_page()
page.goto(f"{base}/b/g/", wait_until="networkidle")
tile, tray = 'figure.item[data-item="a.png"] img', ".tray-item.is-blurred img"
before = [page.evaluate(_FILTER, tile), page.evaluate(_FILTER, tray)]
page.locator("[data-reveal-all]").click()
_settle(page)
after = [page.evaluate(_FILTER, tile), page.evaluate(_FILTER, tray)]
page.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle")
_settle(page)
review = [page.evaluate(_FILTER, "#vimg"), page.evaluate(_FILTER, ".film-f.is-blurred img"),
page.locator("[data-reveal-all]").inner_text()]
fresh = ctx.new_page() # a new tab: sessionStorage is per tab
fresh.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle")
_settle(fresh)
again = fresh.evaluate(_FILTER, "#vimg")
ctx.close()
assert all("blur" in f for f in before), before
assert after == ["none", "none"], after
assert review[:2] == ["none", "none"] and "blur again" in review[2], review
assert "blur" in again, again
def test_reveal_all_on_booth_a_does_not_reveal_booth_b_or_the_desk(browser, live):
"""r2b D2 + INV-4: the reveal is scoped to the booth you are in. Booth A's
cannot follow you into booth B, and nothing on the index is revealed."""
base, root = live
_blurred_set(root, "ga")
_blurred_set(root, "gb")
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/ga/", wait_until="networkidle")
page.locator("[data-reveal-all]").click()
page.goto(f"{base}/b/gb/", wait_until="networkidle")
_settle(page)
b_tile = page.evaluate(_FILTER, 'figure.item[data-item="a.png"] img')
page.goto(f"{base}/", wait_until="networkidle")
desk = page.evaluate("""() => [...document.querySelectorAll('.desk-strip img.blurred-thumb')]
.map(i => getComputedStyle(i).filter)""")
page.close()
assert b_tile == "blur(22px)", b_tile
assert desk and all(f == "blur(16px)" for f in desk), desk # exact: blur(0px) is not blurred
def test_reveal_all_survives_an_in_place_save(browser, live):
"""r2b D2: after an in-place save the blur is still lifted — the swapped-in
tile included — the control still says "blur again" and still works, and
the per-tile reveal buttons are still stood down."""
base, root = live
_blurred_set(root, blur=("a.png", "b.png"), flag=())
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.evaluate("window.__same_page = 1")
page.locator("[data-reveal-all]").click()
page.locator('figure.item[data-item="b.png"] .flagtoggle button').click()
page.wait_for_selector('figure.item.is-flagged[data-item="b.png"]', timeout=10000)
_settle(page)
got = {"same": page.evaluate("window.__same_page === 1"),
"b": page.evaluate(_FILTER, 'figure.item[data-item="b.png"] img'),
"label": page.locator("[data-reveal-all]").inner_text(),
"tile_btn": page.evaluate("""() => getComputedStyle(
document.querySelector('figure.item[data-item="b.png"] .reveal')).display""")}
page.locator("[data-reveal-all]").click()
_settle(page)
got["back"] = page.evaluate(_FILTER, 'figure.item[data-item="b.png"] img')
page.close()
assert got["same"] and got["b"] == "none" and "blur again" in got["label"], got
assert got["tile_btn"] == "none" and "blur" in got["back"], got
def test_blur_again_restores_each_items_own_reveal(browser, live):
"""r2b D2: Reveal all never touches an item's own reveal, so "blur again"
returns each item exactly as it was — one revealed on its own stays so."""
base, root = live
_blurred_set(root, blur=("a.png", "b.png"), flag=())
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.locator('figure.item[data-item="a.png"] .reveal').click()
page.locator("[data-reveal-all]").click()
page.locator("[data-reveal-all]").click()
_settle(page)
got = [page.evaluate(_FILTER, f'figure.item[data-item="{r}"] img') for r in ("a.png", "b.png")]
page.close()
assert got[0] == "none" and "blur" in got[1], got
def test_reveal_all_never_shows_without_js_and_a_storage_failure_still_applies_the_click(browser, live):
"""r2b D2: without JS the control is in the markup but never shown. With
sessionStorage throwing on write (a private window), the click still
applies to the page — only the memory is lost."""
base, root = live
_blurred_set(root)
ctx = browser.new_context(java_script_enabled=False)
page = ctx.new_page()
page.goto(f"{base}/b/g/", wait_until="networkidle")
nojs = page.locator("[data-reveal-all]").is_visible()
ctx.close()
page = browser.new_page(viewport={"width": 1400, "height": 900})
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
# READS and WRITES both throw: the pre-paint read must degrade to "not
# revealed" without raising, and the click must still apply.
page.add_init_script("""Storage.prototype.setItem = function () { throw new Error('quota'); };
Storage.prototype.getItem = function () { throw new Error('denied'); };""")
page.goto(f"{base}/b/g/", wait_until="networkidle")
_settle(page)
before = page.evaluate(_FILTER, 'figure.item[data-item="a.png"] img')
page.locator("[data-reveal-all]").click()
_settle(page)
lifted = page.evaluate(_FILTER, 'figure.item[data-item="a.png"] img')
page.close()
assert not nojs
assert before == "blur(22px)" and lifted == "none", (before, lifted)
assert errors == [], errors
def test_an_items_own_reveal_survives_an_in_place_save(browser, live):
"""heid code-review (groa, kimi): nothing pinned the swap carrying an item's
own `revealed` — deleting it from the carry list left every test green.
Reveal one tile, save something else in place: it stays revealed, and its
button still says so."""
base, root = live
_blurred_set(root, blur=("a.png", "b.png"), flag=())
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.locator('figure.item[data-item="b.png"] .reveal').click()
page.locator('figure.item[data-item="a.png"] .flagtoggle button').click()
page.wait_for_selector('figure.item.is-flagged[data-item="a.png"]', timeout=10000)
_settle(page)
got = [page.evaluate(_FILTER, 'figure.item[data-item="b.png"] img'),
page.locator('figure.item[data-item="b.png"] .reveal').inner_text()]
page.close()
assert got[0] == "none" and "hide" in got[1], got
def test_reveal_all_lifts_a_blurred_docs_own_page(browser, live):
"""The doc page's blur (heid code-review) obeys the same <html> class, and
its own reveal works."""
from booth.app import set_blurred
base, root = live
b = root / "g"
b.mkdir()
(b / "n.md").write_text("# secret\n\nbody")
set_blurred(b, "n.md", True)
page = browser.new_page(viewport={"width": 1200, "height": 800})
page.goto(f"{base}/b/g/view?f=n.md", wait_until="networkidle")
_settle(page)
at_rest = page.evaluate(_FILTER, "#docbody .markdown-body, #docbody .textview")
page.locator("#docreveal").click()
_settle(page)
own = page.evaluate(_FILTER, "#docbody .markdown-body, #docbody .textview")
page.close()
assert at_rest == "blur(22px)" and own == "none", (at_rest, own)
def test_space_on_a_focused_review_button_presses_it_and_does_not_move_on(browser, live):
"""heid bug-hunt (hulda): the review's document-level Space handler moved to
the next item before a focused button could take the key, so a keyboard
user could not press Reveal all or the fog control with Space."""
base, root = live
_blurred_set(root, flag=())
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle")
page.locator("[data-reveal-all]").focus()
page.keyboard.press(" ")
_settle(page)
got = [page.url, page.evaluate("document.documentElement.classList.contains('reveal-all')")]
page.close()
assert got[0].endswith("/b/g/view?f=a.png") and got[1], got
def test_the_review_and_doc_top_bars_fit_a_phone(browser, live):
"""heid bug-hunt (hulda, groa; needs-repro): the top bar gained the fog
control and Reveal all beside the name, the fit toggle and the download.
At 390px no review or doc page scrolls sideways, even with a long name."""
from booth.app import set_blurred
base, root = live
b = _blurred_set(root, flag=())
long = "a-rather-long-picture-name-" * 3 + ".png"
(b / long).write_bytes(PNG)
set_blurred(b, long, True)
(b / "n.md").write_text("# n")
set_blurred(b, "n.md", True)
over = {}
for url in (f"/b/g/view?f={long}", "/b/g/view?f=n.md"):
page = browser.new_page(viewport={"width": 390, "height": 844})
page.goto(f"{base}{url}", wait_until="networkidle")
over[url] = page.evaluate(
"document.documentElement.scrollWidth - document.documentElement.clientWidth")
# and nothing squeezed into a stack: every top-bar control is one line
over[url + " tallest"] = page.evaluate("""() => Math.max(...[...document.querySelectorAll(
'.vbar button, .vbar .vbtn')].filter(e => e.offsetParent).map(e => e.getBoundingClientRect().height)) - 40""")
page.close()
assert all(v <= 0 for v in over.values()), over
def test_the_test_browser_has_no_internet(browser, live):
"""Positive control for OFFLINE (booth-dev's ask: see the fix in force,
don't assume it). An external host fails at once, and a Booth page — whose
fonts are external — still goes idle in well under the goto timeout."""
base, root = live
(root / "g").mkdir()
page = browser.new_page()
t = time.time()
with pytest.raises(Exception) as err:
page.goto("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans", timeout=10000)
external = time.time() - t
page.close()
page = browser.new_page()
t = time.time()
page.goto(f"{base}/b/g/", wait_until="networkidle")
local = time.time() - t
page.close()
assert "ERR_NAME_NOT_RESOLVED" in str(err.value) and external < 3, (str(err.value)[:80], external)
assert local < 10, local