From 5ded5ffe551fd65bd65aecd0c29e96224749c741 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 17:52:33 -0700 Subject: [PATCH 1/4] feat(blur): reveal all, and the booth blur control (r2b merge 1: D2 + D2b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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. --- booth/app.py | 11 + booth/templates/base.html | 70 ++++- booth/templates/booth.html | 27 +- booth/templates/doc.html | 1 + booth/templates/index.html | 4 +- booth/templates/marks.html | 1 + booth/templates/view.html | 9 + .../r2b_desk_reveal_theme.contract.md | 295 ++++++++++++++++++ tests/mutations/r2b.toml | 139 +++++++++ tests/test_flow.py | 74 +++++ tests/test_flow_browser.py | 134 ++++++++ 11 files changed, 759 insertions(+), 6 deletions(-) create mode 100644 docs/contracts/r2b_desk_reveal_theme.contract.md create mode 100644 tests/mutations/r2b.toml 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 %}
diff --git a/booth/templates/index.html b/booth/templates/index.html index 25f69e6..5d20d74 100644 --- a/booth/templates/index.html +++ b/booth/templates/index.html @@ -68,12 +68,14 @@
{# 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). #} - {% 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 %}
{% if b.marks_open %}? {{ b.marks_open }} open {% elif b.hold == "unreadable" %}marks unreadable {% elif section == 'new' %}new{% endif %} {% if b.uploaded %}โฌ† pickup{% endif %} + {# r2b D2b: a fogged strip says why. Information, not the control. #} + {% if b.booth_blurred %}โ—‰ blurred{% endif %}
{% endif %} diff --git a/booth/templates/marks.html b/booth/templates/marks.html index 4ecd97c..7ded995 100644 --- a/booth/templates/marks.html +++ b/booth/templates/marks.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% from "_lifetime.html" import lifetime %} {% block title %}{{ name }} ยท marks ยท The Booth{% endblock %} +{% block html_attrs %} data-booth="{{ name }}"{% endblock %} {% block content %} {# 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 diff --git a/booth/templates/view.html b/booth/templates/view.html index 9557cf4..c990f09 100644 --- a/booth/templates/view.html +++ b/booth/templates/view.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% 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 โ€” 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 @@ -20,6 +21,14 @@ {% 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. #} + + + + +
+ {% 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 `
/blurbooth>` with + `on=1|0`, so it works with scripts off (INV-2). From the review it carries + `back=` 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 ``.** Absent = the OS preference, exactly + today's sheet. An early `` 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 ``: 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. diff --git a/tests/mutations/r2b.toml b/tests/mutations/r2b.toml new file mode 100644 index 0000000..5db6d28 --- /dev/null +++ b/tests/mutations/r2b.toml @@ -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 %} - - {% if all_items | selectattr('blurred') | list %}{% endif %} + + {% 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 @@ -17,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 %} +