diff --git a/booth/app.py b/booth/app.py
index c98d902..5f88a82 100644
--- a/booth/app.py
+++ b/booth/app.py
@@ -680,6 +680,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 +748,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 +774,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 +1200,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 +1833,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.
diff --git a/booth/templates/base.html b/booth/templates/base.html
index bd2f110..cbc6e78 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,28 @@
/* 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)}
+ .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 +1069,35 @@
if (window.ResizeObserver) new ResizeObserver(set).observe(rail);
})();
+
diff --git a/booth/templates/booth.html b/booth/templates/booth.html
index bcb4c99..2591a88 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,17 @@
+ {% 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. #}
+ {% if not board %}
+
+ {% if all_items | selectattr('blurred') | list %}{% endif %}
{% endif %}
{% if not board %}
+ {% if film | selectattr('blurred') | list %}{% endif %}
โฌ
diff --git a/docs/contracts/r2b_desk_reveal_theme.contract.md b/docs/contracts/r2b_desk_reveal_theme.contract.md
new file mode 100644
index 0000000..a244830
--- /dev/null
+++ b/docs/contracts/r2b_desk_reveal_theme.contract.md
@@ -0,0 +1,295 @@
+---
+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 , 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=` โ 303 to the booth, or to `view?f=` (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 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".** One instance is in the booth header,
+ one in the review's top bar, and both sit OUTSIDE every `data-region`, so no
+ in-place swap replaces them. The server puts the control in the markup only
+ when at least one item of the booth is blurred (`Item.blurred`), and always
+ with the `hidden` attribute. The script removes `hidden` and binds it. Without
+ JS it is in the markup but never shown.
+- **State: `sessionStorage["booth.reveal:" + ] = "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 ``, `reveal-all`.** CSS lifts the blur
+ under it on every booth surface: tiles, the flag tray, the filmstrip and the
+ review stage. `` is outside every `data-region`, so an in-place swap
+ can never drop it.
+ - `` 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 `` 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 carries no `data-booth`, so **nothing on the index is revealed
+ by D2**, the Desk strip included.
+- **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
+ tape bar**: a plain `