180 Commits
Author SHA1 Message Date
vh 62c0c9b638 memory: the owed items and the SPYRJA fold; the mutation tool's collection-error hole 2026-09-28 18:40:41 -07:00
vh 213071b6ce fix(embed,mutation): SPYRJA fold — report input on every path; an instrument that cannot certify what it did not run
The heid bug-hunt (hulda, with heid's second voice) on 377e652 found eight
issues. Every fixed one has a red-first test.

embed.js:
- H1: the report-input guard covered only the batch path. A lone changed
  answer went out as a native POST, whose 303 navigation took the report's
  typed text with it. Unsaved report input now routes even a lone answer
  in place. With nothing of ours to send, the submit block says so.
- H7 / V1: a bare <select>, and a range or color input with no value
  attribute, read as typed-into by their default attributes, so every clean
  batch refused its reload with a false message. Report controls are now
  measured against how they stood when the Booth mounted. A control added
  later falls back to its defaults, counting a select's first option as its
  default.
- H2 / V2: a contenteditable region counts as report input.

scripts/mutation_check.py:
- H5: any non-zero exit counted as proof, including a collection error
  where the test never ran. Only pytest's "tests failed" (1) proves now.
- H6: the test run has a timeout (300 s). A hang reports "timed out" and
  the source is still restored.
- H3: source is read and restored as bytes, so a CRLF file comes back
  byte-exact.
- H4: one run per tree, enforced by a lock. The in-flight marker lives with
  the tree it guards.
- H8: anchors are counted with overlaps. The check is `matches()`, not
  str.count.

Tool controls +5 (tests/test_mutation_check.py). u3_submit_all +3 rows.
2026-09-28 17:49:13 -07:00
vh 377e652670 fix(inplace,embed): input set back mid-flight, report inputs, ambiguous anchors
Four items owed after S5b, reported by design-dev during the anti-slop run:

- carry() measured a sent-then-changed form against its OLD DEFAULTS. An
  answer set back mid-flight to the value the page first showed read as
  untouched, and the swap put the just-saved value over it. A form sent and
  then changed is now measured against its sent snapshot (sentSet.snapOf).
- The embed's clean-batch reload saw only our own forms. A report's own
  inputs lost whatever the operator had typed into them. Unsaved text in
  any control we don't own now holds the reload, and the page says so.
- Two r2b.toml rows ("D3 a stored theme...", "D3 forced light...") matched
  twice, so they proved only by where the first match fell. Both are
  re-anchored, and scripts/mutation_check.py now refuses any anchor that
  matches more than once. A new tool control covers that.
- The r2_flow contract's C3 steps 2 and 4 now say what S5b superseded. U3
  gains the report-input rule.

Mutation rows: u3_submit_all +1, r2_submit_all +1. Four rows were
re-anchored onto the moved lines.
2026-09-28 16:52:42 -07:00
vh a22a00b82e memory: S5b merged on the operator's word; the r2_flow C3 amendment joins what booth-dev owes 2026-09-28 16:28:34 -07:00
vh 0233ca64fb fix(as-S5b): focus survives a swap, a status line you can see, drafts that ask before they go
The in-place client half of the anti-slop interaction work (guidelines
G1, G2, G4, G13). It still never re-POSTs, still serializes saves, and a
batch still never reloads.
- Focus: the focused element is recorded by identity (its region, its
  key, which same-key element it was) and the fresh one is focused
  without scrolling. If an answered pick's form folds into a closed
  <details>, focus goes to its summary; if nothing is left, to the region
  (tabindex=-1, set by the script). Focus outside the swapped regions
  is not touched.
- One status line per page (_status.html). It floats at the bottom
  centre, above the fixed review stage, so it moves nothing and is in view
  wherever the reader is. Wider than 900px, the letterhead and footer the
  review covers leave the Tab order (visibility:hidden, CSS only).
- The line is never hidden: empty, it takes no space and stays displayed.
  "Saving…" at the press, "Still saving…" on a repeat press, "Saved." when
  the swap lands (cleared after 2s if still the same write), and warnings
  with data-tone="warn". Every write sets or clears the tone. The form in
  flight carries aria-busy until its save settles.
- The client never reloads over a draft: both of its reloads run only
  when every in-place form is clean except the one just sent, unchanged
  since its press, asked again at the reload beat; otherwise it says so
  and stays. A beforeunload guard asks when an in-place form is dirty (its
  own reload does not ask). The embed asks when one of our answers is
  unsent, and skips the pressed form on its own one-form submit.
- Six booth-dev browser tests read the line's hidden state; they read
  its words and tone instead. Two r2_submit_all.toml rows are re-anchored
  to the same failure in the moved code.
Folded from the heid bug-hunt (panel 4/4, thread 01M3MRTNTWEPJHTN4APRR81KH4):
- aria-busy mirrors which forms are in flight on the LIVE page. It is set
  at the press and re-synced whenever a save settles, so it ends on every
  path (a stale tile the swap never replaced included), and a queued form
  replaced by an earlier swap is marked busy again.
- A press inside the reload beat cancels the reload.
- A failure that stayed is said again after an unrelated save, rather
  than buried under "Saved.".
- A 204 followed by a failed page GET is "Saved.", never "could not save".
- An edit made while its save flew is said to be unsaved.
- A focused <summary> has a key.
- The queue settles on rejection.
- The embed's skip covers the one navigation its submit starts; a
  cancelled submit, or one that leaves the page in place, is guarded
  again.
- Pinned: no in-place form holds a control dirty() cannot read, and no
  region nests in another.
Folded from this slice's gate: the status line floats (fixed, bottom
centre, above the review stage) instead of sitting at the top of <main>
or under the viewer's bar. In the flow, every save's "Saving…" moved the
page; booth-dev's test_a_flag_lands_in_place_and_every_region_catches_up
caught a 50px jump. The viewers' grids are back as they were.

