Files
booth/docs/contracts/r2b_desk_reveal_theme.contract.md
vh 4cfbce5109 fix(blur): .blurred round-trips any rel, and one writer serves both surfaces
The heid bug-hunt on r2b merge 1 found the /blur route stripping `f` before
writing, so the form for " a.png" blurred its neighbour "a.png". The route was
only half of it: `.blurred` was one stripped rel per line, so no writer could
store a rel with a leading space or a newline, whatever the route did.
Operator-ruled 2026-09-23 ("fix the blur").

- booth/blur.py (new, stdlib-only): read_blurred / set_blurred / BLUR_FILE.
  `.blurred` is now a JSON array in sorted order, the `.seen` shape: opened
  O_NOFOLLOW | O_NONBLOCK with an S_ISREG check and a 1 MiB cap, so a planted
  symlink is refused and a FIFO can no longer hang every Desk render (the old
  read_text() blocked on one). Writes go through mkstemp + os.replace. The
  legacy line format is still READ, so the 6 live line-format files keep their
  blur until their next write upgrades them. Measured before the change: 42
  live rels, none with edge whitespace, so the defect had no live victims.
- The route no longer strips `f`.
- scripts/booth `blur`/`unblur` go through booth.blur.set_blurred instead of
  their own grep/printf line writer. Two writers of one format is how the
  formats drift, and after this change the shell writer would have appended a
  line to a JSON array. Every path is checked before anything is written.
- Item.blurred_self (appended to the record): the item's own blur, resolved in
  booth_items from the same read as `blurred`. It replaces build_gallery's
  second read_blurred, which a write between the two reads could split
  (invariant 3). app.py no longer reads blur state at all, and a test asserts
  it.

Names stay importable from booth.app and booth.items (invariant 4). blur joins
test_stdlib_only. test_cli's per-item-survives test now reads through the reader
rather than asserting the old byte format. The r2b contract and its mutation
row follow blurred_self onto the record. tests/mutations/blur_storage.toml
proves 12 falsifiers by running the change each forbids.

Not in this change, and still ours: the "off"-means-ON idiom drift between
/blur, /blurbooth and /flag (forms only ever send 0/1), and the CLI's
`.blurbooth` touch following a symlink where the service no longer does.
2026-09-23 22:05:18 -07:00

25 KiB
Raw Permalink Blame History

contract_version, status, module, purpose, depends_on, language, complexity, estimated_loc, confidence, touches, assumptions, resolved_questions
contract_version status module purpose depends_on language complexity estimated_loc confidence touches assumptions resolved_questions
0.1 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. templates + base.html CSS/JS + the vendored token sheet (the Desk row, Reveal all, the theme toggle) 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.
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.
jinja + css + a little javascript medium 350 0.7
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, read off `Item.blurred_self` — moved onto the item record by booth-dev 2026-09-23 so blur state has one reader, invariant 3)
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)
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.
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.
  • The cluster is LAST in the row's markup, so the booth's name comes first in tab order and wipe comes last. Where a real hover exists it is placed over the strip from the row's own box: the strip keeps to the row's top, 210px wide from the row's 12px padding.
    • Accepted: the hover query uses Media Queries 4 not (...). An engine without MQ4 drops the whole query, and the failure is the safe direction: the controls show, in flow.
    • Known limit: the touch-LAPTOP branch (a fine primary pointer plus a coarse one) cannot be emulated, because Chromium's touch emulation makes the primary pointer coarse. That branch is covered by reading the media query, not by a test. 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's facts line and in the booth header's status line as dated FACTS, a different kind of thing from the lifetime pill (state) and the controls (actions). One macro, _dates.html, serves both:

  • created is a DATE, "created 12 Sep" (with the year only when it is not this year's);
  • updated is an AGE, "updated 5d ago", measured from ONE clock per page (now in the context), so every row is measured from the same instant;
  • each is a <time> with datetime (ISO) and its exact local stamp as the title;
  • updated shows whenever it differs from created by a minute or more, EITHER way. Copied files keep their mtimes while the folder is born now, so content can be older than its booth. Within a minute, one date;
  • a content clock AHEAD of now is said as its DATE, never as an age ("updated just now" would be false);
  • an age is in its largest whole unit, a day being 24h;
  • a date the filesystem cannot give, or the calendar cannot hold, renders NOTHING. The filters never raise: the Desk renders every row in one response, so one unrenderable clock would otherwise 500 the index for every booth.

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, from Item.blurred_self (the same single read booth_items makes for 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.

  • A choice made in another tab moves every open Booth page (the storage event), as it moves the ask chrome.

  • 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. Light declares every dark-block property PLUS the art layer's four light-only values (the three shadows and the armed glow). That extra set is written in the test from SVOS, never derived from the copies, so dropping it from both copies fails too.
  • 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 MOUNTED (never an author's own element of that class) 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 (Item.blurred_self, from booth_items' one read). 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: under prefers-contrast: more, forced dark resolves exactly what OS dark does, and forced light what OS light does. The check reads tokens that DIFFER between a theme and its high-contrast variant (--text-faint, --border-default; --surface-card is the same in both and would prove nothing), and asserts that high contrast actually changed them.
  • a_forced_theme_and_the_os_theme_are_the_same_declarations: each light copy equals the other and declares the dark block's property set plus the four art-light values; each light-hc copy equals the other and declares exactly the dark-hc block's.
  • created_and_updated_are_dated_facts_and_none_says_nothing, updated_shows_whenever_it_differs_from_created_and_a_future_one_says_its_date, a_date_no_calendar_can_hold_renders_nothing_and_never_500s, an_age_is_said_in_its_largest_whole_unit (D1b).
  • a_rows_booth_name_comes_before_its_controls_in_tab_order, the_pill_shows_at_rest_and_focus_reveals_the_controls, with_scripts_off_a_rows_controls_still_act (D1).
  • a_theme_chosen_in_one_tab_moves_the_others, the_theme_marks_only_the_ask_fragments_we_mounted (D3).

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
test_flow_browser test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open clicks the row's wipe at rest hovers the row first, then clicks wipe is hidden until hover (OPEN-1, answered yes)
tests/mutations/r2_flow.toml, four rows on the facts-line controls proved the controls visible at rest on the facts line retired, with successors in r2b.toml their tests were replaced, as declared above

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.