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 %}
โœ• {{ file }} + {# Reveal all can lift this page's blur, so this page must be able to put it + back (r2b, heid bug-hunt). #} + {% if blurred %}{% endif %} โฌ‡
{# 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' %}
{{ m.text }}
{% endfor %}
{% 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 %}
{{ body }}
{% endif %} +