Contract: as_antislop S5b (heid contract review and bug-hunt folded).
Falsifiers: antislop.toml S5b sections.
2026-09-28 15:42:37 -07:00
vh 9a97cbdf0c memory: the anti-slop stack merged on the operator's word; what booth-dev still owes after S5b 2026-09-28 15:31:27 -07:00
vh 1080ec1270 memory: 50bfc7b and 190a75a pushed on the operator's word 2026-09-28 15:31:17 -07:00
vh 7143fae6c7 fix(as-S5a): fixup from booth-dev's gate — release asks, fail-closed words, ids that cannot collide
From booth-dev's hulda bug-hunt with heid's second voice (BRINGA, thread
01M3MVGQ7QSCCK8WT59TQ4J469):
- The booth page's "kept — release" asks by name, as the Desk's does.
- WORDS has no prototype: data-confirm="__proto__" or "constructor" is an
  unknown word, and asks, instead of throwing before preventDefault.
- The confirm helper moved into <head>: its capture listener exists
  before any form, so a click during load is asked too (the inline
  confirm() it replaced had that property).
- shown() also marks U+2028/U+2029 and the zero-width characters.
- Derived ids take ':' (bk-ask-<id>-<key>:prompt, bk-ask-<id>:title), which
  no id or key can contain; '-prompt' and '-title' collided with valid
  keys. booth-dev's chip test now looks its fragment up by [id=...].
- human_dur says "—" for a value that is not finite, instead of raising.
- The tile's copy of a note drops its id (booth-dev: mark-<id> is the
  panel's article).
- Four guards that asserted source patterns now also hold on computed
  effects: embed rings, rings inside clipping containers, the withdraw ×
  on both axes, and question-level notes fields.

Contract: as_antislop S5a (fixup). Falsifiers: antislop.toml 102/102 with
r2_flow.toml 24/24 proved; the full gate follows.
2026-09-28 13:58:52 -07:00
vh d4f64fd7ec fix(as-S5a): every control named, one h1 and a skip link, rings and hit areas
The markup and CSS half of the anti-slop interaction work. The in-place
client is untouched (that is S5b).
- Glyph-only controls carry a name: the withdraw ×s, downloads, open full
  page, the viewers' ✕, the board's pin, copy and remove, the bench's
  remove, the 1:1 toggle ("1:1, natural pixels"). Film-strip and tray
  frames carry the file's name as sr-only text instead of reading "01".
  A Desk row's wipe names its booth.
- Fields are named by aria-label, not by their placeholder.
- The inline ask's options are a radiogroup labelled by the prompt; a
  single-question fieldset gets an sr-only legend; a titled ask's title
  takes bk-ask-<id>-title (it duplicated the question's id).
- One h1 per page (sr-only on the Desk, review and compare), a skip link
  to <main id="main">, theme-color for light and dark.
- The review tape is one picture (role=img); its segments leave the tab
  order (the film strip holds the same links, named).
- Wipe now uses the Desk's delegated prompt, moved to base.html: it names
  the booth and asks the kept-booth question for a kept booth.
- Embed focus rings of its own; rings drawn inside clipping containers;
  the withdraw × at least 24px, 44px under a coarse pointer;
  touch-action:manipulation; strips contain their overscroll; a long
  slug wraps on a phone.
- A truncated why carries its full text in title; a countdown of 48h or
  more reads in days.
Two r2_flow.toml rows for the confirm helper now name base.html, where
the helper moved (anchors unchanged; the gate found them drifted).

Contract: as_antislop S5a. Falsifiers: antislop.toml 86/86 proved (S1-S6, S5a);
all 12 tables 366/366 proved on this tree.
2026-09-28 13:30:31 -07:00
vh ec807fe41b fix(as-S6): the operator's rulings — sentence tagline, no side stripe, matte dot, stripe on ::before
Operator, 2026-09-28: "go with your recommendations".
- Tagline: 'held for review · wipes in {ttl}h unless kept', mono, muted,
  12px, sentence case ("ephemeral" goes, as agreed with booth-dev).
- 'Needs you' rows lose the 3px side stripe; the '? N OPEN' stamp says it.
  The flagged filmstrip frame keeps its bottom stripe.
- The brand dot is matte (glow = live power; a live bench keeps its glow).
- Wipe now and the armed bulk delete carry the hazard stripe on a 3px
  ::before, so the button's own background is honestly what sits under its
  text; the stripe renders as before.

Contract: as_antislop S6. Falsifiers: antislop.toml 49/49 proved (S1-S4, S6);
all 12 tables 329/329 proved on this tree.
2026-09-28 13:30:31 -07:00
vh 1d6821b732 fix(as-S4): reading measure — prose at 72ch, headings step by ~1.2
From the anti-slop run (design-dev, 2026-09-28). A rendered doc ran 110-120
characters a line and its h3 sat at 1.08x its body. Prose blocks in
.markdown-body are capped at 72ch (pre and tables keep the full width, where
they scroll), and h3/h2/h1 step at 1.2/1.44/1.73em. Measured in a real
browser: a long paragraph now reads under 76 characters across, and every
heading step is >= 1.18.

Contract: as_antislop S4. Falsifiers: antislop.toml 43/43 proved (S1-S4);
all 12 tables 323/323 proved on this tree.
2026-09-28 13:30:31 -07:00
vh 44b80e6d9a fix(as-S3): phone layouts — nothing overprints, no word set narrower than itself
From the anti-slop run (design-dev, 2026-09-28). Measured in a real browser
at 390x844 (tests/test_antislop_browser.py), because a layout claim read off
a stylesheet is a guess.

- Bench rows wrap at <=600px (state + name/URL, then who/when/actions); the
  name's column was squeezed to ~53px and overprinted the owner and date.
- The board head and the benches head drop their note under the count, so
  "33 links · 1 pinned" / "3 benches" keep one line.
- An inline doc's bar wraps: the name takes the full width and breaks only
  where it must; it was set one word wide.
- A file tile's download link starts below the ordinal badge.
- The review and compare stages drop the tagline at <=600px (the server
  marks them `page-stage` on <html>), so the header is one line; the Desk
  keeps its tagline (the test's negative control).

Not changed, with reasons in the contract: `.vname` already ellipsises, and
the filmstrip's clipped edge frame is the scroller's "more" cue.
Contract: as_antislop S3. Falsifiers: antislop.toml 41/41 proved (S1-S3);
all 12 tables 321/321 proved on this tree.
2026-09-28 13:30:31 -07:00
vh 54f3531833 fix(as-S2): legibility — nothing fades, labels 11px, sentences 12px
From the anti-slop run (design-dev, 2026-09-28). Faded is not legible:
opacity divides whatever contrast a line had.

- Review arrows: the chip under the thin chevron is 82% dense, not 60%;
  the glyph now clears 7:1 over a white stage by colour (was 3.84:1), and
  reads at pixel level where the detector sampled a 2.9:1 median.
- Filmstrip numbers, the marks' state stamp and the inline ask's state tag
  are labels at 11px (were 9.5 / 10.5 / 10.5px).
- The Desk's section rules, the board note and the bench note are
  sentences at 12px.
- Retired benches: no opacity; the link and URL take --text-muted.
- Embed chrome: answered-ask details and the "recorded:" line inherit the
  host's text colour at full strength (the embed cannot know the host's
  palette); the notes placeholder inherits it at 75%, not the UA grey.

Folded from the heid bug-hunt: the embed's ask title no longer fades
either (Q9), and the legibility claims are also held on the browser's
COMPUTED style (tests/test_antislop_s2_browser.py): a stylesheet grep
cannot see a later rule in the cascade (font-size:1px, color:transparent,
filter:grayscale, a placeholder at opacity:0); the browser can.

Folded after the first gate run: the flagged tray's number, the tile's
"flagged" stamp and compare's A/B badge were still under the 11px label
floor; they take --size-micro too. Two film-number rows are re-anchored
on the .film-ord selector: the tray's line is now identical to it, and
the runner mutates the first match.

Contract: as_antislop S2. Falsifiers: antislop.toml 34/34 proved (S1+S2);
all 12 tables 314/314 proved on this tree.
2026-09-28 13:30:31 -07:00
vh 09071dcb65 fix(as-S1): the house clock — stamps read 0848, no IPs on the page
The anti-slop run (design-dev, 2026-09-28; operator: "start the fix slices")
found raw ISO stamps with microseconds and offsets, the poster's IP address,
and HH:MM in board rows and <time> tooltips. Operator convention 2026-09-24:
a clock the operator reads is 24-hour local time as four digits, no colon.

- `clock` filter: ISO (any precision, any offset), epoch, or the board's
  `YYYY-MM-DD HH:MM` -> `28 Sep 0848` local, year only when not this year's.
  Never raises; what it cannot read is shown as given. No regex (INV-3).
- `byline` filter: a handle is shown, an IP address is not. Stored `by` and
  `answered_by` are unchanged (u2 still records the client host).
- Applied to the marks' answer and memo lines, the inline ask's state tag
  (so the embed chrome inherits it) and the link board's row time, each in a
  <time> whose datetime= carries the stored value exactly.
- `date_stamp` (the created/updated tooltips) renders `YYYY-MM-DD HHMM`.
Folded from the heid bug-hunt (panel 4/4, thread 01M3MGPFKWBX0SJK5HFE0P3AFM):
clock converts a number inside its guard (an int past float range raised,
Q1); a date or ISO week renders no invented 0000 (Q8); byline also hides
addr:port, [v6]:port, addr/prefix and addresses behind invisible characters
(Q7); the board row's author is bylined (Q5). Refuted: Q3 (default Jinja
Undefined has length 0; the test stays as a StrictUndefined guard).
Accepted with reasons: Q4, Q6.

