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:
@@ -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.
|
||||
Reference in New Issue
Block a user