Files
booth/docs/contracts/r2b_desk_reveal_theme.contract.md
T
vh 75623c7dbc fix(blur): the heid code-review and bug-hunt panels on r2b merge 1, folded
Both panels ran 4/4 on 5ded5ff. They converged on the board and doc-page
gaps independently.

- A board holding files lost both blur controls (they sat inside the
  board suppression meant for the one-click wipe), while its items'
  "◉ booth" labels pointed at them. Only the wipe is board-suppressed now.
- A blurred doc's own full page rendered clear. Its body is blurred there
  too, with its own reveal and a Reveal all to put the blur back.
- set_booth_blurred followed a planted .blurbooth symlink (`touch`), and
  the new control made that a click away. Anything at the name already
  reads as fogged; otherwise it is created O_CREAT|O_EXCL|O_NOFOLLOW.
- The fog landing echoed `back` unchecked into the 303. It is now built
  from the review ring, as the mark routes do.
- The fog form is its own region, so an in-place save refreshes its
  label. Reveal all stays outside every region: its state lives in the
  tab.
- The review's Space-to-advance no longer swallows Space on a focused
  button or link.
- Top-bar controls stay on one line at phone width.
- Tests tightened:
  - method="post" on the fog forms;
  - exact blur values;
  - a storage READ that throws;
  - an item's own reveal carried across a swap;
  - reveal gated where it can act.

r2b.toml: 26/26 proved. 774 passed.
2026-09-23 18:41:32 -07:00

321 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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".** It appears in the booth header, the
review's top bar and a blurred doc's own top bar. Every instance sits OUTSIDE
every `data-region`, so no in-place swap replaces it: its state lives in the
tab, and a swap must never reset it. Below 600px it reads "👁 reveal all";
its title still says the blur is cosmetic. The server puts the control in the markup only
where it can act, and always with the `hidden` attribute: in the header when
any item of the booth is blurred (`Item.blurred`), and in the review when any
item of the review RING is (a blurred doc is not on the review page, so a
control there would act on nothing). 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's `<html>` carries no `data-booth` (its rows' own `data-booth`
attributes are unrelated), so **nothing on the index is revealed by D2**,
the Desk strip included.
- **A board holding files gets both controls.** Only the one-click wipe is
board-suppressed; an item's "◉ booth" label points at the header control,
so the control must be there.
- **The full-page doc view is blurred honestly.** A blurred doc's own page
renders its body blurred, with its own JS-only reveal; Reveal all lifts it
by the same `<html>` class.
- **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
top bar (`.vbar`)**, each wrapped in its OWN region, `blur-booth`. Its label is
server state, so an in-place save refreshes it with everything else; a fog
set elsewhere since the page loaded would otherwise leave it saying "blur
booth". It is 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>`. The route lands on the review only when `back` is an item of
the review ring, and otherwise on the booth page; the landing is built from
the ring, never echoed.
- **Fogging never writes through a link.** booth-dev's `set_booth_blurred` used
`touch()`, which followed a planted `.blurbooth` symlink: a click of this
control rewrote an outside file's mtime, or created a dangling target. Any
entry already at the name reads as fogged, so nothing is written; otherwise
the marker is created with `O_CREAT | O_EXCL | O_NOFOLLOW`.
- **Space never hijacks a focused control.** The review's Space-to-advance
ignores a focused button, link or summary, so a keyboard can press these
controls.
- **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 reveal-state machinery
only as an escaped attribute value (`data-booth`), read by `getAttribute` and
never templated into a script.
- **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.