Contract: docs/contracts/as_antislop.contract.md S1. Falsifiers: antislop.toml 15/15 proved (S1);
all 12 tables 295/295 proved on this tree.
2026-09-28 13:30:31 -07:00
vh 190a75a0e1 fix(docs): a posted doc cannot run script on the Booth's origin
Found by design-dev's impeccable run and confirmed at source. Python-Markdown
passes raw HTML through, and doc.html and booth.html render the result |safe.
A <script> in any session's .md ran on the Booth's origin, and a contract that
quoted <pre> opened a real one and swallowed the rest of the doc.

Operator ruling: escape raw HTML (not an allowlist).
- render_doc deregisters Python-Markdown's block and inline HTML processors,
  so raw HTML reaches the serializer as text and is escaped there. Fenced and
  inline code are unchanged.
- Every link href in a doc goes through links.is_safe_href after
  browser-style decoding. Python-Markdown keeps character references in
  attributes, so `java&#115;cript:` reached the browser as `javascript:`.
- is_safe_href reads a backslash as a slash, as a browser does in an http(s)
  URL: `/\evil.test` is `//evil.test`. This also closes the hole on the
  link board.
- A render that raises falls back to raw text, which the template escapes.

Two of 19 live .md files render differently. One is a contract losing the
quoted <pre> that swallowed it. The other is links.md, which renders as a
board, not through render_doc.

heid bug-hunt panel (4/4): the core claim held. Its two concrete edges (the
backslash twin, the unbounded render) are fixed here. Table
tests/mutations/doc_html.toml: 9/9 proved. Suite 951 -> 975.
2026-09-28 10:37:36 -07:00
vh 50bfc7b4ec fix(asks): one submit saves every ask on the page
Operator report (via infra-ops): on a page with several asks, a submit
saved only the pressed one and the reload wiped the rest. Confirmed on
auk-audition: one POST at 15:02:23 saved the last ask on the page, then a
400 from the submit of an ask the reload had just blanked.

Client-side on both surfaces; /answer is unchanged. A submit on a pick
form, while another pick form on the page holds unsent input, sends every
changed ("dirty") pick form: one POST each, to its own action, with
Accept: application/json, in document order. A refusal stops nothing, and
untouched forms are never re-sent. With no other dirty form, a submit is
exactly what it was.

- embed.js (verbatim reports): reloads only when nothing was refused and
  nothing of ours is dirty. Otherwise a server-rendered status line in the
  submit block says what did not save, and input stays. A form the server
  took gets a new baseline. A press during the flight is ignored.
