feat(blur): a booth can be fogged as a whole, composing with per-item blur
The operator ruled booth-level blur in and chose reading A for the reveal
("A is fine"). design-dev specced the semantics and owns the controls; this is
the storage half.
COMPOSES, NEVER OVERRIDES. An item is blurred iff the booth is blurred OR it is
in .blurred, so turning booth blur off leaves an agent's per-item choice exactly
as the poster left it. An override would need a per-item "unblurred" exception
list, which is state nobody can see.
Resolved in booth_items, so every surface inherits it for free — Desk strip,
tiles, flag tray, filmstrip, stage all already read Item.blurred and none of
them learns the booth flag exists (INV-1). Images and video only; audio has
nothing to hide from a glance.
A MARKER, deliberately not JSON. `.seen` is JSON because it holds rels that must
round-trip exactly; a boolean has nothing to round-trip, and matching `.forever`
means the two whole-booth flags read the same way. We told design-dev it would
be JSON and it should not be — said so rather than quietly shipping the other
thing.
is_booth_blurred mirrors is_kept's lstat shape WITH THE SAFETY INVERTED, and the
inversion is the point: is_kept fails toward keeping because a failed read must
not authorise a delete; this fails toward HIDING, because a failed read must not
reveal something a poster asked to fog. Both are "the failure does not cause the
loss".
Also records the operator's 2026-09-23 ruling that there is NO 1.0 yet, and adds
.blurbooth to CLAUDE.md's dotfile list. 766 green.
This commit is contained in:
@@ -212,3 +212,25 @@ old = '''
|
||||
var d = shown(btn.getAttribute('data-desc') || '');'''
|
||||
new = '''
|
||||
var d = btn.getAttribute('data-desc') || '';'''
|
||||
|
||||
[[mutation]]
|
||||
label = "booth blur OVERRIDES per-item instead of composing"
|
||||
file = "booth/items.py"
|
||||
test = "tests/test_booth.py::test_booth_blur_composes_with_per_item_and_never_overrides_it"
|
||||
old = '''
|
||||
blurred=rel in blurred or (booth_blur and kind in BLURRABLE_KINDS),'''
|
||||
new = '''
|
||||
blurred=(booth_blur and kind in BLURRABLE_KINDS),'''
|
||||
|
||||
|
||||
[[mutation]]
|
||||
label = "an unreadable booth-blur marker reveals instead of fogging"
|
||||
file = "booth/items.py"
|
||||
test = "tests/test_booth.py::test_an_unreadable_booth_blur_marker_fogs_rather_than_reveals"
|
||||
old = '''
|
||||
except OSError:
|
||||
return True # cannot tell -> fog it; see above'''
|
||||
new = '''
|
||||
except OSError:
|
||||
return False # cannot tell -> reveal it'''
|
||||
|
||||
|
||||
@@ -1650,3 +1650,79 @@ def test_the_board_delete_dialog_cannot_be_rewritten_by_a_link_row(tmp_path):
|
||||
# both arguments must go through it, not just one
|
||||
assert "shown(btn.getAttribute('data-desc')" in html
|
||||
assert "shown(btn.getAttribute('data-url')" in html
|
||||
|
||||
|
||||
def test_booth_blur_composes_with_per_item_and_never_overrides_it(tmp_path):
|
||||
"""The operator ruled booth-level blur in; design-dev specced the semantics
|
||||
and this is the half that is ours.
|
||||
|
||||
COMPOSES, never overrides: an item is blurred iff the booth is blurred OR it
|
||||
is in `.blurred`. Turning booth blur off must leave an agent's per-item
|
||||
choice exactly as the poster left it — an override would need a per-item
|
||||
"unblurred" exception list, which is state nobody can see.
|
||||
|
||||
Defeating change: assigning `Item.blurred` from the booth flag instead of
|
||||
OR-ing it."""
|
||||
from booth.app import set_blurred, set_booth_blurred
|
||||
from booth.items import booth_items
|
||||
|
||||
b = tmp_path / "g"
|
||||
b.mkdir()
|
||||
for n in ("a.png", "b.png", "c.mp3"):
|
||||
(b / n).write_bytes(b"x")
|
||||
set_blurred(b, "b.png", True)
|
||||
|
||||
def state():
|
||||
return {i.rel: i.blurred for i in booth_items(b)}
|
||||
|
||||
assert state() == {"a.png": False, "b.png": True, "c.mp3": False}
|
||||
|
||||
set_booth_blurred(b, True)
|
||||
# audio has nothing to hide from a glance
|
||||
assert state() == {"a.png": True, "b.png": True, "c.mp3": False}
|
||||
|
||||
set_booth_blurred(b, False)
|
||||
assert state() == {"a.png": False, "b.png": True, "c.mp3": False}, \
|
||||
"unfogging the booth erased the poster's per-item blur"
|
||||
|
||||
|
||||
def test_an_unreadable_booth_blur_marker_fogs_rather_than_reveals(tmp_path, monkeypatch):
|
||||
"""`is_kept` fails toward KEEPING because a failed read must not authorise a
|
||||
delete. This fails toward HIDING, because a failed read must not reveal
|
||||
something the poster asked to fog. Same shape, inverted safety, and the
|
||||
inversion is the point.
|
||||
|
||||
Defeating change: `except OSError: return False`."""
|
||||
import booth.items as items_mod
|
||||
|
||||
b = tmp_path / "g"
|
||||
b.mkdir()
|
||||
|
||||
real = pathlib.Path.lstat
|
||||
|
||||
def boom(self, *a, **k):
|
||||
if self.name == items_mod.BOOTH_BLUR_FILE:
|
||||
raise PermissionError(13, "nope")
|
||||
return real(self, *a, **k)
|
||||
|
||||
monkeypatch.setattr(pathlib.Path, "lstat", boom)
|
||||
assert items_mod.is_booth_blurred(b) is True
|
||||
|
||||
|
||||
def test_the_blurbooth_route_toggles_and_lands_back(tmp_path):
|
||||
"""The POST target design-dev's header control needs, with `back=view` so
|
||||
fogging from the review does not eject you from the review."""
|
||||
b = tmp_path / "g"
|
||||
b.mkdir()
|
||||
(b / "a.png").write_bytes(b"x")
|
||||
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
|
||||
|
||||
r = c.post("/b/g/blurbooth", data={"on": "1"}, follow_redirects=False)
|
||||
assert r.status_code == 303 and r.headers["location"] == "/b/g/"
|
||||
assert (b / ".blurbooth").exists()
|
||||
|
||||
r = c.post("/b/g/blurbooth", data={"on": "1", "back": "a.png"}, follow_redirects=False)
|
||||
assert r.headers["location"] == "/b/g/view?f=a.png"
|
||||
|
||||
c.post("/b/g/blurbooth", data={"on": "0"}, follow_redirects=False)
|
||||
assert not (b / ".blurbooth").exists()
|
||||
|
||||
Reference in New Issue
Block a user