feat(blur): reveal all, and the booth blur control (r2b merge 1: D2 + D2b)
The operator ruled blur A, and made it urgent: "per booth blurring is now
important since we are showing up to 4 images."
- Reveal all: one control per booth, in the booth header and the review's
top bar, outside every data-region. It is in the markup only when
something is blurred, always `hidden` until the script shows it.
- The state is sessionStorage per booth, per tab, and nothing reaches
the server. It is carried as one `reveal-all` class on <html>, applied
before first paint from the page's own data-booth, so booth A's reveal
cannot follow you into booth B and the index is never revealed.
- Per-item reveal buttons stand down by stylesheet, and an item's own
reveal is never touched, so "blur again" restores each item as it was.
- A storage write that throws still applies the click.
- The booth blur control: a plain form to booth-dev's POST /blurbooth, so
it works with scripts off. Its label follows is_booth_blurred; from the
review it carries `back` and lands on the same item. A fogged booth's
Desk row says "◉ blurred".
- Found by rendering it: under a fogged booth every item reported
`blurred`, so an item blurred only by the booth offered an un-blur that
visibly did nothing. The gallery now carries `blurred_self`, and such an
item shows "◉ booth", a label rather than a control.
Contract docs/contracts/r2b_desk_reveal_theme.contract.md (heid contract
panel 4/4, folded). tests/mutations/r2b.toml: 14/14 proved. 765 passed.
This commit is contained in:
@@ -680,6 +680,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 +748,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 +774,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 +1200,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 +1833,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.
|
||||||
|
|||||||
@@ -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,28 @@
|
|||||||
/* 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)}
|
||||||
|
.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 +1069,35 @@
|
|||||||
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.textContent = on ? '🙈 blur again' : '👁 reveal all — blur is cosmetic';
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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,17 @@
|
|||||||
<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. #}
|
||||||
|
{% if not board %}
|
||||||
|
<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>
|
||||||
|
{% if all_items | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden>👁 reveal all — blur is cosmetic</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"
|
||||||
|
|||||||
@@ -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 %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="docview">
|
<div class="docview">
|
||||||
<div class="vbar">
|
<div class="vbar">
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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. #}
|
||||||
|
<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>
|
||||||
|
{% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden>👁 reveal all — blur is cosmetic</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 <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".** 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:" + <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 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 `<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>` and lands back on the same item.
|
||||||
|
- **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 page only as an escaped
|
||||||
|
attribute value.
|
||||||
|
- **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.
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# 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>'''
|
||||||
|
new = '''
|
||||||
|
{% if all_items | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all>'''
|
||||||
|
|
||||||
|
[[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,'''
|
||||||
@@ -844,3 +844,77 @@ 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 'name="on" value="1"' in form and "blur booth" in form
|
||||||
|
assert all(form not in r for r in _regions(page)), "a swap must never replace it"
|
||||||
|
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 '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
|
||||||
|
|||||||
@@ -550,3 +550,137 @@ 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 "blur" in b_tile, b_tile
|
||||||
|
assert desk and all("blur" in f for f in desk), desk
|
||||||
|
|
||||||
|
|
||||||
|
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})
|
||||||
|
page.add_init_script("Storage.prototype.setItem = function () { throw new Error('quota'); };")
|
||||||
|
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
||||||
|
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 lifted == "none", lifted
|
||||||
|
|||||||
Reference in New Issue
Block a user