- base.html (marks page, lightbox, review rail): one refresh in place. A
  batch never reloads. Only forms the server took count as sent. In-flight
  state and "just sent" are keyed by form identity (formKey) plus the fields
  at the press, not the DOM node.

Two heid bug-hunt rounds: a four-arm panel on the first cut, then Hulda
alone on the fold. Ten findings reproduced red in a browser before their
fixes. Contracts: U3 "Submitting several asks at once" + INV-8, R2 C3
steps 2, 3 and 3a. Mutation tables u3_submit_all (15) and r2_submit_all
(11), all proved. Suite 928 -> 951.
2026-09-27 17:05:11 -07:00
vh 34ac1683ee memory: snapshot for shutdown — nothing in flight; the r3 seam pass and the upload-name lessons split to detail files 2026-09-25 10:51:51 -07:00
vh 1a59242709 memory: r3 arc live and pushed; the upload route's three known gaps 2026-09-24 18:04:37 -07:00
vh 225ba32209 fix(upload): drop what no name can hold BEFORE the dot rule; a cut never manufactures a kind
Heid bug hunt, hulda, second round on 92c774e:

- A lone surrogate was dropped at the final decode, after the leading-dot
  rule had already run, so "\ud800.forever" came out as .forever, the
  keep marker, and "\ud800.." as "..". The NUL and every unencodable
  character now go first, in one pass, so nothing dropped later can shield
  a dot. Starlette decodes a multipart filename strictly (utf-8, else
  latin-1), so this was not reachable over HTTP; the helper is now right by
  construction regardless.
- A suffix too long to keep was cut like text, and the cut could land on a
  shorter suffix that means something: "….png" out of "….pngxxxx…"
  became an image. A cut that changes classify/doc_kind now has its dots
  neutralised.
- The 16-byte extension threshold was unguarded (every test suffix was 4
  bytes); a .jpeg case pins it.

Falsifiers: tests/mutations/upload_names.toml, 7/7 proved. Not taken here,
as they sit in the upload route rather than this helper: the pickup-id
mkdir outside the try (a FileExistsError race), rmtree(ignore_errors)
hiding a failed cleanup, and a CancelledError skipping cleanup.
2026-09-24 17:04:35 -07:00
vh 92c774e105 fix(upload): a NUL or an over-long name never reaches open()
safe_upload_name let two names through that the filesystem cannot hold,
and each raised at open(): a 500 with the booth torn down. A NUL raised
ValueError, and a 200-character cap let 200 two-byte characters overrun
NAME_MAX (255 bytes, ENAMETOOLONG). The NUL is now removed first, so it
cannot shield a leading dot from the hide rule. The cap is 200 UTF-8
bytes, cut on a character boundary, and it comes out of the stem: the
extension is what classify reads, so a name that used to fit (80 CJK
characters) keeps its kind.

The NUL test posts a raw multipart body: httpx percent-escapes a NUL in
files=, so the server would see a literal %00 and the test would prove
nothing. Falsifiers in tests/mutations/upload_names.toml, 4/4 proved.
Found by design-dev's r3 heid bug hunt (hulda).
2026-09-24 16:54:30 -07:00
vh d54bb04414 fix(r3): a NUL in the raw file path is a 404, not a 500
Compare's stages load their pictures through the catch-all file route, which
caught only OSError around resolve(); an embedded NUL raises ValueError. Same
class as resolve_booth's fix in f8d136a (heid bug hunt on the race fix,
hulda). The upload route's NUL-in-filename 500 is the same class and is left
to booth-dev: it is not on compare's path.
2026-09-24 16:33:27 -07:00
vh 64b403f7eb test(r3): re-anchor the review's C-key row on the guarded handler 2026-09-24 16:00:01 -07:00
vh 8633b1dded fix(r3): judge each rel once per request — a side or review item that vanishes mid-request never 500s
booth-dev's race note after the merge: the compare route resolved each side
in _compare_side and again in _compare_ring, then ring.index(a) raised if the
file vanished (or was relinked outside the booth) between the two; the review
did the same through cring.index(f). The compare ring is now built once and
the sides are judged by membership of it. The review re-judges its item and
scans forward for the next comparable one (usually one step, no longer a
resolve of the whole ring per render); an item no longer comparable renders
the review without a Compare control, and C does nothing.

The contract records the once-per-request rule and that the phone-width wrap
covers doc.html's bar too. r3.toml: 59 rows, four re-anchored.
2026-09-24 15:59:43 -07:00
vh cf08ae3f33 docs(roadmap): r3 landed — the compare ring and stepping rules, compare mode off the parking lot
The stale v1.1 line for compare pairing is corrected to the 2026-09-24
ruling (pairs are picked, never detected). persistent-memory records r3
live and unpushed, and the open race note for design-dev.
2026-09-24 15:57:42 -07:00
vh f8d136a521 fix(r3): fold heid's bug hunt — no link offers a pair that 404s, NUL booth names, a FIFO marker, encoded view-state names
Navigation was built from the review ring while the compare GET also demands
containment, so an outside symlink (which stays in the ring) was offered by
the strip, the steps, the review's Compare control and the flag landing, and
404ed on arrival. Every one is now built from the compare ring (the review
ring filtered by the same conjunction, _in_booth).

