diff --git a/booth/app.py b/booth/app.py
index c98d902..b72f794 100644
--- a/booth/app.py
+++ b/booth/app.py
@@ -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."""
marker = booth / BOOTH_BLUR_FILE
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
try:
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(),
"uploaded": (child / UPLOAD_MARKER).exists(),
"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),
# `open_marks` and nothing else (INV-2). The count this replaced
# 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.
"""
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):
body = render_doc_body(child, it)
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_html": rendered_html,
"blurred": it.blurred,
+ "blurred_self": it.rel in own_blur,
}
)
return out
@@ -1193,6 +1214,8 @@ def create_app(
# The page could not previously tell keep from release, so it
# offered neither and you had to go back to the index.
"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`.
# Filtering is a VIEW: `shown` is `gallery` with non-matching
# items removed and NOTHING re-sorted, so "the third one" means
@@ -1824,6 +1847,8 @@ def create_app(
**base_ctx,
"name": name,
"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_url": quote(f, safe="/"),
# The facts this route never used to carry.
@@ -2132,7 +2157,10 @@ def create_app(
booth = resolve_booth(name)
set_booth_blurred(booth, on not in ("0", "false", ""))
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='/')}"
return RedirectResponse(url=landing, status_code=303)
diff --git a/booth/templates/base.html b/booth/templates/base.html
index bd2f110..a1ccf47 100644
--- a/booth/templates/base.html
+++ b/booth/templates/base.html
@@ -1,8 +1,22 @@
-
+
+{# 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. #}
+
{% block title %}The Booth{% endblock %}
{# 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);
background:var(--success-soft);font-weight:600}
.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
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. */
.blurred-thumb{filter:blur(16px)}
+ /* REVEAL ALL (r2b D2) โ one class on , 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 ------------------------------------------------------
A .md/.txt/.log shows in place, collapsible and closable, spanning the
full grid width so prose has a readable measure. */
@@ -1030,6 +1074,36 @@
if (window.ResizeObserver) new ResizeObserver(set).observe(rail);
})();
+
diff --git a/booth/templates/booth.html b/booth/templates/booth.html
index bcb4c99..d6cd1ff 100644
--- a/booth/templates/booth.html
+++ b/booth/templates/booth.html
@@ -6,13 +6,20 @@
them, so docs rendered with no control at all. A macro makes "patched two of
three" impossible rather than merely unlikely. #}
{% 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 %}
+ โ booth
+ {% else %}
+ {% endif %}
{%- endmacro %}
{# The per-item MARK controls: flag (the operator pointing at this one) and a
@@ -62,6 +69,7 @@
{%- endmacro %}
{% block title %}{{ name }} ยท The Booth{% endblock %}
+{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
{% block content %}
โน all booths
@@ -94,6 +102,23 @@
+ {% 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. #}
+
+ {% if all_items | selectattr('blurred') | list %}{% endif %}
{% endif %}
{% if not board %}
{% 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 class. #}
+
+ {% if blurred %}{% endif %}
{% if is_html %}
{{ body|safe }}
{% else %}