Two pre-existing gaps compare inherits, fixed at the source: resolve_booth
caught only OSError, so a NUL in the booth segment was a 500; record_view
opened its marker blocking, so a planted FIFO hung every look. Plus: the page
treats %73ide=a as side=a, and the subgrid engine floor is stated. Two
findings refuted (a chorded click mid-drag never fires pointerup, measured;
booth_items never yields an unquotable rel). r3.toml: 57 rows.
2026-09-24 14:39:06 -07:00
vh 23f1bdb41f fix(r3): fold heid's code review — equal stage widths, the axis guard, players, and tests that read the observable
The one drift: the separator was a border on B, making B's stage 1px
narrower than A's; it is now a 1px column gap, so the stages are the same
size to the pixel. Tests now read what the contract promises instead of a
proxy: the strip's ring order, the full bakeoff sequence, 1:1 and Fit by
geometry, the 900px break from both sides, A wrapping, each form naming its
own item, a sibling-prefix symlink, both reveals, the strip's flag, the back
arrow unlinked, a one-axis picture, a focused player, two videos with no
toggle. The contract names .cmp-cap, a press on a stage, INV-4's URL-driven
picker and the redirect branch's isinstance check. r3.toml gains ten rows.
2026-09-24 13:54:29 -07:00
vh 8c7fe77841 feat(r3): compare — two picked rels side by side, linked stepping, synced pan, flag the winner
GET /b/{name}/compare with the conjunction 404 (containment AND the review
ring), both sides recorded as seen, view state (side, link) mapped from a
closed set onto every link, side-keyed regions, and back=compare in
_mark_redirect. compare.html: two stages sharing one set of rows, the strip
as picker (the side active now), linked and per-side stepping, X/L/Z/A/B/C
keys under the review's guards, synced pan by fraction with an echo guard,
per-side blur reveals, JS-off parity.

The stage machinery moves out of view.html into _stage_js.html
(BoothMode.bind, BoothStage.attach), shared by the review and compare. The
review gains a Compare control and a C key. At phone width a full top bar
wraps.

Tables: r2c's 15 stage rows re-pointed to _stage_js.html; r2b's phone
top-bar row re-anchored (the wrap made it vacuous alone); new r3.toml. The
contract records the wrap, equal stages and C on the compare page.
2026-09-24 13:26:11 -07:00
vh 5d785fe3c4 docs(contract): r3 compare — two picked items side by side, linked stepping, synced pan, flag the winner
Restores the contract as it stood at 1593ea2 (proposed a8428dc, booth-dev's
seam pass folded ebd7729/33d9175/05ad6c4, heid's contract panel folded
1593ea2). Those commits lived only in a work clone under /tmp, which the
2026-09-24 reboot wiped; the text is unchanged.
2026-09-24 13:19:22 -07:00
vh 47b39bca53 memory: snapshot for context clear — waiting on design-dev's r3 contract 2026-09-24 08:27:31 -07:00
vh d5ead3f613 memory: the operator kept 768-wide thumbnails 2026-09-24 08:20:50 -07:00
vh 8a78a9bd1d fix(blur): writes are strict, so a set the writer cannot read is never overwritten
groa's late retry on the blur bug-hunt, adjudicated against the landed code.
Its four bugs were already fixed, but a robustness note (mkstemp's 0600 locks
out a reader under another uid, which then "sees nothing and replaces it")
pointed at a real gap. set_blurred built on read_blurred, the renderer's
lenient reader, which turns an unreadable, oversized or malformed
`.blurred.json` into an empty set. The writer then replaced the file, and
whatever it held was gone. This is the `.marks.json` wipe of 2026-09-21 in a
new module, and it shipped for a night.

- `_load` is the one parse with two postures. read_blurred maps its refusal to
  "nothing blurred" (a damaged file costs the blur, never the page).
  set_blurred lets it raise BlurUnwritable, which the route answers with 409
  and the CLI with exit 3, and changes nothing.
- It refuses only for a REGULAR file it cannot read. A link, a directory or a
  FIFO at either name holds no set anyone wrote, so it reads as empty, and the
  postcondition judges whether the write can land: a link is replaced, a
  directory refused.
- The file is 0644 again, as the line-format writer left it (fchmod after
  mkstemp).

The open flags in `_read_capped` became a second layer behind the new lstat
check, and the mutation run caught their rows VACUOUS through the public API.
They are now held to account by direct tests, because they still close the
lstat-to-open race. blur_storage.toml: 25/25. No second panel was run: this
folds one reviewer note plus the repo's own recorded lesson, with a test and
a proved row for each behaviour.
2026-09-24 00:56:25 -07:00
vh 7d4a26f486 memory: r2c live, the push, and a relayed approval that was held 2026-09-24 00:33:41 -07:00
vh fde082e733 merge(r2c): the review stage fills, its arrows sit at the picture, 1:1 pans
design-dev's r2c round, merged on the operator's direct approval. It answers
his ask from 2026-09-23: fit and 1:1 modes, arrows at the image's edge rather
than the stage's, and click-and-pan in 1:1 with native image drag defeated.

- Fit fills the stage, up or down, with or without JS; 1:1 is natural pixels,
  and every pixel is reachable. The operator ruled that Fit may enlarge.
- The toggle shows for every picture. The mode lives on <html> as `stage-one`,
  set by the head script before the stage exists, so a 1:1 reel never flashes
  Fit. It persists per viewer in localStorage (inside a try) and follows other
  tabs.
- The arrows sit 8px outside the drawn picture, clamped inside the stage.
- 1:1 drag-to-pan: grab convention, a 4px threshold, pointer capture, and the
  picture is not draggable.

Templates only (view.html, base.html); no server change. The two test changes
are declared in r2c_review_stage.contract.md: the r2b reveal test asserts "no
blur" (Fit keeps a drop shadow), and the r2_flow 360px-arrow row is retired
with successors in r2c.toml. Contract panel and both code panels 4/4.
2026-09-24 00:30:05 -07:00
vh 7c879e6038 fix(review): the heid code-review and bug-hunt panels on r2c, folded (both 4/4 with retries)
- 1:1 start-aligns. The centred flex item overflowed both sides and the
  start was unreachable; measured, a 3000px picture hid its leftmost
  980px. Auto margins still centre a small picture.
- Drag lifecycle: a move with no button ends the drag, so a press
  released outside the stage never pans on a later hover. Capture is now
  load-bearing in a test. The threshold is 4px of total movement.
- A press on the stage's own scrollbar is never a pan. The arrows clamp
  to the stage's client box, so they are never under a classic
  scrollbar. The test runs a browser without --hide-scrollbars and
  asserts the gutter exists.
- Stacked, the arrows' CSS spot is the stage's centre (30vh), set in
  view.html because base.html lost to the page's later rule.
- The stage reveal is `hidden` until bound, and keeps Fit's drop shadow
  when revealed. A blurred picture composes blur() drop-shadow().
- The mode follows another tab. A failed or unknown size returns the
  arrows to their CSS spot.
- Tests: object-position, vertical centring, the Fit half of
  aria-pressed, a storage read that throws, a large picture's toggle,
  Fit forgetting 1:1, single-axis pan.
- Declared: the r2b reveal test reads "no blur" (the shadow stays), and
  the r2_flow 360px-offset row is retired.

Mutation tables 137/137 across four. 810 passed.
2026-09-24 00:20:15 -07:00
vh 7151a45ec2 feat(review): the review stage fills, its arrows sit at the picture, 1:1 pans (r2c)
The operator: "fit and 1:1 modes as well as moving the forward and back
arrows closer to the edge of the image ... mouse click and pan for 1:1
mode if it exceeds page width (defeat drag drop of image)". Ruled: "Fit
may enlarge."

- Fit: the picture's box is the stage's inner box, and object-fit: contain
  draws it whole at the largest size that fits, up or down, never
  cropped. It works with or without JS. 1:1 is natural pixels.
- The Fit | 1:1 toggle shows for every picture; the per-picture hide is
  gone. It stays hidden without JS.
- The mode persists as `stage-one` on <html>, set by the head script
  before the stage exists, so a 1:1 reel never paints a stage in Fit.
  Anything stored but "one" reads as Fit. Storage never raises.
- The arrows sit wholly outside the DRAWN picture (near edge 8px),
  clamped 8px inside the stage. They sit over the picture only when it
  spans the stage, and never over the rail. They are re-placed on load,
  resize, mode switch and 1:1 scroll, and keep their CSS spot until the
  drawn box is known.
- 1:1 drag-to-pan when the picture overflows either axis: the picture
  follows the pointer, a 4px threshold, pointer capture, grab/grabbing.
  The picture is draggable=false. The stage's reveal button moves out of
  the scrolled content to sit over the stage (a pan carried it off), so
  no control is a pan source.

Contract docs/contracts/r2c_review_stage.contract.md (heid contract
panel 4/4 folded; it changed the no-flash mechanism). Declared test
changes: the Nyx stage-edge arrow test is replaced; the stage class and
the toggle's `hidden` are updated. tests/mutations/r2c.toml 16/16. 803
passed.
2026-09-24 00:20:15 -07:00
vh 0781aa5ee5 docs: a GET of a booth page records a look, so live checks must not sweep :8090
Two sessions' post-deploy sweeps on 2026-09-23 recorded a look at every booth,
which emptied "new since you looked" and collapsed the Desk's last section
into reverse name order. CLAUDE.md now says how to check the live service
without recording anything, and persistent-memory records the Desk ruling and
the three booths it hid.
2026-09-23 23:08:19 -07:00
vh 1d31ab05de merge(thumbs): thumbnails sized for the tile's width at 2x, and a cache that cannot be planted
Operator-approved 2026-09-23 ("fix it, one bigger thumbnail"), after his
report that sindra-nude-final looked "blurry until selected". c2b1454 sizes
thumbnails at 768 wide (the widest desktop tile, doubled for a 2x screen) and
caps them at 4096 tall. A browser test holds the number against the rendered
grid. c19d8c9 folds the heid bug-hunt (4/4 arms, five seat-executed probes):
cache hits must be regular files carrying the source's exact mtime, the cache
dirs never follow a link, the temp file is mkstemp, palette alpha and EXIF
orientation survive, and there is a 64 MP decode budget. 828 passed on the
branch; thumbs.toml 14/14.
2026-09-23 23:07:07 -07:00
vh 6880ab3059 merge(blur): the blur set round-trips any rel, in .blurred.json, with one writer
Operator-ruled 2026-09-23 ("fix the blur"). 4cfbce5 is the fix: a JSON-array
blur set through stdlib-only booth/blur.py, shared by the service and `booth
blur`, plus Item.blurred_self so blur state has one reader. c1f5543 folds the
heid bug-hunt on it (hulda, regin, kimi). The format moves to its own name,
.blurred.json, because sniffing one file for two formats recreated the
wrong-item bug. The writer is judged by its reader, so a planted directory is
a 409 and not a 500. A lone surrogate is dropped, the writer respects the
reader's size cap, and the route and the CLI share one check_rel predicate.
853 passed on the branch; blur_storage.toml 20/20.
2026-09-23 23:07:07 -07:00
vh c19d8c9718 fix(thumbs): fold the heid bug-hunt: a cache that cannot be planted, alpha, orientation
The heid bug-hunt panel on c2b1454 (4/4 arms, five seat-executed probes). The
new size rules governed only cache MISSES; the hit path trusted a name and an
mtime, inside a directory any fleet session can write into.

- A cache hit is a REGULAR file (lstat) carrying its source's EXACT mtime (4/4).
  A planted directory at the cache path was returned as the thumbnail, and a
  source replaced by `cp -p` or an archive extract kept an older stamp that
  `>=` served forever. The encoder now stamps the thumbnail with the source's
  mtime, so any change to the source is a miss.
- The cache directories are made component by component and never through a
  link (seat P4). A `.thumbs` planted as a link put the cache outside the
  booth, beyond the sweep. The booth-mtime restore now keys on creating
  `.thumbs` itself.
- The temp file is mkstemp (4/4, seat P5). The old `<out>.<pid>.tmp` was
  predictable, and a link planted there made the encoder overwrite its target
  (600 B became 316,400 B).
- Palette transparency survives (3/4, seat-executed, and INTRODUCED by
  c2b1454). The fits-but-heavy branch newly re-encoded palette PNGs, and
  getbands() of mode P has no A even with tRNS.
- EXIF orientation is honoured for sizing and for the saved image (groa,
  seat-verified). A camera portrait stored sideways was sized and tiled as a
  landscape.
- A 64 MP decode budget (2/4). A header claims any size, and a failure is not
  cached, so every request re-decoded it.
- The cache name carries the whole rule: width, height cap, quality and an
  encoding version (groa). The width alone would have served stale bytes after
  a quality change.

Declined: the utime-restore failing on a foreign-owned booth (booths are the
service user's), and regin's two solos (the THUMB_MAX export is not imported
anywhere; the live fixture is function-scoped). thumbs.toml: 14/14 proved.
2026-09-23 23:06:45 -07:00
vh c1f5543b77 fix(blur): fold the heid bug-hunt: two file names, a reader-judged writer, one predicate
The heid bug-hunt panel on 4cfbce5 (hulda, regin, kimi; groa timed out) found
four real defects in the round-trip fix, and three of its arms converged on the
worst: it re-created the bug it existed to fix.

- Two names, never a sniffed file (3/3). JSON went into the OLD `.blurred`, and
  the reader guessed the format from the bytes, so a legacy file whose one line
  is an item named `["a.png"]` read as {"a.png"} and blurred the neighbour. The
  set now lives in `.blurred.json`, JSON only. The legacy `.blurred` is read as
  lines only, and only while `.blurred.json` is absent; the first write retires
  it, after the new file is in place.
- A planted directory is a 409, not a 500 (2/3 plus a third angle, executed by
  the seat). The reader was hardened against it and the writer was not:
  os.replace and unlink raised IsADirectoryError through the route. Now the
  writer is judged by its reader: set_blurred re-reads after writing and raises
  BlurUnwritable unless the set on disk is the set asked for. That one check
  covers a directory at either name, a permission and a race.
- A lone surrogate is dropped on read (hulda, executed). `"\ud800"` is a valid
  JSON string that no filename can produce, and the UTF-8 encode raised on it
  at every later write.
- The writer respects the reader's size cap (2/3). Nothing capped the write,
  and the reader reads an oversized file as EMPTY, which reveals everything.
- One predicate, check_rel, for the route and the CLI (2/3). The CLI's `*..*`
  substring guard refused `a..b.png`, which the route accepts. It also refuses
  an empty path now (regin, kimi), and every item is checked before any is
  written.
- `booth blur` fails closed, with a message and exit 3, when its package is
  missing (kimi), as `link` already does.

Declined, with reasons: the Item positional-constructor break (booth_items is
the only constructor, INV-1), the fdopen fd leak and the short read (not
constructible on a local filesystem, and the `.seen` shape), and
unreadable-reads-as-revealed (blur is cosmetic; the `.seen` posture).
blur_storage.toml: 20/20 proved. One row came back VACUOUS on its first run,
because `set() or X` is X, and was rewritten before counting.
2026-09-23 23:01:18 -07:00
vh 64f64889a2 fix(desk): "everything else" is last UPDATED first, not last activity
The operator, on the live Desk: "how is this last activity first?" It was not,
usefully. The section sorted by `_newest_mtime`, which counts a look (`.viewed`),
so opening a booth moved it up. Tonight two post-deploy checks fetched every
booth page within half a second, which recorded 22 looks at once and collapsed
the section into reverse name order through the (mtime, name) tie-break.
Meanwhile each row shows "updated X ago", which is `landed_at`, a different
clock from the one the list was sorted by.

Operator ruling: "last activity can just be last time the booth was updated,
not necessarily operator's last activity." The section now sorts by
`(-landed_at, name)`, the date the row shows, labelled "last updated first".
Looking, flagging and blurring no longer move a booth. `list_booths` keeps its
own order for its other readers, and `_newest_mtime` still feeds lifetime.

The r2_flow contract (§3, the ordering table, INV-5) and ROADMAP's ordering row
are amended to match. Two tests and two r2_flow.toml rows cover it (25/25).
2026-09-23 22:55:10 -07:00
vh c2b1454358 fix(thumbs): size thumbnails for the tile's width at 2x, not 512 on the long side
The operator on sindra-nude-final: "the images look blurry until they're
selected and blown up." The cap was 512px on the LONGEST side, which the
comment called "comfortably above any tile size", and it was, for a square. A
gallery tile is sized by its WIDTH, though, and a 704x1408 portrait got 256px
of width for a tile Chromium renders at 361 CSS px. That is 1.4x stretched at
1x density and 2.8x on a 2x screen. The review stage serves the original,
which is why it looked sharp once opened.

- THUMB_WIDTH = 768: the widest desktop tile (3 columns, 1440px and up,
  measured at 321-361 CSS px across viewports) doubled for a 2x screen.
  THUMB_HEIGHT_MAX = 4096 stops a long screenshot going through at full height.
- An original that fits the bounds is served as-is only when it is also light
  (<= 64 KB; 768-wide thumbnails average 39 KB over the 381 live images) or
  animated, since a thumbnail is one frame. Fitting a tile in pixels is not
  being cheap in bytes: these portraits are ~1.1 MB PNGs.
- The size rule is in the cache name (`<rel>.768w.webp`). The live 512-cap
  thumbnails are newer than their sources, so the mtime check alone would have
  served them forever. The old files are orphans, swept with their booth.
- tests/test_thumbs_browser.py holds THUMB_WIDTH against the rendered grid at
  1440, 1920 and 2560. The constant is a layout number, and a redesign that
  widens the tiles turns it red instead of soft.

Measured cost, all 381 live images: 4.8 MB -> 14.2 MB of thumbnails, still ~27x
under the 386 MB of originals. Known limit: the 2-column (<=472px) and 1-column
(<=650px) reflows are softer than 768 covers at 2x. tests/mutations/thumbs.toml
proves 7 falsifiers.
2026-09-23 22:23:03 -07:00
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
vh cce6a20abe merge(r2b): the Desk row, booth dates, and the theme toggle
design-dev's r2b merge 2 (D1 + D1b + D3), merged on the operator's approval
with both heid panels folded (code review and bug hunt, 4/4 each), landed after
merge 1 and its live check so a live regression points at one of the two.

436d234 is the feature. The Desk row gets an always-visible lifetime pill (kept,
held, counting), with zip / keep|release / wipe floating over the preview strip
on hover or focus and taking no room; on touch they are the row's last line.
Booth dates render on the row and the booth header from created_at (statx birth
time) and landed_at: four never-raise date filters in app.py, one `now` per
page, and a date the filesystem cannot give or the calendar cannot hold renders
nothing. The System / Light / Dark toggle is stored per viewer, applied before
first paint, and reaches the ask chrome embed.js mounts inside verbatim pages
(only the fragments it mounted; an author's own .bk-ask is never marked).
_svos_tokens.css is re-vendored at the same SVOS SHA with a scoping-only
transform.

1558a7f folds both panels.
2026-09-23 21:44:14 -07:00
vh b92b00215f merge(r2b): reveal all, and the booth blur control
design-dev's r2b merge 1 (D2 + D2b), merged on the operator's approval after
design-dev's "merge it" with both heid panels folded (code review and bug hunt,
4/4 each).

5ded5ff is the feature: a per-viewer "reveal all" for blurred items, and the
whole-booth fog control on the booth page, the review and the Desk. 75623c7
folds both panels, and two of its edits land in our code. set_booth_blurred no
longer touch()es through a planted .blurbooth symlink: anything already at the
name reads as fogged and nothing is written, otherwise it creates with
O_CREAT|O_EXCL|O_NOFOLLOW (the class record_view was hardened against).
booth_blur_all only redirects back to the review for a member of the review
ring, as the mark routes do.

20f1cb8 and ca0641f are test-only: opt-in Playwright traces for failing browser
tests, then a test browser with no internet in both fixtures, each with a
positive control (an external host fails fast, a Booth page still goes idle).
The flake's cause is NOT confirmed: 0 reds in 24 untraced runs after the change
is consistent with the fix but no trace ever caught the stalled request.
2026-09-23 21:41:46 -07:00
vh 1558a7fa07 fix(desk): the heid code-review and bug-hunt panels on r2b merge 2, folded
The bug hunt (4/4) and code review (4/4) were both clean on mechanism.
Their shared catch was the one-sided minute check.

Dates:
- The date filters never raise. One clock outside the calendar's range
  500'd the Desk for every booth, because every row renders in one
  response. An unrenderable date now renders nothing.
- "Updated" shows whenever it differs from "created" by a minute or more,
  either way. Copied content is often older than its folder.
- A clock ahead of now shows its date, never "just now".
- A day is 24h ("1d ago" never appeared).
The row:
- The controls are last in the markup, so the booth's name comes first in
  tab order and wipe last. The cluster is placed over the strip from the
  row's box.
The theme:
- A choice made in one tab moves the Booth's other open tabs.
- The theme mark goes only on ask fragments the embed mounted.
Tests, strengthened after the code review:
- the pill is visible at rest;
- keyboard focus reveals the controls;
- the controls act with scripts off;
- Reveal all reaches the doc page;
- the high-contrast check reads tokens that actually differ;
- the art-light extras are written from SVOS, not derived from the
  copies;
- two overstated mutation rows are replaced (one was a runtime no-op, one
  went red through a syntax error).
Contract amended.

r2b.toml 55/55 proved. 799 passed.
2026-09-23 20:03:32 -07:00
vh 436d234ca0 feat(desk): the Desk row, booth dates, and the theme toggle (r2b merge 2: D1 + D1b + D3)
Operator rulings, 2026-09-23.

D1, the Desk row:
- Kept vs ephemeral reads at a glance: an always-visible lifetime pill in
  the right column (sage ★ kept, amber held, ◷ counting down).
- The facts line is facts only.
- zip / keep|release / wipe are one cluster, with zip out of the middle.
  Where a real hover exists it floats over the preview strip (covering
  pictures, never information), appears on hover or keyboard focus, and
  takes no room. Anywhere else (touch, any coarse pointer) it is the
  row's last line, visible, with 32px controls. × hides too (the operator
  answered yes).
D1b: "created 12 Sep" (filesystem birth time; nothing when unknown) and
  "updated 5d ago" (the content clock), as <time> facts on the row and in
  the booth header, from one macro and one clock per page.
D3, the theme toggle: System · Light · Dark in the top bar.
- Stored in localStorage and applied in <head> before any stylesheet.
- System removes data-theme, so the OS query follows the OS live, with
  no listener.
- The token sheet is re-vendored at the same SVOS SHA with a scoping-only
  transform (155 declarations, the same set, both directions), so forced
  themes win over the OS and high contrast follows the theme in effect.
- The ask chrome inside verbatim pages follows the choice through
  data-bk-theme on our own fragments, live across tabs. The host page's
  <html> is never touched.

Declared test changes:
- two row tests replaced;
- the wipe-dialog test hovers first;
- four r2_flow rows retired, with successors in r2b.toml (45/45).
785 passed.
2026-09-23 19:25:32 -07:00
vh ca0641f55b test: the browser tests run with no internet
Every Booth page asks fonts.googleapis.com for its faces, and
wait_until="networkidle" waits for that request. A stalled request to
Google therefore held a page until goto's 30s timeout. That is the
failure the full-suite flake shows: Page.goto timeouts in tests far apart
within one run. A stalled font request reproduces it exactly.

Whether that was THE cause is not proven:
- 23 traced runs went green, against 1 red in 8 untraced;
- no trace captured the pending request.
A test that depends on Google being reachable is wrong regardless.

Both browser fixtures now launch Chromium with every hostname but
127.0.0.1 failing DNS at once. Pages fall back to the system font stacks
the tokens declare. Positive control in each file: an external host fails
with ERR_NAME_NOT_RESOLVED in under 3s, and a Booth page still goes idle.
Mutation-proved (r2b.toml 28/28). 776 passed.
2026-09-23 19:05:12 -07:00
vh 20f1cb8594 test: opt-in Playwright traces for browser tests that fail
The browser tests flake under full-suite load only; every failing test
passes alone. BOOTH_TRACE=1 keeps a full trace (screenshots and DOM
snapshots) for each browser test that fails. BOOTH_TRACE=light keeps
actions and network only, because the full mode perturbs the timing it
watches: 0/8 red traced against 1/8 untraced on the same tree. Off by
default. Positive control: a deliberately failing test keeps a trace,
and a passing one keeps nothing.
2026-09-23 18:41:32 -07:00
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